
Security News
Deno Goes the Way of the Dinosaur
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.
@asolens/mcp
Advanced tools
ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine
Live App Store data for your AI assistant — where every number tells you how much to trust it.
Search ranks, competitor teardowns, review complaints, and an audit of your own keyword field, across 17 storefronts. Runs on your machine and stores to a local SQLite file. Fourteen of the seventeen tools need no account and no API key — install and use them. The other three read a hosted server that watches rank day by day; those need a key, and everything else works without one.
claude mcp add asolens -s user -- npx -y @asolens/mcp
tabell is Swedish for table, as in multiplication table. An obvious keyword
for a maths app, and a real one ranks #2 of 16 for it. Then look at who
actually types it:
tabell #2 of 16 tabellen.se · allsvenskan tabell · fotboll tabell
Swedish football league tables. Ranking first for a word whose searchers want something else is worth nothing — and no rank measurement can show you that.
The same check catches räkna ("count") pulling calorie trackers, division
pulling Ubisoft's shooter, and, in French, addition — which to Apple's
searchers means the restaurant bill.
Every number says how much to trust it. Each value arrives labelled
observed (with its source and when it was fetched), modelled (with the model
name and the inputs it was computed from), or unavailable (with the reason and
what would fix it). No score with undisclosed inputs, ever.
It refuses to guess. No download estimates, no revenue estimates. Nobody outside Apple knows those numbers, and a figure you cannot check is worse than no figure — you will act on it.
It tells you when its own answer is suspect. That is the unusual part:
Everything it knows about App Store search — including the traps above and the measurements behind them — is in this README, with dates.
Nothing is uploaded by default, and the three exceptions all require credentials you set yourself. Configure DataForSEO for keyword search volume and it sends a competitor app id and a country/language pair — never your app, never your keywords. Configure App Store Connect and it reads your own listing from Apple, signed with your key, only for apps that key can already see. Set an ASOLens API key and the three tracking tools talk to the hosted server — and that one does send your app id and your keywords, because storing them is the entire point of asking a server to watch them daily. None is on by default; without the credentials all three paths are unreachable. Full detail in What leaves your machine.
us only, from any sourceRead this before evaluating the tool. Every other signal here works in all 17 storefronts. Keyword search volume works in exactly one, and that is not a limitation of this tool but of every data source tested:
40501 Invalid Field: 'location_code' (verified live for all 16), a
second Apple endpoint (app_competitors) behaves identically, and
DataForSEO support confirmed it in writing: the Labs Apple
keywords_for_app endpoint is "limited to Location: United States,
Language: English."app_data/apple/app_searches for other storefronts. It is a paid endpoint
that returns App Store SERP rankings — their own docs describe it as
"a list of apps ranking on the App Store for the specified keyword" — not
volume. That is precisely what this server's search_ranks already
returns, free, straight from Apple, in every storefront. Support's own
sentence is the clearest framing: "App Store SERP rankings are not the
same as App Store keyword search volume or popularity."spel (Swedish for "game"), matematik, barnspel, glosor and
two established local competitor apps all returned 1 of 5 — identical to
terms known to be near-dead. Only global names (instagram, tiktok) reach
5/5. Reaching even that requires a full ad-account signup with a legal
entity name, tax ID, registered address and payment method, and the
currency and time-zone choices are permanent. Third-party blogs describe a
finer 5–100 index over the Apple Ads API; Apple documents no such field on
the Campaign Management API's Keyword object, so treat that as unverified
and do not sign up expecting it.Even inside us, a keyword can come back unpriced — and a blank is not a
demand signal. No vendor sells "volume for this keyword". DataForSEO's Apple
data exists only as "the keywords this app ranks for", so a term gets a number
exactly when one of the competitor apps the report queried happens to rank for
it. That is a fact about which competitors were reached, not about the term.
There is a second reason a blank appears, and it matters as much: each competitor's ranked list is read only 50 rows deep, while a single app can rank for thousands. So a term can be missing simply because it sat below that window.
Measured 2026-09-02 in us: math for kids came back unpriced — while 248
apps compete for it, led by Khan Academy and Prodigy. Its demand is obviously
large. Read a blank as "we could not price this", never as "nobody searches
this" — and not even as "no competitor ranks for it", which the 50-row window
cannot establish either. Every such row says so in its own unavailable reason.
So the tool is built to be useful without volume. Ranks, keyword
coverage, audit_keywords, cohesion, autocomplete depth and low-star reviews
all work in every storefront, and in practice they are what produce
actionable changes — every metadata change shipped from a real audit this
week came from those, not from a volume number.
The closest thing to a demand signal that does work everywhere is what Apple
autocompletes. audit_keywords shows, for each word in your field, what the
people typing it are actually searching for — which is how a keyword ranking
#2 of 16 turned out to be serving people looking for football league
tables. See
What each word's searchers actually want.
Honesty rule. Every number this server returns is labelled one of three ways, and the label travels with the value instead of being buried in prose:
niche_report, which omits the per-row model name/inputs by
default to keep large reports small — pass include_inputs: true to get
them back).Nothing is ever presented as observed fact when it was actually guessed or
modelled. If a payload doesn't say observed/modelled/unavailable on a
field, the field is a plain observed passthrough (e.g. rank, resultCount)
and it also carries day/fetchedAt/fromCache so you know how fresh it is.
Requires Node.js >= 22.13.0 (needed for the built-in node:sqlite
module — check with node --version; there is no separate install step for
the database).
Claude Code
claude mcp add asolens -s user -- npx -y @asolens/mcp
-s user makes ASOLens available in every project, which is usually what
you want — you audit your app from your app's repo. Drop the flag to add it
for the current project only, or use -s project to commit the registration
into a repo 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.
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 'räkna' in se, and what else do people search near it?" — worked example |
| Are these two keywords the same search? | "Compare 'jogos de matemática' and 'jogos de matemática escolares' in br." — worked example |
| Who am I actually competing with? | "Build a niche report for these three keywords in se, against my app." — worked example |
| What do users actually complain about? | "Pull the low-star reviews for app 284882215 in us." |
| Am I gaining or losing rank over weeks? | "Track 'math games for kids' in us for my app." …then any time later… "Show me its rank history." — worked example |
If you have just installed it and want the shortest useful path:
<your main keyword> in <your biggest market>?" — the
ground truth you are about to reason from.<that keyword>." — free, and the
fastest way to find out whether searchers mean what you assume. This is the
step that catches a tabell before you build a strategy on it.<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. The only write that leaves your machine is
track, adding rows to your own list on the tracking server.
Settings live in ~/.asolens/config.json, created on first run. Each one can
be overridden per run with an environment variable.
| 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. |
apiBaseUrl | ASOLENS_API_BASE_URL | https://api.asolens.danielhan.dev | Where the three tracking tools look for the hosted server. Only consulted when apiKey is set; point it at your own if you run one. |
All three are off unless you set them, and none is required. Without them
every other tool still runs; the values they would unlock come back
unavailable with a reason and a fix, never an error.
| Set | Unlocks | Cost |
|---|---|---|
DATAFORSEO_LOGINDATAFORSEO_PASSWORD | Keyword search volume in keyword_table and niche_report | 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 | Free — details |
apiKey in config.jsonor ASOLENS_API_KEY | Day-by-day rank history from the hosted server: track, rank_history, tracking_status | Paid, and issued by hand for now — details |
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.
Requests to Apple's public App Store endpoints — search, lookup, autocomplete, reviews — which are what answer the tool calls.
Then three paths you have to switch on yourself, by setting the credentials above:
api.appstoreconnect.apple.com, a different host
from the four above), to read your own listing: a request signed with your
own .p8 key, limited to apps that key can already see.api.asolens.danielhan.dev, overridable
with apiBaseUrl), reached only by track, rank_history and
tracking_status, and only when you have set an ASOLens API key. This is
the one path that sends your own app and keywords: track posts the
(country, keyword, app id) rows you ask it to watch, rank_history asks for
one of them back, and tracking_status sends nothing but the key. With no
key set, the client returns "unconfigured" before it opens a connection.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 that you did not name in
a tool call.
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.
us storefront only -- see the section above
for the evidence and for why the alternatives do not work either. In any
other country volume comes back unavailable saying exactly that, at no
cost, and the rest of the report -- rank, difficulty, weakness, reviews --
is unaffected and works in all 17 storefronts.keyword_table and niche_report query at most 3 competitor
apps per call by default. Every payload that spent money carries
vendorCost (dollars, that call) so spend is visible in the conversation,
and a verbose-mode call line for a vendor request always names its cost.observed. A volume value is always modelled (with
model: "dataforseo-labs-apple" and, with include_inputs: true, the
source competitor app id it came from) -- it's a vendor estimate, not a
number Apple returned.volume: false on either tool to skip DataForSEO entirely for that
call (e.g. to conserve balance), even with credentials configured.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
live iOS version (a draft in review is reported separately -- what you
are about to ship is not what is being indexed today). It also tells you
when a storefront has no localization at all and Apple is serving your
primary locale there instead.
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.
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.
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. Exactly one example below is an illustration rather than a measurement, and it says so where it appears.
compare_markets)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:
| 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/11
The 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. An anchor the app already tops the
results for is its own brand, and every probe built from it would return the
app whatever word was under test. That is not hypothetical: Japanese has no
spaces to tokenise on, so the first auto-anchor for jp was マスクエスト —
the transliterated brand — and it scored 29 of 29 words inside the top
10. The audit now searches each candidate alone first and discards any the
app ranks top-3 for, reporting which it skipped and why. With a real anchor
the same market scores 12 of 29. If every candidate is brand-like, it says
the results are unusable rather than presenting them.
snapshot_save, snapshot_diff)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" 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 for that storefront -- with
competitorSampleSize beside it, because a median over 10 apps and one over
50 are the same shape and mean different things.
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:
resultCount context are shown; you judge.onlyInA/onlyInB, because a vanished row is how a
regression disappears from a report.audit_keywords)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 and never leaves your
machine -- which is also why no real field appears anywhere in this README.
It answers the strategic question as well as the tactical one. Same app, same day, same tool, two storefronts:
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 market is wrong -- no amount of keyword editing closes a gap of 2,747 ratings to zero. 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 competitive field 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
"수학 어린이" and "수학게임"'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 in a field of 220,
tabell at #122 of 211, räkna unranked in 227 -- because a one-word search
is a fight with 200+ apps that a small app always loses. Anchored, the same
words separate: mattespel tabell #2 of 16, mattespel bråk #4 of 13,
mattespel barn #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:
jp was マスクエスト,
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 (算数ゲーム) the same field reads 12 of 29.se: matteträning returns 32 apps, matteträning barn returns 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.
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 tabell
Ranking 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, fracciones, subtração.
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.
compare_queries)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"
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.)
keyword_ranks)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=true
| 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.
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.
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" 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", so a genre-fit score would read 5/5 for the wrong keyword and the right one alike. That is why this is a cohesion count and not a category match. Read it 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.
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. Autocomplete runs on its own request lane, so it
overlaps the searches already in flight: a cold 6-keyword niche_report
measured 4.9s without it and 4.1s with (the difference 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:
se "matematik för barn", 0
for se "lågstadiet".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
What each word's searchers actually want),
and suggest_keywords shows them for any term. The clearest case, measured live: in br, "jogos de matemática 1
ano" 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.
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. Every line goes to stderr only — stdout is the MCP JSON-RPC
channel, and ASOLens never writes verbose output there. The same lines are
also echoed into that call's tool response as calls: string[], so you can
see them in the conversation itself, not just the server's log:
[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, 190ms
If you expected se and see us in these lines (or the reverse), that's the
bug. Off by default, and costs nothing when off.
Every other tool here answers where am I today. Rank moves slowly and noisily, so one reading rarely tells you whether a change worked. These three read a hosted server that checks each tracked keyword once a day and keeps the result — so the history exists whether or not your laptop was on.
This is the only part of ASOLens that needs an ASOLens account — and to be
straight with you: there is no signup yet. No checkout, no self-serve
plan. Keys are issued by hand at the moment, so if you want one, open an issue
or mail the address in bugs on this package. The other fourteen tools are
unaffected and always will be. Once you have a key, put it in
~/.asolens/config.json:
{ "apiKey": "your-key-here" }
or set ASOLENS_API_KEY. Start collecting:
track add=[{keyword: "math games for kids", country: "us", app: <your app>}]
Then, any time later:
rank_history app=<your app> keyword="math games for kids" country=us
Every other example in this section is a real measurement. This one is not — it shows the shape of the answer. The server went live on 2026-09-01 and 0.2.0 shipped before the first daily pass had run, so there was no genuine history to print. Passes have run since, but the rows below are still an illustration rather than a capture — when a real series replaces them it will carry its own date, like every other measurement here:
day observed rank where it came from
2026-09-02 true 14 server pass pass_2026-09-02
2026-09-03 true 14 server pass pass_2026-09-03
2026-09-04 true 11 server pass pass_2026-09-04
2026-09-05 true null server pass pass_2026-09-05 <- looked; you were not in the results
2026-09-06 false - no observation for this day <- nobody looked
2026-09-07 true 9 server pass pass_2026-09-07
Called without from/to it returns the last 30 days, so a real answer is
30 rows — mostly observed: false until the server has been watching a while.
Those two middle rows are the whole point. observed: true, rank: null
means the search ran and your app was not in it — a real result you can act
on. observed: false means nobody looked that day — a hole in the data. Most
tools collapse both into a gap in a chart, or worse, into a zero. These never
merge, anywhere in ASOLens.
tracking_status answers is this data actually fresh? — when the last daily
pass ran, whether it finished, and how many of your tracked pairs it got to.
Worth asking before you read anything into a flat line.
If the server is unreachable, nothing else stops working. Every other tool
in this README runs locally and is unaffected. rank_history itself falls back
to whatever this machine observed from your own earlier tool calls, marked
serverUnreachable: true — usually far sparser, since it only knows the days
you personally ran a search. It says which it is giving you; it never quietly
passes one off as the other.
Seventeen tools. Eleven need nothing at all -- no account, no key, no
signup. 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. The last three
-- track, rank_history and tracking_status -- are the paid tier and need
an ASOLens API key; without one they say so plainly and every other tool here
carries on exactly as before. Two tools, keyword_table and niche_report, can
optionally spend a little real money on keyword search volume if you set
DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD -- see
Keyword search volume above; without those
set, every tool here 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 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 run earlier the same day already paid for a list containing it. | "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." |
track | Adds or removes (country, keyword, app) rows from the hosted server's daily collection, then returns the full current list so you can see the result. Needs an ASOLens API key. | "Track 'math games for kids' in us for my app." |
rank_history | Day-by-day rank history for one tracked (country, keyword, app) triple. Every day says where it came from, and a day nobody looked at is reported as not observed rather than as a zero. Falls back to this machine's own observations if the server is unreachable, and says so. | "Show me the rank history for 'math games for kids' in us." |
tracking_status | Whether the hosted data is fresh: when the last daily pass ran, whether it finished, and how many of your tracked pairs it covered. | "Is my tracking data up to date?" |
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 includes retryAfterSeconds; 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 is
npx @asolens/mcp@latest to pick up an update; if that doesn't help,
it's worth reporting.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.)
shareCache exists in config but does nothing yet;
every fetch is per-install.suggest_keywords — no vendor
sells volume for a bare App Store keyword (DataForSEO's Apple data is
per-app: "the keywords a given app ranks for"), so this tool can never buy
it, whatever credentials you set. What it does do is free: if a
niche_report or keyword_table run earlier the same UTC day already
paid for competitor keyword lists in us, every suggestion appearing in
one comes back priced (volumes, each row modelled with
inputs.fromCache: true), and suggestions that don't appear say so rather
than implying they have no volume. With nothing paid for today you get a
single volume field explaining which of the three cases you are in.FAQs
ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine
The npm package @asolens/mcp receives a total of 0 weekly downloads. As such, @asolens/mcp popularity was classified as not popular.
We found that @asolens/mcp demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.

Security News
Anthropic’s free OSS Scanner uses Mythos and other Claude models to find open source vulnerabilities and send maintainers reports with proposed fixes.

Security News
On Front Lines’ Early Adopters: CISO podcast, Andrew Becherer shares lessons from Datadog, weighs the long-term costs of AI-built tools, and explains how founders can actually gain a CISO’s trust.