Skip to content

mcp · ai-governance · revops · seo

Google Search Console MCP: 5 Setup Errors

There is no official GSC MCP server. invalid_scope shows up as "Authentication failed", a dead server reports 12 tools registered, and the token file deletes itself.

On this page
  1. Error 1: invalid_scope: Bad Request disguised as a missing credential
  2. Error 2: you cannot enforce read-only at the token layer
  3. Error 3: "12 tool(s) registered" while every call fails
  4. Error 4: the server registers zero tools, silently
  5. Error 5: the token file deletes itself
  6. The scorecard
  7. After it works: twelve tools is too many
  8. The bug the first live run found
  9. Would we deploy it?
  10. Related

There is no official Google Search Console MCP server. Every option is third-party, and the one most teams run — mcp-search-console, from github.com/AminForou/mcp-gsc — has five failure modes that each present as something other than what they are. An invalid_scope rejection surfaces as "Authentication failed. Please set up OAuth." A dead server reports twelve tools registered. A token file deletes itself.

We put GSC into production on 30 July 2026 and hit all five. Here is each one, what it actually says, and what it actually means.

Error 1: invalid_scope: Bad Request disguised as a missing credential

What you see:

Authentication failed. Please either:
1. Set up OAuth...

What is happening: mcp-search-console hardcodes SCOPES = ["https://www.googleapis.com/auth/webmasters"] — the read and write scope — and passes it to Credentials.from_authorized_user_file. On every token refresh, google-auth sends that scope string to Google's token endpoint. If your credential was minted with anything narrower, Google refuses:

RefreshError: ('invalid_scope: Bad Request', {'error': 'invalid_scope'})

The package then deletes its cached token and raises its generic authentication message — which describes a credential that was never configured, not a scope that was refused. You will go and check whether the credential exists. It exists. That is not the problem.

The fix: mint the grant with the full webmasters scope. It must be a superset of what the package requests.

We originally shipped webmasters.readonly, reasoning that Credentials.refresh only warns about ungranted scopes. That client-side check does exist — it simply never runs, because the token endpoint rejects the request first. We verified both directions against a live token: a read-only grant reads GSC correctly when the scope request is dropped, and fails with invalid_scope when the package requests webmasters, which it always does.

Error 2: you cannot enforce read-only at the token layer

This follows directly from error 1, and it is the most important thing on this page.

Because the package forces the write-capable webmasters scope, a read-only credential cannot refresh. The scope that would make your integration structurally safe is the scope that makes it stop working.

The token cannot be your lock. Two other locks have to do the job:

  1. An allow-list that omits add_site, delete_site, and delete_sitemap. Also omit reauthenticate, which attempts a browser OAuth flow that cannot work in a container.
  2. GSC_ALLOW_DESTRUCTIVE left unset, so the server disables those tools itself.

Both live in configuration, in the repository, under review. Neither is enforced by Google.

This is the Ungated failure mode: the server ships no scoping or guardrails of its own, so every boundary is one you add and maintain. Of the five failure modes we score MCP servers against, Ungated is the one that wrappers cannot fully bridge — an under-described tool can be re-described and an unshaped response can be reshaped, but a credential that must hold write access is a standing risk you can only manage, not eliminate.

We also keep a test that fails if the documented scope silently regresses to .readonly again, because the failure it causes is a debugging cycle rather than an obvious break.

Error 3: "12 tool(s) registered" while every call fails

What you see at boot:

'google-search-console' connected — 12 tool(s) registered

What it proves: that the process started and enumerated its tools. Nothing else.

The credential is read lazily, on the first tool call. A server with an expired token, a missing token, or a token minted at the wrong scope reports exactly the same healthy startup line as a working one.

Tool registration is not credential verification. Only a real call proves a credential. Build your health check around list_properties returning actual properties, not around a count in a log line.

Error 4: the server registers zero tools, silently

Ours ran as a stdio child of a gateway process. Stdio children inherit the parent's environment. Our gateway runs with MCP_TRANSPORT=combined, and mcp-search-console's main() raises ValueError on any transport value it does not recognise.

The child died at spawn. No tools registered. No obvious error anywhere — the integration simply was not there.

The fix is to pin the transport explicitly in the connection's environment overrides:

"env": {
  "MCP_TRANSPORT": "stdio",
  "GSC_CONFIG_DIR": "${GSC_CONFIG_DIR}",
  "GSC_DATA_STATE": "all"
}

The general shape of this bug is worth internalising: anything you spawn as a stdio child inherits your whole environment, and any variable you set for yourself is a variable you set for it. We now have a test asserting the pin, because the failure is invisible.

Error 5: the token file deletes itself

mcp-search-console rewrites token.json after each refresh, so its config directory must be writable. Ours is /tmp/gsc, seeded at container boot from an environment variable.

Two consequences, both of which bit us:

  • A failed refresh makes the package delete the seeded token. After that, every call returns the misleading message from error 1 until the next boot re-seeds it. A single bad refresh puts the integration into a state that looks permanent and is not.
  • Rotating the credential does nothing until you restart. The token file is seeded at boot only, so a running container never picks up a new value.

And a trap on top of that trap: HTTP 200 is not a restart. Polling the service port returns 200 from the old container the entire time a new one is deploying. Watch for a fresh Starting Container line in the logs, and note that log streams can keep showing the previous deployment after a new one is live. We concluded things from failing calls twice before realising we were testing a container we thought we had replaced.

