@asolens/mcp
v0.4.0
Published
ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine
Maintainers
Readme
@asolens/mcp
See how your app really performs in App Store search — and how much to trust every number you get.
ASOLens gives your AI assistant live App Store data. You ask a question in plain language; it goes and looks.
claude mcp add asolens -s user -- npx -y @asolens/mcpMost of it needs no account and no API key — install it and start asking. Only
two things need a credential you set yourself: reading your own listing, and
search volume (paid, and us only).
What you can do with it
- Find where you rank for any keyword, in any of 17 storefronts — and how many apps you were ranked against, which is what decides whether the rank means anything at all.
- Check what a keyword really means to searchers before you build a strategy on it. That is the example below, and it is the thing a rank alone cannot tell you.
- Audit your keyword field — which words are working, which are dead weight, and which could not be tested.
- Size up the competition — who owns your niche, where they are weak, and what their users complain about.
- Prove a change worked. Snapshot before, ship the metadata, diff after.
The thing a rank cannot tell you
Ask the App Store what people type after division:
division -> the division resurgence · division math games · the division 2
division games · division math · division flash cards
division 2 map · division mobile · division two
lexus, a division of toyota motor sales, u.s.a.Three unrelated audiences in one word: people hunting Ubisoft's shooter, people
wanting maths practice, and someone looking for a car dealership. Ranking near
the top for division tells you nothing about which of them you reached.
It is not a rare case:
- After
addition, the top two suggestions areaddition financialandaddition financial credit union— a credit union. - After
table, people typeopen table,tablecheck,tabelog— restaurant booking — andtableau, the business-intelligence tool.
A maths app can rank beautifully for all three and get no users from any of them. ASOLens checks this for free, in every storefront, before you commit.
(Measured 2026-09-06 in the us storefront. Autocomplete drifts — run it
yourself and see what you get.)
What makes it different
Every number tells you how much to trust it. Each value comes back labelled
observed (with where it came from and when), modelled (with the model and
the inputs it used), or unavailable (with the reason, and what would fix it).
No score with hidden inputs, ever.
It refuses to guess. No download estimates, no revenue estimates. Nobody outside Apple knows those, and a number you cannot check is worse than no number — because you will act on it.
It tells you when its own answer is suspect. That is the unusual part:
- It tests its probe word against the real store before trusting it. A word your own app already dominates makes every keyword look perfect — one auto-picked word read a keyword field as 29 of 29 words working, which was pure brand recall. Words like that are detected and rejected now, and it says so.
- It separates real placements from arithmetic ones. Ranking in the top 10 of a search that returns 7 apps is not a result, and is counted separately.
- It flags a keyword it could not test, rather than scoring it anyway.
It runs locally and needs no account. Storage is a SQLite file in
~/.asolens/, and with no credentials set it reaches only Apple's public
endpoints, carrying no key, no account and nothing that identifies you or
your install. Two features reach past that, and each needs credentials you
set yourself — see What leaves your machine.
It cannot change your listing. ASOLens only ever reads from Apple. There is no write path to your App Store record, for any tool.
Keyword search volume (paid, us only)
Works, and costs money. Search volume needs your own DataForSEO account (set
DATAFORSEO_LOGINandDATAFORSEO_PASSWORD), and every lookup is billed to it. There is no managed key or billing on our side.Everything else in this README is free and needs no account. If you are evaluating ASOLens, you can skip this section.
Volume is us-only, from every source we tested. DataForSEO's Apple
dataset covers the United States and English only: the endpoint's own docs say
so, every other storefront is rejected with 40501 Invalid Field:
'location_code' (verified live for all 16), and DataForSEO support confirmed
it in writing. The alternative their support offers for other storefronts,
app_data/apple/app_searches, returns App Store rankings, not volume -- what
search_ranks already returns free. Apple Ads' Search Popularity is no
substitute either: measured in one Apple Ads panel, at the 1-5 scale its web UI
exposes, common Swedish terms and two established local competitor apps all
read 1 of 5, the same as terms known to be near-dead; only global names
(instagram, tiktok) reach 5/5. Seeing even that 1-5 reading takes a full
ad-account signup (legal entity, tax ID, permanent currency and time zone), and
the finer 5-100 index third-party blogs describe is documented nowhere by Apple
-- do not sign up expecting it. Every other signal works in
all 17 storefronts.
Even in us, a blank is not a demand signal. No vendor sells "volume for
this keyword"; DataForSEO has only "the keywords this app ranks for", so a term
gets a number when a competitor app the report queried ranks for it. An app's
unfiltered window is its top 50 by volume; niche_report and keyword_table
then look up what the window missed by name, across the app's full ranked
list, and price_keywords does the same for a list you already have. A blank
is therefore either not asked (window only) or asked by name and still
not found -- each unavailable reason says which -- and neither is about the
keyword: the first is about how deep the window went, the second about which
apps were queried. Measured 2026-09-02 in us:
math for kids came back unpriced while 248 apps compete for it.
So the tool is built to be useful without volume. Ranks, keyword coverage,
audit_keywords, cohesion, autocomplete depth and low-star reviews work in
every storefront, and in the week this was written every metadata change
shipped from a real audit came from those, not from a volume number. The
closest demand signal that works everywhere is autocomplete -- see The thing
a rank cannot tell you.
Honesty rule. Every number is labelled observed (read from Apple, with
its source and fetch time), modelled (computed, with the model name and its
inputs -- niche_report omits the per-row inputs by default to stay small; pass
include_inputs: true), or unavailable (with the reason and what would
unlock it). A field with no label is a plain observed passthrough, such as
rank or resultCount, and carries day/fetchedAt/fromCache.
Install
Requires Node.js >= 22.13.0, for the built-in node:sqlite module (check
with node --version); the database needs no separate install.
Claude Code
claude mcp add asolens -s user -- npx -y @asolens/mcp-s user makes it available in every project. Drop the flag for the current
project only, or use -s project to commit the registration for your team.
Check it with claude mcp list, then restart Claude Code.
Claude Desktop — add to your MCP config file:
{
"mcpServers": {
"asolens": {
"command": "npx",
"args": ["-y", "@asolens/mcp"]
}
}
}Codex — add to config.toml (check your Codex version's docs — the MCP
config key and format have changed across releases):
[mcp_servers.asolens]
command = "npx"
args = ["-y", "@asolens/mcp"]The first run creates ~/.asolens/ (config file + SQLite database) and
prints a ready line on stderr once connected. To remove it again:
claude mcp remove asolens -s user.
Recipes
ASOLens has no CLI — you talk to your assistant, and it calls the tools. So every example here is just a sentence you type.
| You want to know | Ask something like | |---|---| | Is my keyword field pulling its weight? | "Audit my keyword field in br." — worked example | | Which market should I work on next? | "Compare jp, br, mx and de for my app." — worked example | | Did my metadata change actually help? | "Snapshot these keywords as 'before' for br." …ship the change… "Now diff 'before' against 'after'." — worked example | | 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 | | Are these two keywords the same search? | "Compare 'math games' and 'math games for kids' in my main market." — worked example | | Who am I actually competing with? | "Build a niche report for these three keywords against my app." — worked example | | What do users actually complain about? | "Pull the low-star reviews for app 284882215 in us." |
A first session
If you have just installed it and want the shortest useful path:
- "Find my app on the App Store." — resolves the id everything else uses.
- "What ranks for
<your main keyword>in<your biggest market>?" — the ground truth you are about to reason from. - "Suggest autocomplete keywords for
<that keyword>." — free, and the fastest way to find out whether searchers mean what you assume. This is the step that catches adivisionbefore you build a strategy on it. - "Audit my keyword field in
<that market>." — needs App Store Connect credentials; without them, steps 1-3 still work.
Nothing here can change your App Store listing. ASOLens only ever reads
from Apple — the App Store Connect client issues no write of any kind, not for
any tool. It writes plenty locally: almost every tool caches what it fetched
into the SQLite file under ~/.asolens/, which is what makes a second call
cheap and a snapshot_diff possible. One write leaves your machine, and not to
Apple: a DataForSEO lookup POSTs an app id to create a billable task -- carrying
the keyword text too, when you are pricing keywords by name.
Config
Settings live in ~/.asolens/config.json, created on first run. Each one can
be overridden per run with an environment variable.
Settings
| Setting | Env override | Default | What it does |
|---|---|---|---|
| defaultCountry | ASOLENS_DEFAULT_COUNTRY | us | Storefront used when a tool call omits country. |
| verbose | ASOLENS_VERBOSE | false | One line per remote call on stderr, plus a calls array on every response — see Verbose call log. |
| — | ASOLENS_HOME | ~/.asolens | Where the config file and SQLite database live. Point it elsewhere to run isolated instances. |
Optional credentials
Both are off unless you set them, and neither is required to install.
Without them, keyword_table and niche_report still run in full --
volume just comes back unavailable with a reason and a fix, never an
error. price_keywords is the one exception: it exists only to buy this
data, so without credentials it has nothing to do and returns unavailable
for every keyword rather than an error.
| Set | Unlocks | Cost |
|---|---|---|
| DATAFORSEO_LOGINDATAFORSEO_PASSWORD | Keyword search volume in keyword_table and niche_report; all of price_keywords (see notice) | Paid, and us only — details |
| ASC_API_ISSUER_IDASC_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 |
Two keys you will see but need not set
config.json also contains installId, a random UUID generated once and sent
nowhere in this release, and shareCache, reserved for a future shared-cache
tier and with no effect today. Both are listed here only so they are not a
surprise when you open the file.
What leaves your machine
Requests to Apple's public App Store endpoints — search, lookup,
autocomplete, reviews — which are what answer the tool calls. Worth knowing:
audit_keywords sends each comma-separated entry of your private keywords
field (up to 30) to Apple as a probe, because measuring whether a word works
means asking the store for it. It goes to two endpoints, not one: search, and
— unless you pass suggestions: false — autocomplete. compare_markets runs
that audit once per storefront, up to eight, reading whichever localisation
each storefront resolves to. How much of your listing that reaches depends on
how localised your app is: a fully localised one sends up to eight different
keyword fields, plus anchors from eight localised names and subtitles; an app
with a single localisation sends that one field eight times, because Apple
serves the primary locale wherever a localisation is missing. It never asks
autocomplete, which it turns off deliberately.
Then two paths you have to switch on yourself, by setting the credentials above:
- DataForSEO, for keyword volume: sends the app ids being priced through, a country/language pair, and -- since 0.3.0 -- the keyword text itself, because naming keywords to the vendor is how a term outside an app's free top-50 window gets priced at all. The apps are the competitors a report found, which can include YOUR app when it ranks for those keywords: nothing excludes it. (This said "never your app, never your keywords" until 2026-09-07 -- true before 0.3.0, false the moment by-name pricing shipped.)
- App Store Connect (
api.appstoreconnect.apple.com, a third Apple host beside the publicitunes.apple.comandsearch.itunes.apple.com), to read your own listing text: a request signed with your own.p8key. Five tools reach it --my_listing,audit_keywordsandcompare_marketsby their nature, pluskeyword_ranks(whosecoveragedefaults on, so passcoverage: falseto keep it local) andsnapshot_save(which records coverage with no flag to turn it off). Apple returns data only for apps that key can see, but that limits what comes BACK, not what goes out: the request carries whichever app id you asked about, and for anyone else's app it returns nothing and coverage is silently absent.
There is no telemetry. Your installId is sent nowhere, and vendor results
are never pooled into a shared cache — they stay on your machine, tied to the
account that paid for them. Nothing is sent about you except what a tool call
asks for -- which, for audit_keywords and compare_markets, includes words
read from your own listing rather than typed by you.
Keyword search volume (DataForSEO)
Paid, and us only — see the notice above.
Apple doesn't publish App Store search volume anywhere, and there is no
endpoint that returns it for an arbitrary keyword list. The only path in is
via DataForSEO's Labs product: "the keywords a
given app ranks for, with volume." So ASOLens queries the competitor apps
already surfaced by your keywords' own top rankings (free -- they're already
fetched) and merges their ranked-keyword lists into one volume lookup. This
is why niche_report also returns discoveredKeywords: the top 10
higher-volume terms those competitors rank for that aren't in your own
keyword list yet. price_keywords skips the window entirely and names your
own keyword list to the vendor directly -- see its row in the tools table.
usstorefront only -- see above. Elsewherevolumecomes backunavailableat no cost, and the rest of the report is unaffected.- Costs money. Each competitor app queried is a paid DataForSEO call
(typically a cent or two, billed per keyword row returned), and
keyword_tableandniche_reportquery at most 3 per call by default. Keywords the window missed then cost one more task fee per 150, per app -- even for an app whose window was already free today. Every payload that spent money carriesvendorCost(dollars, that call), and a verbose-mode line for a vendor request names its cost. If a vendor call fails part-way, a tool reports what had already been billed rather than0;price_keywordsreportsvendorCost: nullwhen the amount cannot be determined, whileniche_reportandkeyword_tablekeep a numeric field, so0is the floor they can express.price_keywordsalso returnsestimatedCost(a min/max range with its basis) BEFORE spending. - Cached per app, per country, per UTC day. A repeat call for a
competitor app already queried today costs nothing -- it's served from the
local SQLite cache. Cross-app or cross-day results are never assumed; a
new day (or a new competitor app) means a new paid lookup. The one reader
that looks further back is
suggest_keywords, which spends nothing and shows a price bought on any of the last 7 UTC days, labelled with that day. - Never shared, never pooled. Vendor results are per-install only, same as the rest of this release -- DataForSEO's terms don't address redistribution, so results from your paid calls never leave your machine and are never merged with another install's cache.
- Never
observed. A volume value is alwaysmodelled(withmodel: "dataforseo-labs-apple"and, withinclude_inputs: true, the source competitor app id it came from) -- it's a vendor estimate, not a number Apple returned. - Pass
volume: falseon either tool to skip DataForSEO entirely for that call (e.g. to conserve balance), even with credentials configured.price_keywordshas no such flag -- naming keywords by hand is its whole job, so it always spends when it runs.
Your own listing, and keyword coverage (App Store Connect)
The App Store Connect keywords field is private -- no public endpoint exposes it -- so for your own apps ASOLens reads it from the App Store Connect API. Set two environment variables:
export ASC_API_ISSUER_ID=... # App Store Connect > Users and Access > Integrations
export ASC_API_KEY_ID=...
# the .p8 is read from ~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8,
# the same place Apple's own tools look. Override with ASC_API_PRIVATE_KEY_PATH.Nothing is written anywhere: the key is read once at startup and held in
memory, never copied into the config file or any payload. Without the
credentials every tool still works -- my_listing returns an unavailable
saying what to set, and coverage is simply absent.
my_listing shows the name, subtitle and keywords field per locale for the
iOS version App Store Connect treats as live -- which can be one approved
but not yet released, so check appStoreState -- and reports a draft in review
separately, since what you are about to ship is not what is being indexed. It also tells you
when a storefront has no localization at all and Apple is serving your
primary locale there instead.
The other tools that read that listing -- keyword_ranks (with coverage on),
audit_keywords, compare_markets and snapshot_save -- name the version
they read in listingVersion: versionString and appStoreState (a version
approved but not yet released can count as live, so check the state), any
editableVersion whose text was not read, and fetchedAt. Keywords that
exist only in your own files or an unreleased version are not in it. The
listing is read once per server process, so restart the server after a
release. snapshot_diff reports both sides' versions; null means that
snapshot recorded none (saved before this existed, or without coverage).
keyword_ranks then reports coverage per keyword: which of its words
appear in your indexed text, checked across name + subtitle + keywords
together, because Apple indexes them as one bag. Live, in br:
| keyword | rank | coverage | |
|---|---|---|---|
| tabuada de multiplicação | #11 | 3/3 | tabuada←subtitle, multiplicação←keywords |
| jogos de matemática | #19 | 3/3 | |
| jogos de matemática 1 ano | #34 | 4/5 | |
| matemática divertida para crianças | — | 2/4 | missing divertida, para |
The first row is why coverage is not computed per field: it is only covered because two different fields each supply a word.
looseOnly: true is a weaker match than it looks. A token normally has
to appear in your text exactly. looseOnly means it matched only after
accents were stripped from both sides -- right when you wrote
multiplicação and the query says multiplicacao, but it crosses languages
carelessly: checking the Swedish phrase matte för barn against the
English listing marks för covered, because stripping accents turns it
into for, which sits in "Practice for Years 1-3". That is a false
friend, not a match. Read looseOnly: true as "possibly", and check the
fields it claims to have matched in.
covered: null means the check cannot tell. Chinese, Japanese, Thai,
Lao, Khmer and Burmese are written without spaces between words, and Korean
can write compounds unspaced (MathQuest's Korean name holds 수학게임).
Coverage compares whole words, so a word inside a longer run of such text is
invisible to it -- including a Latin word joined to it, as line is in
LINEマンガ. When that could be happening, the word comes back covered: null
with a note, is listed under cannotTell, and counts as neither covered nor
missing: it may or may not be there. That is when the word is not a separate
word in your listing and either sits inside such a run (a Latin word only as a
whole piece, so quest in MathQuest算数ゲーム is still a miss), or contains
a character from one of those scripts with every one of its characters
somewhere in the listing. Otherwise it is false as usual. Before this,
수학 게임 checked against a listing holding 수학게임 reported both words
missing, so a snapshot_diff against a snapshot saved before this change can
show a coverage change such as 0/2 to 0/2, 2 cannot tell: that is the older
snapshot's misreading, not an edit to your listing.
Coverage is an observation about your text, not a ranking model. Apple
stems and matches in ways nobody outside Apple can enumerate, so a missing
word is a lead worth investigating, not proof you cannot rank -- and full
coverage promises nothing. Two live cases where coverage is 2/2 and the
app does not rank at all: measured in br and au, two queries whose words
were all present in the listing and which still returned nothing.
Coverage is necessary, not sufficient. Common function words (de, para)
are counted too; whether Apple ignores them is not something this tool can
observe, so weigh missing content words more heavily.
What coverage is genuinely for is settling questions about the indexed
text that no amount of rank-watching can answer. A real one: does Apple
split compound words? The Swedish listing contains "Matteträning" and
"Mattespel", so whether the app already covered the bare word matte
was an open question. Coverage answers it by reading the text: for the query
matte för barn, matte comes back not covered -- Apple's index holds
the compounds, not their parts, so the bare word had to be added explicitly.
That had been an unverified hypothesis for hours; one coverage call settled
it.
Walkthroughs
Real runs, with real numbers, measured on the dates given. Each one is a question a solo developer actually has — and several of them are here because the obvious answer turned out to be wrong.
Which market to work on (compare_markets)
Measured in jp, br, mx and de at once. Foreign words are glossed at
first mention.
Runs the keyword-field audit across several storefronts and puts them side by side. This is the question a solo developer with sixteen locales actually has, and no single-market answer settles it.
compare_markets app=<your bundle id> countries=[jp, br, mx, de]
market anchor top50/of ratings median (n)
jp 算数ゲーム 12/29 1 240 (55)
br matemática 1/11 0 8 (43)
mx matemáticos 2/11 0 117 (44)
de lernspiele 1/11 0 2127 (42)The anchor is a column because it is the caveat. Each market is audited with its own anchor against its own keyword field in its own language, so the medians are not commensurable — 8 and 2,127 are not the same kind of number. Showing the anchors lets you see that rather than being told it. The response says whether the spread is large enough to survive: a ~266x range is far too big to be an artefact, so the ordering is real even though the numbers are not comparable one to one; a 2x range would not be.
Rows come back in the order you asked for and are never sorted by median, since sorting would imply exactly the ranking the medians cannot support.
Read the ordering, not the exact counts. inTop50 moves with the
anchor, because a mechanically built probe can ask a question nobody types.
Measured in br on one day, the same eleven words scored 1 of 11 with
the mechanical anchor and 3 of 11 with natural phrasing — phrases like
soma e subtração (Portuguese for "addition and subtraction") or
exercícios de matemática (Portuguese for "maths exercises"), instead of a
generated anchor-plus-word pair:
| | rank | results |
|---|---|---|
| soma e subtração (natural) | #23 | 28 |
| the same word, <anchor> <word> (mechanical) | #118 | 170 |
| exercícios de matemática (natural) | #35 | 175 |
| the same word, <anchor> <word> (mechanical) | #105 | 149 |
Across two independent runs of four markets the ordering was stable and
the counts moved by up to 3x. So compare markets by their ordering, their
rating counts and their medians — and where you know a market's real
phrasing, run audit_keywords for it with probes= and use that count. A
long notReturned list under a mechanical anchor usually means the probe is
wrong, not the words.
Bring your own phrasing where you have it. anchors= and probes=
override per market, so a first pass on auto anchors can be refined market
by market without leaving the tool:
compare_markets app=... countries=[br, de] probes=[
{country: "br", word: "soma", probe: "soma e subtração"},
{country: "br", word: "exercícios", probe: "exercícios de matemática"},
]
br 1/11 -> 3/11The response marks which markets were refined (anchorSource,
probeOverrides) and warns when the table is mixed: a count from
natural phrasing and one from a generated phrase are different
measurements, so a refined market and an unrefined one are less comparable
to each other than either is internally. Refine all of them or none for a
fair ranking. An override naming a storefront that is not being compared is
rejected rather than ignored, since a silent no-op would leave you believing
your phrasing was used.
Anchors are tested, not guessed, as described under audit_keywords
below: a brand-like anchor is discarded (the first auto-anchor for jp, the
transliterated brand, scored 29 of 29), and if every candidate is brand-like
the tool says the results are unusable rather than presenting them.
Before/after a metadata change (snapshot_save, snapshot_diff)
Measured mostly in br, with a Swedish (se) pairs example. The mechanics
are the same everywhere.
Apple publishes no rank history, and this tool's own cache is per-UTC-day. So the "before" of a before/after has to be captured deliberately, while the old build is still live -- once you ship, it is gone.
snapshot_save name="pre-release" app=<your app id> countries=["br"] keywords=[
{keyword: "matemática divertida", label: "target"},
{keyword: "jogos de matemática", label: "regression watch"},
{keyword: "jogos de matemática", label: "control"},
]When storefronts need different keywords, pass pairs instead of the
keywords x countries cross product:
snapshot_save name="baseline" app=<your app id> pairs=[
{keyword: "jogos de matemática", country: "br", label: "control"},
{keyword: "matemática divertida", country: "br", label: "target"},
{keyword: "matematik för barn", country: "se", label: "control"},
{keyword: "times tables", country: "au", label: "control"},
]A real baseline of 13 Brazilian and 9 Nordic keywords is 22 rows as pairs
and 198 as a cross product, nearly all of them nonsense ("matteträning" —
Swedish for "maths training" — in France). Without pairs it has to be split into two snapshots -- and then two
diffs, both of which someone has to remember exist later. keyword_ranks
takes the same pairs input, and returns rows in the order given.
Each row keeps the rank (including null -- "did not rank" is a value,
and "unranked → ranked" is usually the very thing being tested),
resultCount, the top 10 app ids, keyword coverage, and your rating count
against the competitor median, with competitorSampleSize and
competitorSampleTopN beside it, since size alone cannot say how deep the
sample went. That median is over the union of each keyword's top 10 -- the apps
at the TOP of those lists, not the storefront -- and is not comparable with
audit_keywords' (a shallower slice) or niche_report's (which includes your
own app and drops bundles, even where its depth matches).
snapshot_diff before after groups rows by your labels and reports, per
row: the rank change, how resultCount moved, which apps entered or left
the top 10, and -- when your rank is inside the stored top 10 -- how many of
the apps above you are new.
Read the controls first. If keywords you expected to hold still have moved, the comparison is drift and the targets prove nothing. That is what labels are for; they are free text and never interpreted.
Three deliberate refusals:
- No significance or confidence scoring. With small numbers there is no
statistical power, and a confidence label would be theatre. The numbers
and the
resultCountcontext are shown; you judge. - No silently dropped rows. A keyword present in only one snapshot is
listed under
onlyInA/onlyInB, because a vanished row is how a regression disappears from a report. - Coverage absence is recorded, not omitted. A snapshot taken without App Store Connect credentials records why, so a later diff cannot read a configuration change as a change to your listing text.
Auditing your keyword field (audit_keywords)
Measured mostly in se, with comparisons in de, jp, kr and tw.
Foreign words are glossed at first mention.
Which of the words you spent 100 characters on are actually doing anything?
For each word, audit_keywords searches a phrase where that word is the
distinguishing one -- an anchor your app certainly covers, plus the word
under test:
audit_keywords app=<your app> country=se
anchor: "mattespel" (auto, tested against the store -- override with anchor=)
#7/26 mattespel addition #33/81 mattespel barn
#6/22 mattespel subtraktion #35/67 mattespel matematik
...
Apple returned your app for 12 of 12 probes, but only 11 inside the top 50
and 8 inside the top 10. 2 of those 8 were in result sets of 10 apps or
fewer, where anything returned is inside the top 10 -- discount them and
the real count is 6.The four words above are ones a real competitor publishes itself, in its own
name and subtitle: "Matematik Kul: lär dig siffror" / "Barn addition och
subtraktion". They are shown here because they are public. Your field is
read only through your own App Store Connect key, is never stored anywhere
but your machine, and appears nowhere in this README. It is not sealed off,
though: auditing an entry means asking the store about it, so each
comma-separated entry (up to 30) goes to Apple's public search endpoint as a
probe, and to autocomplete as well unless you pass suggestions: false. The
search half is the measurement and cannot be avoided; the autocomplete half
can, and compare_markets always does -- it runs only the search probes, once
per storefront, up to eight, reading the localisation each storefront
resolves to.
The Swedish words here: mattespel is "maths game", barn is "children",
subtraktion and matematik are what they look like. The argument below does
not depend on knowing them — only on the shape of the numbers.
It answers the strategic question as well as the tactical one. Same app, same day, same tool, two storefronts — Germany's anchor is "mathe" (German for "maths"), Japan's is 算数 (Japanese for "arithmetic/maths"):
| | de, anchor "mathe" | jp, anchor 算数 |
|---|---|---|
| returned | 0 of 11 | 26 of 29 |
| inside top 50 | 0 | 11 |
| your ratings | 0 | 1 |
| competitor median | 2,747 | 243 |
Germany says the apps at the top of these results are out of reach -- no amount of keyword editing closes a gap of 2,747 ratings to zero. (It does not say the market is wrong: the median is over the union of each probe's top 5, so it describes those apps, not the storefront.) Japan says the words are mostly right on a single rating, with three of its words worth revisiting (they returned nothing for their probe). Telling those two apart is the entire point of the summary.
The verdict says when its own count is suspect. Two or more words returning nothing under a generated probe is the tell that the anchor is asking the wrong question, so the verdict names them and says the count is probably too low — rather than listing them neutrally beside a number they contradict. Words the caller phrased themselves get no such excuse: those look genuinely dead.
Read the summary before the rows. "Returned at all" and "returned where
anyone would see it" are different questions, and only the second one
matters -- 9 of 11 sounds healthy until you notice one of them is inside the
top 50. Where the app sits versus the apps at the TOP of those probes' results
-- not the competitive field, which this number does not measure -- is
reported as two plain numbers for the same reason: in de the same audit shows 0 ratings
against a competitor median of 2,158, which means keyword edits are not
the lever in that storefront at all, whatever the individual rows say. That
is a different problem from a badly chosen word, and worth knowing before
spending an afternoon on the keywords field.
In jp, kr and tw the probe's space is not neutral. Probes are
built by joining an anchor to a word with a space, and those languages do
not use one between words. Measured by comparing full ranked id lists: kr
"수학 어린이" (Korean for "math kids") and "수학게임" (Korean for "math
game")'s counterpart behave completely differently --
수학 어린이 vs 수학어린이 returned byte-identical results, while
수학 게임 vs 수학게임 shared only 12 of their top 25. Japanese pairs
overlapped 23-24 of 25 with ranks shifting (#12 → #16, #168 → #182). So the
space usually barely matters and occasionally matters a great deal -- the
same shape as the accent finding, and the same conclusion: test both rather
than guess. The tool says this in its own output for those storefronts, and
probes= lets you re-test a word with the unspaced form.
Searching each word alone instead would mostly measure how small your app
is, which is why probes are anchored. Measured live in se, every keyword
searched on its own returned nothing findable -- bråk (Swedish for
"fraction") in a field of 220, tabell (Swedish for "table") at #122 of
211, räkna (Swedish for "to count") unranked in 227 -- because a one-word
search is a fight with 200+ apps that a small app always loses. Anchored to
mattespel (Swedish for "maths game"), the same words separate: mattespel
tabell #2 of 16, mattespel bråk #4 of 13, mattespel barn (Swedish for
"kids") #33 of 81. That spread is the point. The tool cannot tell
you a word is good in the abstract; it tells you which of your words is
working hardest, which is the decision you face with only 100 characters.
The anchor is tested against the store, not guessed. Each candidate is
searched on its own first and discarded on either of two grounds, with the
reason reported in anchorRejected:
- brand -- your app is already top 3 for it, so every probe would return
you whatever the word was. Live: the auto anchor for
jpwas マスクエスト, the transliterated brand, which scored 29 of 29 inside the top 10 -- pure brand recall, and a market that looked like the best investment going. With a real anchor (算数ゲーム, Japanese for "maths game") the same field reads 12 of 29. - narrow -- its own search returns 50 apps or fewer, so probes built on it
land in fields too small to distinguish a working word from a dead one. Live
in
se:matteträning(Swedish for "maths training") returns 32 apps,matteträning barnreturns 7, and the audit read 12 of 12 inside the top 10. The next candidate,mattespel, returns 219 and gives a real measurement.
A probe cannot test a word the anchor already contains. mattespel swallows
spel, so that probe re-runs the anchor; the row is flagged echoesAnchor and
called neither a pass nor a fail rather than scored. echoesAnchor is
three-valued: true echoes, false does not, and null means the check
could not be run at all — the anchor's list could not be fetched, or that
probe returned no apps, or the anchor's own search returned none (which
leaves nothing to compare against even for a probe that returned hundreds). A null is not a false, and
probesEchoingAnchor counts only the known trues. It takes two signals to
detect, because each alone is wrong: practice contains ice and
practice ice is a perfectly good probe, while overlap alone flagged German
kinder at 56% -- a word lernspiele does not contain -- simply because
German kids' learning games are the same apps.
If the anchor still produces a phrase nobody would type, pass anchor=, or
override individual words with probes=[{word, probe}] (natural phrasing like
aprender a contar beats matemática contar). A word that returns nothing is
a lead, not proof -- a different phrasing can rank where one does not.
What each word's searchers actually want
Every word row carries suggestions: what App Store autocomplete says the
people typing that word are looking for. This is the one signal a rank cannot
give you, and reading it is usually the fastest way to find a dead keyword.
tabell was the best-ranking word in a Swedish keyword field -- #2 of 16.
The people who type it want Swedish football league tables:
tabell #2/16 tabellen.se / allsvenskan tabell / fotboll tabellRanking first for a word whose searchers want something else is worth nothing. This is the coverage lesson one step further out: coverage is necessary and not sufficient, and so is ranking.
The same kinds of word are hijacked in every language, which is worth knowing before you audit your own:
| The word you meant | Who actually owns that search |
|---|---|
| "learn" verbs -- lära, aprender, apprendre | language-learning apps, in every locale tested |
| "count" verbs -- räkna, contar | calorie and step counters |
| dividir | PDF splitters and bill-splitting apps |
| division | Ubisoft's The Division |
| "exercise" -- exercícios, ejercicio | gym and home-workout apps |
| school words -- escola, primaire | school admin portals |
Plus outright false friends: French addition is the restaurant bill; Mexican
Spanish kinder is chocolate eggs (it ranks #11 of 18 precisely because that
field is tiny and irrelevant). What survives everywhere is the specific noun
for the thing you actually do -- bruchrechnen (German: "fraction
calculation"), fracciones (Spanish: "fractions"), subtração (Portuguese:
"subtraction").
Free, works in all 17 storefronts, and adds no measurable wall-clock
(autocomplete has its own request lane). It is also the only intent signal
available outside us, where keyword volume cannot be bought at all. Same
caveats as the depth column below: it is prefix completion capped at 10, so
an empty list means nothing popular extends the word rather than that
nobody searches it, and it shows direction, never size. Pass
suggestions: false to leave it out.
Comparing two queries (compare_queries)
Measured in br. The behaviour is the same everywhere.
Rank is relative to whoever else is in that result set, so an app at #19 for
one phrase and #131 for another has not necessarily been hurt by the extra
word -- the two searches may simply have different competitors. That
inference is the most repeated analysis mistake in this project's history,
made three times in one session by an experienced caller and once by the
author of this file. compare_queries makes the check one call:
compare_queries a="jogos de matemática" b="jogos de matemática escolares"
country=br app=<your app id>
-> #19 vs #131, 1 of the top 25 shared
"these are substantially different result sets, so do NOT read a rank
difference between them as one query being better"(jogos de matemática is Portuguese for "math games"; the second query
adds escolares, "school" as an adjective, e.g. "school math games".)
identicalResults: true means Apple answered one search for both, so any
rank difference is noise. A high overlap means the fields are comparable and
a rank gap is real. A low one means you are comparing positions in two
different races. null means both queries returned nothing — there was no
list to compare, so it says nothing either way. (Comparing a query with itself
stays true, empty or not: it is the same search.)
Accent variants (keyword_ranks)
Measured mostly in br, with Hindi, Japanese, Swedish and German notes.
Foreign words are glossed at first mention.
Apple usually treats an accented and an unaccented spelling as two different
searches, sometimes answers both with one identical result list, and nothing
observable tells you which case you are in (16 pairs measured; the obvious
rule fails in both directions). So the only reliable method is to run the
pair -- which is what variants: true does:
keyword_ranks app=<your app id> keywords=["jogos de matemática",
"tabuada de multiplicação", "jogos de matemática 1 ano"]
countries=["br"] variants=true depth=trueThe three keywords: jogos de matemática ("math games"), tabuada de
multiplicação ("multiplication times table"), and jogos de matemática 1
ano ("math games, grade 1").
| keyword | accented | plain | identicalResults |
|---|---|---|---|
| jogos de matemática | #19 | not returned | false |
| tabuada de multiplicação | #12 | #114 | false |
| jogos de matemática 1 ano | #34 | #34 | true |
identicalResults is the bit that matters: true means Apple answered one
query for both spellings, so the spelling you target makes no difference;
false means they are genuinely separate searches and the rank gap above is
real; null means neither spelling returned anything, so there was nothing to
compare — not evidence that the two behave alike. Each variant is one more App Store search, so it counts against the
25 keyword-country pair cap. Accents are only ever stripped, never added --
pass the accented spelling if you want the comparison.
A mark counts as an accent only after a Latin, Greek or Cyrillic letter.
Where it is part of the word -- Devanagari vowel signs (गणित, Hindi for
"mathematics"), a kana dakuten (ゲ), Thai tone marks -- nothing is stripped,
no variant is searched and no note appears, because the result would be another
word or none (算数ゲーム, "arithmetic game", would become 算数ケーム).
Scripts not checked are left alone.
Call it without variants and an accented keyword still gets a note in the
response telling you the plain spelling and inviting you to re-run, because
checking one spelling alone can make a live keyword look dead.
In se and de the note adds a warning: Swedish å/ä/ö and German umlauts
are letters, not accents, so the stripped form is a different word --
useful as "what someone without the right keyboard types", not as an
equivalent spelling.
Keyword cohesion, and competitor subtitles
Measured in se. The behaviour is the same everywhere.
niche_report keyword rows carry cohesion: how many of that keyword's own
top-N apps also appear in the top-N of another keyword in the same report.
It costs nothing -- it compares results already fetched -- and it catches
something no other signal here can: a keyword that pulls a different
audience entirely.
Measured in se: "lågstadiet" (Swedish for "the early primary grades")
shares 0 of 10 with four maths keywords, which share 2-7 with each
other. Its top results are spelling and alphabet apps. Its difficulty score
is 0, which reads as "easy, go for it".
Genre cannot see this: every one of those apps, and the app under audit, is in "Utbildning" (Education), so a genre-fit score would rate the wrong keyword and the right one alike. Read cohesion as a count, not a score: a long-tail keyword can legitimately share little.
Competitor rows also carry subtitle and genres. A null subtitle means
Apple did not hydrate that app in these search results -- it hydrates roughly
the top 8 per keyword -- not that the app has no subtitle; call app_snapshot
on that id to settle it.
Autocomplete depth (free, every storefront)
Measured mostly in se, with one br example. The behaviour is the same
everywhere.
keyword_table and niche_report carry an autocompleteDepth column: how
many App Store autocomplete suggestions begin with that keyword. It costs
nothing, needs no account, and works in all 17 storefronts -- which makes it
the only demand-ish signal available outside us, where keyword volume
cannot be bought at all. It runs on its own request lane: a cold 6-keyword
niche_report measured 4.9 s without it and 4.1 s with (the gap is network
noise).
Read it for what it is. Apple's autocomplete is prefix completion -- 9-10 of every 10 suggestions literally begin with the text sent -- so this counts popular queries that extend your keyword, not searches for the keyword itself. Two consequences:
- It caps at 10, so it cannot rank head terms. "cool math games" (316,880 searches) and "math games for kids" (1,109) both score 10.
- A 0 does not mean nobody searches it. It means nothing popular
extends that exact string. "math quiz for kids" scores 0 and is obviously
a real search. The informative range is the middle: 4 for "math games for
adults", 2 for "learn math for kids", 9 for
se"matematik för barn" (Swedish for "maths for kids"), 0 forse"lågstadiet" (Swedish for "the early primary grades").
The words beat the number. Measured on one Swedish field, nine of twelve
keywords scored 10, so the count separated almost nothing -- while the
suggestion lists exposed four dead keywords in minutes. Use the count as a
prompt to look closer, never as proof a keyword is dead, and read the actual
suggestions: audit_keywords puts them on every word row (see
The thing a rank cannot tell you),
and suggest_keywords shows them for any term. The clearest case, measured live: in br, "jogos de matemática 1
ano" (Portuguese for "math games, year 1") scores 0 while the app ranks
34th for it, against 52 results and five real competitors above it. Pass depth: false on either tool to
leave the column out.
Verbose call log
Uses the Swedish storefront (se) as an example in the log lines below.
The behaviour is the same everywhere; only the words differ.
Turn on verbose (or set ASOLENS_VERBOSE=1) to see exactly what each
tool call asked Apple and what came back — the fastest way to catch a
wrong-storefront bug (e.g. querying se when you meant us) without reading
the source. The log itself goes to stderr — never stdout, which is the MCP
JSON-RPC channel. The same lines are also returned inside that call's tool
response as calls: string[], so you can see them in the conversation itself
rather than only in the server's log. Note what that means for
audit_keywords and compare_markets: their probe strings are built from
your private keywords field, so verbose mode puts that field in front of your
AI assistant -- for compare_markets, one locale's field per storefront.
[asolens] search se "mattespel" -> 200, 190 results, 1180ms
[asolens] lookup us id=<your app id> -> 200, 1 record, 240ms
[asolens] reviews se id=1609226786 page=1 -> 200, 50 entries, lastPage=10, 300ms
[asolens] hints us "math" -> 200, 10 suggestions, 190msIf you expected se and see us in these lines (or the reverse), that's the
bug. Off by default, and costs nothing when off.
Tools
Fifteen tools. Eleven need nothing at all -- no account, no key, no
signup. price_keywords needs DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD to do
anything at all -- without them it returns unavailable rather than working
normally -- and every call is billed to your DataForSEO account (see
Keyword search volume). Three more -- my_listing, audit_keywords and
compare_markets -- read your app's keywords field, which Apple keeps
private to the developer, so they need your own App Store Connect API key;
without it they return a labelled unavailable saying what to set, rather
than failing. Three tools -- keyword_table, niche_report and price_keywords
-- can spend a little real money on keyword search volume if you set
DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD -- see
Keyword search volume above.
price_keywords is the only one of the three with no volume: false
escape hatch: naming keywords by hand is its whole job, so it always spends
when it runs (and returns unavailable at $0 with no credentials
configured, rather than spending). Without those credentials set, every other
tool is exactly as free as before.
| Tool | What it does | Example prompt |
|---|---|---|
| find_app | Looks up an app by id, App Store URL, or name; returns candidate matches. | "Find my app on the App Store." |
| app_snapshot | Current listing data for one app plus that developer's other apps in the storefront. | "Give me a snapshot of app in us." |
| 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?" |
| 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?" |
| 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." |
| 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." |
| 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." |
| 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." |
| 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." |
| 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." |
| low_star_reviews | Recent 1-3★ reviews for an app, for reading what's actually bothering users. | "Pull the low-star reviews for app in us." |
| 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'." |
| 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." |
| 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. | "Which of these 40 keywords has the most search volume: 'math games for kids', 'kids math games', ..." |
Rate limits & errors
Tool calls can fail with a structured error instead of a result. The three worth knowing:
rate_limited— Apple's App Store endpoints throttled this machine. The error includesretryAfterSeconds; wait that long and retry. This is normal under heavy use, not a bug.endpoint_changed— an Apple response no longer matches the shape ASOLens expects. Apple's search/lookup/autocomplete endpoints are undocumented and can change without notice; rather than guess at a malformed response, ASOLens fails loudly. The fix isnpx @asolens/mcp@latestto pick up an update; if that doesn't help, it's worth reporting. A search Apple simply has nothing for is NOT this: it comes back as a normal answer withresultCount: 0, andrank: nullwherever a rank is reported.unreachable— ASOLens couldn't reach Apple's servers from this machine. Usually this means a network issue (no connection, DNS failure) or Apple is temporarily down. Check your network connection and retry.
(There are also bad_input for a bad argument like an unsupported country
code, not_found when an app can't be resolved, and vendor_error for a
DataForSEO problem on a keyword_table/niche_report call with volume
on -- rejected credentials, an empty account balance, or DataForSEO itself
being unreachable; the error's fix says which. This only ever happens when
credentials are configured and volume wasn't set to false -- with no
DataForSEO key, or volume: false, volume is unavailable instead, never
an error.)
Known limitations
- App Store (iOS) only. No Google Play data.
- Keyword search volume is
usonly, and needs your own DataForSEO account. DataForSEO's Apple data covers the United States alone, and every lookup is billed to you. Every other signal works in all 17 storefronts. suggest_keywordsnever buys volume. No vendor sells volume for a bare App Store keyword. It only shows prices another tool already bought on any of the last 7 UTC days.- Your App Store Connect listing is read once per server process. Restart the server after a release to see the new version.
