# seo: a MAJR Agent Service
> Generates SEO / GEO / AEO machinery: production-ready robots.txt (AI crawlers welcomed
> by default), sitemap.xml, the page `
` block (title, description, canonical, Open
> Graph + schema.org JSON-LD), and llms.txt. Separately, AUDITS one page: technical SEO
> and social metadata, plus two AI-readiness signals. Machinery only: page content and
> keyword strategy stay with the caller.
> Scope of the audit, precisely — it is a **technical SEO audit**, not a full GEO/AEO
> assessment. It scores 11 deterministic checks on the HTML of ONE page. Two of them are
> AI-readiness signals: `json_ld` (structured data) and `ssr_text` (your copy is in the
> served HTML, not JS-injected). It does NOT read your llms.txt or your robots.txt
> AI-crawler policy — this service GENERATES both, but the audit gives them no credit, so
> a perfect score is not a statement about answer-engine readiness.
> Privacy: nothing is persisted; the audit logs fetched URLs as scheme + host + path only
> (never the query, fragment, or `user:pass@` credentials), so signed or credentialed URLs
> can't leak into logs.
Auth: every `/v1/*` call and every MCP request needs `Authorization: Bearer ` — a
per-product key; ask jamie@majr.app for one. Usage is metered against the key, so it is
also your identity. Errors: RFC 7807 `application/problem+json`; your request content is
never echoed back. A `401` means the key is wrong. A `403` with
`code: insufficient_service_scope` means the key is VALID but not scoped for `seo` — do not
rotate it and do not retry here: use a key whose scopes include `seo`. The body's
`granted_scopes` lists where that key does work, `required_scope` names this service, and
`WWW-Authenticate: Bearer error="insufficient_scope", scope="seo"` carries the same signal.
A `503` is NEVER a verdict on your key — it means the service is unconfigured, or auth isn't
judgeable yet (honor `Retry-After` and retry; do not rotate). Every request may carry a
`source` slug (who's calling) — your own provenance label only; attribution comes from the
key and `source` is ignored for it.
## MCP (Model Context Protocol)
Standard MCP over Streamable HTTP, stateless, at `https://seo.majr.app/mcp` — **any MCP client works**;
nothing here is specific to one vendor or model. Auth is the same `Authorization: Bearer ` header as `/v1`. **Six tools — the full `/v1` surface: `audit_page`, `audit_html`,
`generate_head`, `generate_robots`, `generate_sitemap`, `generate_llms_txt`.** Every tool runs
the same engine as its `/v1` sibling: same input rules, same output, same cost.
- `audit_page(url)` — fetch one public http(s) URL and audit its technical SEO + AI-readiness
signals. Returns `{url, score, findings: [{check, ok, detail}], version}`. `score` is the
fraction of checks passed (0..1); each finding names a `check`, whether it passed (`ok`),
and a short `detail` saying what is missing when it fails.
- `audit_html(html, url?)` — the same scorecard for HTML you already have, so you can check
a fix **before** deploying it. Render your template first (JavaScript is not executed).
`url` is an optional label to match the result against an earlier `audit_page` run — it is
not fetched. `http_200` always passes here: you already have the document.
- `generate_head(title, description?, canonical_path?, site_url?, robots?, og?, twitter_card?,
json_ld?)` — the page `` inner block (title, meta description, canonical, Open
Graph/Twitter, schema.org JSON-LD), ready to embed. Returns `{html, version}`.
- `generate_robots(site_url?, sitemap_path?, allow_ai?, disallow_paths?)` — production-ready
robots.txt. `allow_ai` defaults **true**: AI crawlers (GPTBot, ClaudeBot, PerplexityBot,
Google-Extended, ...) are explicitly welcomed; `false` emits an explicit Disallow for them.
Returns `{content, version}`.
- `generate_sitemap(site_url?, entries?)` — sitemaps.org-valid sitemap.xml;
`entries: [{path, changefreq?, priority?, lastmod?}]` is validated at the boundary so a typo
cannot emit invalid XML. Returns `{content, version}`.
- `generate_llms_txt(site_name, summary, site_url?, key_facts?, links?)` — an llms.txt for
YOUR site, so answer engines cite you accurately. Returns `{content, version}`.
Generation tools are pure — nothing is fetched, nothing persisted; you author the content,
they render the machinery deterministically. The loop: `audit_page` the live page → fix your
template (`generate_*` render the artifacts you need) → `audit_html` the render → deploy →
`audit_page` again to confirm.
Connect — example, Claude Code's CLI. Point any other MCP client at the same endpoint and
header; the registration command is your client's, not this service's:
claude mcp add --transport http seo https://seo.majr.app/mcp \
--header "Authorization: Bearer $SEO_API_KEY"
Limits worth knowing: one page per call (no crawl), only the first 2,000,000 BYTES of HTML is
read (`audit_page` caps the content-decoded response body — gzip is already undone, so this is
decompressed size, not transfer size — and character-decodes what it kept; `audit_html` caps the
UTF-8 encoding of the string you send. Same cut for UTF-8 pages, which is nearly all of them,
though a non-UTF-8 page near the cap may keep a slightly different tail), and
JavaScript is NOT executed — a failing `ssr_text` means the copy isn't in the served HTML,
which is what crawlers and answer engines actually read. A page that doesn't serve a
scorecard-able 200 (dead host, 404, refused target) returns `score: 0.0` with one failed
`http_200` finding: that means unreachable, NOT that the page scored badly.
## The 11 checks, and the exact rule each applies
`score` = passed / 11, equal weight, rounded to 3 decimals. No check is weighted above any
other: a missing `` and a missing `og:image` each cost 1/11. These are the thresholds
in the code — not advice, the actual pass conditions:
| check | passes when |
|---|---|
| `http_200` | status is exactly `200`. Redirects are followed first (max 10 hops). Any other status, or an unreachable host, short-circuits the audit: one finding, `score: 0.0`. |
| `title` | `` exists and is non-empty after trimming. |
| `title_length` | title length is **10-65 characters** inclusive. Always reported: a missing title fails this too, so it can't score better by shrinking the denominator. |
| `meta_description` | `` exists AND has a non-empty `content`. |
| `canonical` | `` exists AND has a non-empty `href`. |
| `og_title` | `` exists AND is non-empty. |
| `og_image` | `` exists AND is non-empty. **Presence only — the URL is never fetched**, so a page can pass while pointing at a 404. |
| `json_ld` | at least 1 `