The scorecard

Each cell scored 0–4, where 0 means not addressable at that layer and 4 means an agent succeeds on the first call from the tool description alone.

Layer Read Write Relationship Workflow Audit / History
UI 4 3 1 3 2
API 3 2 0 1 1
MCP 3 0 0 1 0

UI and API rows are assessed against Google's published product and API surface. The MCP row is scored from production use, with write deliberately denied by our allow-list.

Provenance: community, no official alternative. mcp-search-console is Python, MIT-licensed, and widely adopted — around 900 stars as of this writing, with its last push on 30 April 2026. That three-month gap since the last commit is worth watching. It is a capable package, but it is still a third-party dependency in the path of your search data, and there is no first-party option to fall back to. That is a maintenance liability to name rather than ignore, and it is the main argument for keeping the allow-list tight.

After it works: twelve tools is too many

Getting the integration running is the smaller half of the problem.

Twelve GSC tools, seven GA4 tools and five blog tools is far too much surface for an agent to join correctly and consistently every week. The agent has to decide which reports to pull, over which window, at which dimensions, then reconcile the results against its own published content. It will do this differently every run, and the variance is invisible in the output.

So we stopped exposing the join to the agent and built the answer instead: a page_triage tool that pulls Search Console page performance, joins it to our own post records, and returns every page pre-classified into one bucket — ctr_problem, rank_problem, invisible, too_new, or working. One call, pre-judged, thresholds as testable module constants rather than prompt text.

The thresholds came from measuring first. Trailing 28 days to 30 July 2026: 19 clicks, 841 impressions, 25 pages. At that volume the dominant failure is CTR, not rank — the two highest-impression posts sat on page one at 1.9% and 0.6% CTR — and a naive "you rank badly" triage would have missed it entirely.

The bug the first live run found

Our first rule set gated everything on age: a page younger than the 14-day grace window was excluded from every other bucket. That is a sensible-sounding rule.

The first production run returned an empty ctr_problem bucket and 15 pages in too_new. Every post on the blog was younger than 14 days, so the age gate swallowed the entire corpus — including the 216-impression page at 1.9% CTR that the whole design existed to catch.

The rule was wrong, not the threshold. Age excuses a page that has no signal yet. It should never suppress one that already has hundreds of impressions and a CTR measurable on day three. We now check the impressions floor first and apply the grace window only below it. The same run then surfaced all three page-one pages with sub-2% CTR and kept 12 genuinely unjudgeable posts in too_new.

Worth stating plainly: that bug was only findable by running the thing against real data. It passed review, and it would have passed any test written against a synthetic corpus with a realistic age distribution — because the bug was the age distribution.

Would we deploy it?

Yes, read-only, with the allow-list, the transport pin, and a health check that makes a real call.

The package is competent and the API underneath it is coherent. Every one of the five errors above is a legibility problem rather than a capability problem: the server can do the thing, and tells you something misleading when it cannot. That is bridgeable, and we have bridged it.

The part that is not bridgeable is the scope. Until the package accepts webmasters.readonly, anyone running this holds a write-capable credential for their search property and enforces read-only in configuration alone. Know that you are making that trade.

Frequently asked questions

Is there an official Google Search Console MCP server?

No. Unlike Google Analytics, which has a first-party analytics-mcp server, every Google Search Console MCP server is third-party. The most widely used is mcp-search-console (AminForou/mcp-gsc), a Python MIT-licensed package. There is no first-party option to fall back to, which is a maintenance liability worth naming before you depend on it.

How do I fix invalid_scope: Bad Request with mcp-search-console?

Re-mint your credential with the full https://www.googleapis.com/auth/webmasters scope. The package hardcodes that scope and sends it on every token refresh, and Google's token endpoint rejects any scope outside the original grant. A narrower grant such as webmasters.readonly will fail. The error usually surfaces as a generic "Authentication failed. Please set up OAuth" message, which misdescribes it as a missing credential.

Why does Search Console MCP say authentication failed when my credential exists?

Because the package catches the underlying RefreshError, deletes its cached token, and raises a generic setup message. The real cause is almost always a scope mismatch: your grant is narrower than the webmasters scope the package requests on refresh. Check the scope on the grant, not the existence of the credential file.

Can I run Google Search Console MCP read-only?

Not at the token layer. The package forces the write-capable webmasters scope, so a read-only credential cannot refresh at all. Enforce read-only with two configuration locks instead: an allow-list that omits add_site, delete_site, delete_sitemap and reauthenticate, and leaving GSC_ALLOW_DESTRUCTIVE unset so the server disables those tools itself.

Why does my MCP server register tools but every call fails?

Tool registration is not credential verification. The server enumerates its tools at startup and reads the credential lazily on the first tool call, so an expired, missing or wrongly-scoped token produces exactly the same healthy-looking startup line as a working one. Base your health check on a real call that returns real data, such as list_properties, rather than on a tool count in the logs.

Why did my stdio MCP server start with no tools at all?

Stdio children inherit the parent process's environment. If the parent sets MCP_TRANSPORT to a value the child does not recognise, the child can raise on startup and die at spawn with no visible error. Pin the transport explicitly in the child's environment overrides, for example MCP_TRANSPORT set to stdio, and add a test asserting the pin, because the failure mode is silent.