An MCP server that exposes SEO analysis tools — crawlability, structured data, Search Console and Analytics — to an agent. It runs on your own machine, over HTTP, and holds your own Google credentials.
One instance serves exactly one Operator: you, whoever runs it. The server
never distinguishes between callers. One Operator still covers many sites — an
SEO consultant managing a dozen clients is one Operator, not a dozen.
CONTEXT.md defines this and the rest of the vocabulary.
New here? docs/setup.md takes a clean machine to a working
server and a Full Report, including the Google Cloud steps, and maps every common
failure message to its fix.
Node 24 or newer, and pnpm.
Nothing else to start with. Around twenty Tools do Basic Analysis, which reads
a site's public surface alone and works on any domain, including ones you do not
own. The Search Console and Analytics Tools additionally need Property Access
— a Google account that can read the site's Google Property — and the SQLite
database under db/. Tokens and database both stay on your machine, and neither
is committed.
pnpm install
pnpm build
pnpm startAnything this server needs configured is read from a .env file at its root —
copy .env.example and fill in what you want. Nothing in it is required to start:
with an empty file, or none at all, the credential-free Tools work as they are.
A variable already set in your shell wins over the file, and the file is read once at startup, so a change to it needs a restart.
It listens on http://127.0.0.1:3737/mcp. Point an MCP client at that URL — for
Claude Code:
claude mcp add --transport http thatseoagent http://127.0.0.1:3737/mcpThere is no authentication. The server binds loopback only and refuses cross-origin requests, so a web page cannot reach it, but any process on your machine can. That is fine for a personal machine and is not a posture to deploy. See ADR-0004.
Every Tool below is Basic Analysis: it reads a site's public surface and needs no credentials and no database.
seo_robots_validator— reads a site'srobots.txtand reports which crawlers are blocked, whether AI crawlers may train on the content, which sitemaps are declared, and any syntax problems.seo_analyze_page— one page's on-page SEO: meta, headings, word count, links, image alt text, Open Graph, JSON-LD, hreflang, and the issues those add up to.seo_content_analysis— readability, heading structure and outline, link mix, vocabulary, and the GEO signals answer engines read.seo_schema_detection— the structured data a page publishes, validated, plus which schema types this kind of page owes and which are missing.seo_schema_generator— valid JSON-LD for one of ten schema.org types, with the snippet to paste and the placeholders left to fill in.seo_eeat_score— the on-page signals of Experience, Expertise, Authoritativeness and Trustworthiness, scored as a directional checklist.seo_geo_score— the signals that correlate with being cited by AI answer engines, led by the three checks that decide whether Google can index the page at all.ai_visibility_score— whether a site resolves as an entity, what a model would know about it, where its category is covered editorially, and whether its content is structured to be cited.entity_mentions— where a brand exists off its own site: Wikipedia, Wikidata, Reddit, and the LinkedIn, YouTube and GitHub profiles its homepage links to.crawl_site— walks a site from a root URL for the findings only a multi-page view can produce: duplicate titles and meta descriptions, broken internal links, pages buried deep in the click hierarchy, and the shortest pages.seo_crawlability_audit— what stands between one URL and being indexed: its canonical, its redirect chain, and the directives that block indexing.seo_security_headers— the HTTP security headers a URL sends, graded, with the header lines to add.seo_hreflang_validator— a page's hreflang setup wherever it is declared: HTML, theLinkheader, and optionally a sitemap.seo_agent_discovery— the artifacts an agent looks for before reading a site:llms.txt, an MCP server card, and the well-known documents that point at them.seo_agent_navigability— the HTTP behaviour an agent depends on when it reads one URL, every finding shipped with the request that produced it.seo_agent_api_surface— the API a site offers an agent, measured against the OpenAPI or MCP description it publishes.seo_llms_txt— reads and scores a site's/llms.txt, and generates one from the site's own pages.wayback_history— what the Wayback Machine holds for a URL: first and latest capture, content versions, and when it started redirecting or answering 404; across a path or domain, which archived pages are lost now.brand_pageviews— how interest in a brand's Wikipedia article has moved over time: trend, peaks and year over year. A proxy while Google Trends has no open API.
Every outbound request obeys the target site's robots.txt and paces itself, so a
crawl of fifty pages is never a burst at somebody else's server. Requests identify
themselves as ThatSEOAgentBot and carry a URL a webmaster can actually reach.
The scores are readings of signals we can see, not measurements of how any AI system behaves, and each one says so in its own output. A check that could not run is reported as not run, never as a failure.
run_site_audit— names a domain and produces the Full Report: its public surface, Search Console and Analytics together, with every number recorded so the next run can be compared against it. Registers the Site if it is new.seo_metric_trend— reads that history back, per run or month by month.sync_gsc_properties— registers domains as Sites and asks Google which of them a Full Report is possible for.run_page_audit— audits one page and keeps the result. Run it before and after a change and the second run reports what moved. The checks a run could not make — a rule a JavaScript-rendered page gives no text for, headers that could not be read — are listed as not checked and never compared as a change.get_page_audits— reads those back, per Site or per page.
run_site_audit refuses rather than degrading. Without the Google login, or
without access to that Site's Search Console property, it says what to fix
instead of returning the public-surface half dressed as a complete report — see
ADR-0003. The
credential-free Tools cover that case, and calling one of them is an explicit
choice rather than a report that quietly left half its subject out.
These read the Operator's own Search Console data. Without the login each is still listed and returns an error naming the command to run.
gsc_list_properties— the properties this Google account can read, with the permission level on each and whether it is a Domain Property or a URL-Prefix Property. Start here: the other Tools take one of these identifiers.gsc_search_analytics— clicks, impressions, CTR and average position, broken down by query, page, country, device, date or search appearance.gsc_inspect_url— what Google knows about one URL: indexing verdict, the canonical Google chose, last crawl, robots and directive state, rich results.gsc_bulk_url_inspection— the same across many URLs, reporting which are indexed and where Google chose a different canonical.gsc_list_sitemaps/gsc_get_sitemap— what Search Console has recorded about a site's sitemaps: submitted against indexed, warnings, errors.gsc_sites_health_check— every property at once, so you can tell which are worth opening before running anything per-site.
And these read the Operator's own Analytics. Unlike the gsc_* Tools, they
cannot take a bare domain on their own: Google's Analytics Admin API does not
say which site a property measures — a property has a display name you chose,
not a domain — so there is nothing to match example.com against, and guessing
would report another site's traffic as yours. Run ga4_list_properties and pass
the identifier, or record it once with run_site_audit's ga4PropertyId and the
domain will resolve on its own from then on.
ga4_list_properties— the Analytics properties this account can read, grouped by the account each belongs to.ga4_run_report— any report: pick metrics and dimensions, get rows back.ga4_pivot_report— two dimensions crossed against each other.ga4_get_realtime— who is on the site in the last 30 minutes.ga4_metadata/ga4_custom_definitions— what this property can report, including the custom fields somebody defined for it.ga4_key_events— what is converting, and whether anything is even marked as a conversion.ga4_check_compatibility— whether a set of fields can be reported together, before you spend a report finding out.ga4_ai_traffic— how much traffic arrives from ChatGPT, Perplexity, Claude, Gemini, Copilot and the rest, which pages they land on, and whether it is growing. No other Tool here answers this.ga4_setup_audit— whether the property is set up to measure what an SEO needs: retention, web streams, enhanced measurement, key events and how they are counted, attribution, Signals, reporting identity, channel groups and Ads/BigQuery links, each with why it matters. No score.ga4_annotations— what the property itself says happened, and when: its annotations in a window, to line up with a traffic shift.ga4_funnel_report— of the people who did step 1, how many reached each next step and where the rest dropped off, for a 2–10 step funnel.
The interpreted read of the same data:
gsc_detect_quick_wins— queries already being seen, just below the fold, that nobody clicks. Usually a title problem rather than a ranking one.gsc_detect_trends/gsc_detect_lost_queries— this window against the one before it, of exactly the same length.gsc_detect_anomalies— days that do not look like the rest of the window.gsc_detect_cannibalization— queries where more than one page appears.gsc_branded_split— people looking for you by name, versus everyone else.gsc_device_gap/gsc_country_opportunity— where the impressions are and where they are not converting.gsc_page_query_map— what each page is actually understood to be about.gsc_search_appearance/gsc_serp_features_gap— how the results look, and which enhancements the site is not part of.gsc_detect_featured_snippets— inferred from position and CTR, and said to be.gsc_index_coverage_analysis/gsc_crawl_freshness/gsc_rich_results— Google's own record for a sample of the busiest pages.gsc_discover_performance— the feed, which has no queries and no ranking.gsc_hourly_performance— clicks and impressions hour by hour over the last ten days at most, each hour against the same hour on earlier days, with the hours Google is still collecting marked. The Tool for "did today's deploy change anything?".gsc_content_decay— pages losing search clicks over up to sixteen months, with a year-over-year check to tell a falling page from a seasonal one.gsc_page_value— Search Console clicks joined to GA4 organic sessions, engagement and key events per page: clicks that are worth little, and value that is barely seen.
gsc_search_analytics also takes freshData, which reads up to today and names
the days Google is still collecting. gsc_branded_split is this server's split
by the brand terms you give it: Google's own branded-queries filter is not in
the Search Console API.
Every one of these says what it is based on: how many rows it read, whose threshold it applied, and that an absence of findings is an absence in those rows rather than a fact about the site.
A bare domain works anywhere a property is asked for: example.com is matched
against the properties the account holds, preferring the Domain Property.
These cross the Operator's own Google data with the site's public pages, so they
need the Google login and fetch the site the way the credential-free Tools do —
obeying its robots.txt and pacing themselves. A page that could not be read is
reported as not checked, never as a finding.
site_orphan_pages— pages Google shows that the site's own links do not reach within a crawl of up to 50 pages, sitemap or crawled pages with no impressions, and how many URLs are only in the sitemap, only in the crawl, or in both.site_title_query_fit— whether the busiest pages' titles and H1s carry the words of the queries they are found for.site_schema_detection_gap— the structured data each busy page declares against the rich results URL Inspection says Google detected.site_hreflang_country_gap— countries the site is seen in without a version in their language, and hreflang alternates aimed at countries where it is barely seen.site_lastmod_accuracy— whether sitemap<lastmod>dates can be believed, compared with Google's last crawl for a sample of URLs.site_ai_landing_signals— what the pages AI assistants send people to have in common, against busy organic pages no assistant sent anyone to.site_ai_crawler_traffic— whetherrobots.txtblocks the search crawlers of the assistants that are sending the site visitors, or only their training crawlers.site_vitals_by_traffic— the busiest Search Console pages ranked by clicks × how far their Core Web Vitals are from "good". Also needsPAGESPEED_API_KEYwith the Chrome UX Report API enabled.
The URL-inspecting ones spend Search Console's inspection allowance, 2,000 a day per property, and each says how many it used.
These need something set before they can work:
pagespeed_insights— Google's PageSpeed Insights for a URL, reporting both halves separately: field data (what real Chrome users experienced over the last 28 days, which is what Google ranks on) and lab data (one throttled Lighthouse run, which is a diagnostic), plus the third parties the page loads. The field data comes from the Chrome UX Report API, because Google has announced PSI will stop carrying it. NeedsPAGESPEED_API_KEY.crux_history— the same field data as a series: the last 40 weekly collection periods (about nine months) from the Chrome UX Report History API, for a page or a whole origin, each rated against Google's thresholds, with when the rating last changed, and the diagnostics behind LCP and navigation. The Tool for "did the fix move anything?". Needs the samePAGESPEED_API_KEY.web_risk_check— whether Google lists the homepage, or up to nine other URLs, as malware, phishing or unwanted software: the only programmatic view of what Search Console's Security Issues report would show. NeedsGOOGLE_CLOUD_API_KEY.page_entities— which entities a page is about, ranked by salience with their Wikipedia and Knowledge Graph identity, whether its own subject leads, and its content categories. Sends the page's text to Google Cloud. NeedsGOOGLE_CLOUD_API_KEY.domain_authority— link authority for a site and up to nine competitors side by side, from Open PageRank's Common Crawl estimate. Not a Google metric. NeedsOPEN_PAGERANK_API_KEY.
Every variable is optional. Set only the ones for the Tools you want; each Tool that is missing its variable says which one and where to get a value.
| Variable | Tools | What to enable | Cost |
|---|---|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
every gsc_*, ga4_* and site_* Tool, run_site_audit, sync_gsc_properties |
A "Desktop app" OAuth client with the Search Console, Analytics Data and Analytics Admin APIs enabled, then pnpm mcp-auth — see Google login |
Free |
PAGESPEED_API_KEY |
pagespeed_insights, crux_history, site_vitals_by_traffic |
The PageSpeed Insights API and the Chrome UX Report API, on the key's project | Free, no billing account |
GOOGLE_CLOUD_API_KEY |
web_risk_check, page_entities |
The Web Risk API and/or the Cloud Natural Language API, on a project with billing enabled | Free tiers: 100,000 Web Risk lookups a month; 5,000 Natural Language units a month (a unit is 1,000 characters) |
OPEN_PAGERANK_API_KEY |
domain_authority |
An Open PageRank key, from its dashboard | Free plan: 30,000 domains a month |
GOOGLE_KG_API_KEY |
seo_geo_score, ai_visibility_score (one check each) |
The Knowledge Graph Search API | Free. Optional enrichment: without it that one check reports as not evaluated, and the Tool still runs |
wayback_history and brand_pageviews read the Internet Archive and Wikimedia,
which need no key at all.
Put them in a .env file at the root of the server — copy
.env.example, which carries the same notes:
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
PAGESPEED_API_KEY=your_free_key
GOOGLE_CLOUD_API_KEY=your_billed_key
OPEN_PAGERANK_API_KEY=your_opr_key
GOOGLE_KG_API_KEY=your_kg_keyCreate the Google keys at Google Cloud Console.
GOOGLE_CLOUD_API_KEY is a separate variable from PAGESPEED_API_KEY on purpose:
Web Risk and Cloud Natural Language refuse a project without billing even inside
their free tiers, and the free key never has to sit on one. A key whose project
has the API disabled, or has no billing account, gets an error naming exactly
that and the console page that fixes it. docs/setup.md walks
through each.
Without it each Tool is still listed, and returns an error naming the variable
and where to get a value. That is the rule for every Tool on this server, recorded
in ADR-0003: a Tool that
cannot do its whole job says what to configure and never returns a smaller result
instead. Tools stay in tools/list whether or not they are usable, because many
MCP clients cache that list and a Tool that vanishes leaves the agent nothing to
explain.
Three prompts orchestrate the Tools rather than leaving an agent to pick them by
name: audit_site, find_quick_wins and track_progress. Three playbooks are
readable as MCP resources under seo://playbooks/ — quick wins, site audit, and
being cited by AI answers.
The server also delivers instructions in the handshake, so an agent knows which Tool to start with and what a refusal means before its first call. A test asserts that every Tool name any of those mention actually exists.
pnpm test # unit suite: no network, no credentials, no database
pnpm test:e2e # builds, then drives the real server over HTTP
pnpm typecheckpnpm dev runs the server with reloading.
pnpm dev and pnpm start cannot run at the same time: both own dist/, and
they would fight over the port anyway.
Opening http://127.0.0.1:3737/ in a browser shows the server's own page — what
is running, what needs no setup, and how to connect. It is generated by
pnpm home (which pnpm build runs first) and committed, so its Tool count is
never a number somebody forgot to update.
The Search Console and Analytics Tools read the Operator's own Google data, so
they need the Operator's own OAuth client. Create one in
Google Cloud Console under
APIs & Services > Credentials, choosing the application type Desktop app —
that type is what permits the localhost redirect this login uses
(ADR-0002). Enable the Search
Console API, the Google Analytics Data API and the Google Analytics Admin API for
the same project — the Admin API is what lists your properties and what
ga4_setup_audit and ga4_annotations read.
Put the two values in .env — .env.example lists every variable this server
reads — and then log in:
# .env
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...thatseoagent-mcp-login # or `pnpm mcp-auth` from a cloneThe command prints the two scopes it asks for and what stops working without
each, opens your browser, receives Google's redirect on
http://127.0.0.1:3738/callback, stores the tokens and stops listening. Both
scopes are read-only: this server never submits a sitemap, requests
indexing, or writes anything to your Google account.
You log in once. The server refreshes the access token internally from then on. Re-running the command switches accounts. Tokens are stored unencrypted in the local database, which ADR-0002 records as deliberate — the file is local and gitignored, and a key in the adjacent environment file would protect nothing.
The server keeps a SQLite database under db/. It is created on first use and
migrated automatically — there is no command to run. What it holds is history:
Site records, audit runs, metric readings and their monthly rollups, page audits,
and a cache of Tool results so repeating an analysis does not re-crawl the site.
It is optional. With TSA_DB_PATH=off in .env the server starts anyway, every
credential-free Tool works exactly as before, and nothing is cached or kept.
TSA_DB_PATH=/some/path.sqlite puts the file elsewhere.
pnpm db:generate # regenerate drizzle/ after editing src/lib/db/schema.tspnpm start goes through scripts/start.mjs, which refuses to start when port
3737 is already taken rather than letting the server move to another port — the
listening address is compiled into the build, so a server that moved would leave
your MCP client pointed at nothing. To change the port, edit
src/lib/server-address.json and rebuild.
CONTEXT.md— the domain glossary. Use these words.docs/adr/— the decisions that are hard to reverse, and why. Read these before changing how the server is reached, how it authenticates to Google, or what a tool does when it cannot do its whole job.
MIT — see LICENSE.