Skip to main content
Ahrefs MCP Server: Setup for Claude, Codex and the Rest
Back to Blog
SEO & Marketing August 3, 2026 12 min readby Matthias Meyer

Ahrefs MCP Server: Setup for Claude, Codex and the Rest

There are two Ahrefs MCP servers and two kinds of key, and the wrong pairing fails silently. Here is the wiring per client, plus the traps that cost real money.

On this page

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. SSE is deprecated and you should not build anything new on it.

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 keyMCP-scoped key
Local @ahrefs/mcpworked, repo now archivedfails
Remote /mcp/mcpfailscorrect

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.

The codex mcp add subcommand exists, but it is shaped around stdio servers, so for a remote endpoint editing the TOML is both faster and easier to put in version control. Verify with codex mcp list.

Cursor, VS Code and Windsurf#

All three speak the same JSON dialect with one annoying difference: the key that holds the URL. Cursor calls it url. VS Code wants url alongside an explicit "type": "http", the same shape Claude Code uses. Windsurf calls it serverUrl. Everything else about the block copies across unchanged, which means a config that works in one editor is thirty seconds of renaming away from working in the next.

VS Code has had native MCP support since 1.99, surfaced through Copilot Chat. Windsurf added it early this year. If your team is split across editors, write the block once and keep the three variants in a snippet somewhere, because you will need them again.

Headless, Without Any Client at All#

MCP is a session protocol, not a plain REST call. You initialize, you send an initialized notification, and only then can you call a tool. Every request after the handshake carries the session id you got back.

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.

One caveat on that snippet, because the ground has moved. It targets protocol version 2025-06-18, which is what this server speaks and what most deployed servers speak today. The 2026-07-28 revision restructured Streamable HTTP considerably: protocol-level sessions were removed, so Mcp-Session-Id is gone, and the initialize handshake itself became the legacy path in favour of per-request metadata. The code above is correct for the server it talks to, but check the revision your target actually implements before reusing it elsewhere.

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 gates two things by subscription tier: how many units you get per month, and how many rows a single request may return.

PlanUnits per monthMax rows per request
Lite100,000100
Standard400,000250
Advanced1,000,000500
Enterprise2,000,000unlimited

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.

Now hold that next to how the pricing works. A call costs a minimum of 50 units, and beyond that you pay per row, multiplied by how many columns you asked for. Premium columns like volume, keyword_difficulty and traffic_domain add roughly ten units per row each.

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.

Treat org_traffic as a model, not a measurement. It is estimated from keywords in the Ahrefs index multiplied by a click curve, so anything the index does not carry simply does not exist in that figure. On the niche sites I compared it against real Search Console data it came out far too low, in one case by a factor I would not have believed without both numbers side by side. On your own domains, Search Console is the truth. On competitor domains, label it as a floor rather than a number.

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.

Matthias Meyer

Matthias Meyer

Founder & AI Director

Founder & AI Director at StudioMeyer. Has been building websites and AI systems for 10+ years. Living on Mallorca for 15 years, running an AI and design studio there: web design, AI connectors, AI systems and custom-trained models, plus four self-serve MCP servers.

Claude + Claude Code

Three more posts from the same topic cluster that show how the picture fits together:

Cluster overview: Claude in 2026: Models, Apps, Claude Code, and the API