The first thing that will happen when you connect Ahrefs to an AI client is that nothing happens. No error, no tools, just a server that sits there looking connected. In my case the cause was mundane: Ahrefs ships two different MCP servers and two different kinds of API key, and only one of the four possible pairings is the one you want today. Nobody tells you which one you picked.
That is the short version of why this guide exists. The longer version is that I spent a subscription cycle running about 1,100 logged calls through this thing, and most of what cost me time was not the SEO analysis. It was the plumbing.
What You Are Actually Connecting To#
Ahrefs runs a hosted MCP server at https://api.ahrefs.com/mcp/mcp. It speaks Streamable HTTP, which is the current transport in the Model Context Protocol spec and the one every serious client supports. The legacy HTTP+SSE transport is deprecated. SSE remains usable as a response format within Streamable HTTP.
Behind that endpoint sits most of what you would otherwise click through in the Ahrefs web app: Site Explorer for backlinks and organic keywords, Keywords Explorer for volume and difficulty, Rank Tracker, Site Audit, and the Google Search Console integration if you have connected an account. In my instance that came out to 130 callable tools, which is more than the marketing pages claim, because the server has been growing.
Two things about it are worth knowing before you wire anything up. Access starts at the Lite plan, so a free trial account will not get you in. And every billable call draws from the same monthly API unit budget as regular API v3 usage, which means your chat assistant and your cron jobs are eating from one plate. Billing splits three ways: a good number of endpoints cost nothing, some charge a flat rate per request, and the rest charge per row. The last section is about telling them apart.
Two Servers, Two Key Types, One Silent Failure#
This is the part that wasted my first evening.
There is an older local server, published as @ahrefs/mcp on npm and hosted at ahrefs/ahrefs-mcp-server on GitHub. That repository is now archived, and its README carries a sentence worth reading twice: it works with API v3 keys only, and it does not work with MCP keys.
The hosted remote server is the opposite. It wants a key with MCP scope, which you generate separately in your Ahrefs account. Ahrefs states plainly that API keys and MCP keys are not interchangeable.
So the matrix looks like this. Two cells work, but only one of them is a sensible choice today:
| API v3 key | MCP-scoped key | |
|---|---|---|
Local @ahrefs/mcp | worked, repo now archived | fails |
Remote /mcp/mcp | fails | correct |
The archived combination is not broken so much as abandoned. It still runs if you already have it, but it receives no maintenance and Ahrefs points at the remote server instead.
The failure modes are quiet. An MCP-scoped key pointed at the REST API returns Unauthorized, which at least tells you something. A client that cannot complete the handshake often just shows the server with zero tools, and you go looking for a config typo that is not there.
If you are setting up today, use the remote server and generate an MCP-scoped key. Ignore every tutorial that has you npm-installing anything.
Authentication: OAuth Is the Official Path, Bearer Is the Useful One#
Ahrefs documents OAuth as the way in. Your client opens a browser window, you sign in, it caches the credentials. For interactive work that is fine and it is genuinely the least fiddly option.
It gets awkward the moment you want a scheduled job to pull data at seven on a Sunday morning. OAuth needs a human at a browser for the initial authorisation, and after that you are maintaining a token refresh that has to keep working unattended. So for anything headless I authenticate with a bearer token instead, and pass the MCP key directly in the Authorization header. Same endpoint, same tools, no browser, nothing to refresh.
The practical rule I settled on: bearer for anything that has to survive without me, OAuth for a laptop I am sitting in front of. If you only ever use this in chat, take the OAuth prompt and skip the next few sections.
Claude Code#
One command, and the scope flag decides whether the server lives in this project or in your user config.
claude mcp add --transport http ahrefs https://api.ahrefs.com/mcp/mcp \
--header "Authorization: Bearer $AHREFS_API_KEY" -s project
Project scope writes into a .mcp.json next to your code, which is the right choice when the key belongs to one client or one site. Put that file in .gitignore before you paste a key into it, because the header sits there in plain text.
Two things that cost me time here. The tools appear as mcp__ahrefs__<toolname>, not as ahrefs.<toolname>, which matters when you are writing prompts that name a tool explicitly. And a running session loads its MCP servers at startup only, so the connection you just added shows up in the next session, not this one. I restarted three times convinced the config was wrong.
Claude Desktop#
No config file needed. Settings, then Connectors, then Add custom connector, then paste the endpoint URL. OAuth client ID and secret go under Advanced settings if your server needs them.
There is one architectural detail here that surprises people and that changes what you can connect. Claude Desktop does not reach your MCP server from your machine. It reaches it from Anthropic's cloud infrastructure. For a hosted service like Ahrefs that makes no difference at all. For a server running on your own laptop or behind a company VPN it makes all the difference, because that server has to be reachable from the public internet before it will ever work.
Free accounts are capped at one custom connector. Paid tiers are not.
Codex#
Codex reads ~/.codex/config.toml globally, or a .codex/config.toml in a project directory you have marked as trusted. One TOML table per server, and the transport is inferred from which keys you set: a command key means stdio, a url key means Streamable HTTP.
[mcp_servers.ahrefs]
url = "https://api.ahrefs.com/mcp/mcp"
bearer_token_env_var = "AHREFS_API_KEY"
Note what bearer_token_env_var takes. It is the name of an environment variable, not the token itself. Writing your key there directly gives you a config file full of secret and a server full of nothing.
Codex also supports remote servers through its CLI: codex mcp add ahrefs --url https://api.ahrefs.com/mcp/mcp. For bearer access, use the TOML configuration above with bearer_token_env_var. Check it with codex mcp list; the Codex documentation also covers OAuth.
Cursor, VS Code and Windsurf#
Editors differ in file path, top-level structure and secret handling as well as the URL key. VS Code’s native format uses .vscode/mcp.json with a top-level servers object; the portable .mcp.json format uses mcpServers. Its HTTP configuration specifies url and "type": "http". Cursor and Windsurf have their own setup requirements. Do not transfer an entire block by merely renaming a field: check format, location, authentication and a first call in each client. The VS Code configuration reference also covers input variables for secrets.
Maintain a verified snippet for each team client. Store real credentials separately and keep them out of published project configuration.
Headless, Without Any Client at All#
The example below shows the 2025-06-18 handshake I used for this Ahrefs measurement. It is not a universal pattern for revision 2026-07-28, which uses per-request metadata and removes protocol sessions. Use a revision supported by Ahrefs and forward a session ID only if the server assigns one. See the current HTTP specification for the distinction.
curl -sD hdr -X POST "$AHREFS_MCP_URL" \
-H "Authorization: Bearer $AHREFS_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"sm","version":"1"}}}'
SID=$(grep -i '^mcp-session-id:' hdr | awk '{print $2}' | tr -d '\r')
curl -s -X POST "$AHREFS_MCP_URL" \
-H "Authorization: Bearer $AHREFS_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-06-18" \
-H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
Three things in there are easy to get wrong. Do not skip the second call: the session id alone does not finish the handshake, and a server that never received the initialized notification will refuse tool calls. The Accept header needs both content types even if you never intend to read a stream. And from the 2025-06-18 revision onward, every request after initialization must carry the MCP-Protocol-Version header, so it belongs on the notification and on every tool call that follows, alongside mcp-session-id.
The example targets 2025-06-18. It does not establish which revision Ahrefs supports today. Reuse it only if the service accepts that revision; newer clients may use a different flow.
Wrapping this in a small shell helper is worth the twenty minutes, because it makes the same data available to cron jobs and agents that have no chat interface at all. Mine is published as ahrefs-mcp-kit if you would rather start from something that already works. One warning from experience: if you build that helper with a bash default like ${ARG:-{}} for the JSON argument, brace matching inside the default value will silently append a stray closing brace and produce malformed JSON. The call then fails with no output and no error. Default the variable on a separate line.
Your Plan Decides Your Row Cap, and That Is the Expensive Part#
This is the section I wish I had read first, because it explains a mistake that cost me a full month of budget in a single morning.
Ahrefs sets both a monthly unit allowance and a row cap per request. The following table reflects my historical measurement period after the April change, not a current offer. Check Ahrefs help for current allowances, which have since increased.
| Plan | Units per month | Max rows per request |
|---|---|---|
| Lite | 100,000 | 100 |
| Standard | 400,000 | 250 |
| Advanced | 1,000,000 | 500 |
| Enterprise | 2,000,000 | unlimited |
Those numbers changed on 28 April 2026, and they changed a lot. Lite went from 25,000 units to 100,000 and from 10 rows to 100. Standard went from 150,000 to 400,000 units and from 25 rows to 250. Advanced doubled its units and went from 100 rows to 500.
The official cost formula is max(50, rows × field costs). Field costs sum the costs of unique fields in the output, filters and sorting. A field appearing more than once is counted once. A standard field costs one unit; premium fields have their own costs rather than a blanket ten-unit surcharge.
Put those two facts together and you get the trap. The most expensive call in my entire log ran with a limit of 250 rows. That is not a number I chose after thinking about it. It is exactly the row cap of the plan I was on, and I reached for it because it was the maximum available. Each of those calls cost 5,250 units. The same query at 50 rows would have cost 1,050 and told me the same thing, because rows 51 through 250 were long-tail noise I never used.
The row cap is not a recommendation. It is a ceiling, and since April it is a ceiling that sits up to ten times higher than it used to. That change is harmless if your code passes an explicit limit, because 50 still means 50. It bites in three specific cases: when you pass no limit at all, when your code asks for whatever the maximum currently is, and when a query used to be clipped by the old cap and now returns the full ten times more rows. All three are common in scripts written before spring, and none of them look different in the code.
Set the limit from what you will actually read. For keyword expansion I now start at 50 and go higher only when a result visibly clipped at the boundary and the extra rows matter.
Ten Traps That Each Cost Me a Run#
None of these are in the documentation in a way you would find before you hit them. All of them produced either an error I had to decode or, worse, an empty result that looked like a finding.
where is JSON, never a string expression. Writing "position>3 and position<15" returns bad where: invalid JSON syntax. The same filter as JSON needs one clause per condition:
{"and":[{"field":"position","is":["gt",3]},
{"field":"position","is":["lt",15]}]}
Operators are gt, gte, lt, lte, eq.
select changes type between tools. Site Explorer and Keywords Explorer want a comma-separated string. batch-analysis wants an array. Mixing them up gives you column '["domain"' not found in one direction and expected array but got string in the other.
Do not transliterate umlauts. Keyword matching is literal. A German term written with ue instead of ü returns zero rows and looks like a dead keyword. The same applies to Spanish tildes.
The country filter can silently delete the truth. Filtering organic keywords by country returned zero rows for a domain that demonstrably ranks, because its rankings sat in other countries. I paid 50 units for an empty answer and nearly concluded the domain ranked for nothing. Pull without the filter and take keyword_country as a column instead.
Use mode: "subdomains" by default. For any site where the apex redirects to www, mode: "domain" measures the exact host and returns phantom zeros. I have seen a domain report a few hundred backlinks and no traffic in domain mode against twenty-two thousand backlinks in subdomains mode.
volume-history takes keyword, singular. Passing keywords fails with required arguments [keyword] are missing. Twenty-six calls in one batch, all rejected, all for one letter.
Today is not a valid date_to. Even the aggregate endpoints reject the current date with bad date_to. Use yesterday.
Never look up a Site Audit issue by name. Issue names are not unique. I found the same name attached to two different issue ids, one of which was permanently empty. Take the id from your own issues response rather than matching on text.
Empty Search Console detail tables do not mean a broken connection. The detail endpoints materialise with roughly six weeks of lag, so a query for the last 30 days comes back empty while the aggregates are current to yesterday. I have watched two people conclude their GSC integration was dead when it was working perfectly.
org_traffic is an estimate, not a measurement. Ahrefs models organic search traffic from its keyword and ranking data. It underestimated measured traffic in my sample; that does not make it a guaranteed lower bound for other domains. For your own domains, also use Search Console and respect its scope.
Run the Free Surfaces Before You Spend Anything#
The single best habit I picked up: everything connected to a verified project of your own is either free or nearly free, and most people never touch it.
Across my log, the Search Console endpoints, the management endpoints and the free domain rating lookup together cost zero units and returned just under 6,000 rows. There is exactly one exception to that rule and it is a trap: gsc-anonymous-queries carries the same gsc- prefix but bills from 50 units per call, and on a small site it returns almost nothing. site-audit-page-explorer costs a flat 50 units per request regardless of how many rows come back, which worked out to about a third of a unit per row across nearly 10,000 rows, with twenty-plus technical fields per URL. Rank tracker and subscription info are free as well.
Then there is the other side of the ledger. Three tools, all of them row-priced keyword and backlink pulls, accounted for 78 percent of everything I spent. The cheap and free surfaces returned about 18,000 rows for 3,400 units. The three expensive ones returned 30,000 rows for 592,000. That is a factor of about a hundred in value for the same budget.
So the order is: check your remaining units, exhaust the free project surfaces, drill with the flat-rate audit tools, and only then reach for the row-priced ones with a limit you chose deliberately.
What I Would Tell Someone Starting Tomorrow#
Generate an MCP-scoped key, point your client at the remote endpoint, and do not install anything. Use OAuth if you work in chat, bearer if anything of yours runs on a schedule. Before your first real query, open the subscription info tool and read your row cap out of your plan rather than assuming it.
Then write your own numbers down. The costs in this article are what I measured on one plan across 1,102 calls, and the pricing model moved noticeably in April. Every response carries its actual cost inline, which means the tool tells you what it charged if you bother to look. Two days of logging that field taught me more about where the money goes than any amount of reading the documentation did.
