# AI SEO: a MAJR Agent Service
> Generates AI SEO 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 13 deterministic checks on 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, and it reads your
> robots.txt for exactly one question — is this path disallowed — giving your AI-crawler
> policy no credit either way. This service GENERATES both artifacts; the audit does not
> score them, 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 — mint your own at https://majr.app/keys (email sign-in, free, instant).
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 `429` with `code: free_tier_quota` means the key is over its free-tier quota: `reason`
(`account_quota` | `fleet_quota`) and `resets_at` say why and until when — honor `Retry-After`;
the key is valid, do not rotate.
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)` — audit one public http(s) URL's technical SEO + AI-readiness signals.
Returns `{url, score, findable, citable, 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. Three checks always pass here, because none is knowable from HTML: `http_200` (you
already have the document), `crawlable` (there is no origin to read `/robots.txt` from), and
`canonical_resolves` (there is nothing to dial). `indexable` reads markup only — an
`X-Robots-Tag` response header is not knowable either, so only a live `audit_page` run catches a
server-set noindex. Verify a template here; confirm the deployment with `audit_page`.
- `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 SCORED per call (no crawl — `audit_page` makes up to three FETCH
OPERATIONS, but the two extras are `/robots.txt` and the canonical target, and neither is scored as
a page of its own). Each operation follows up to 10 redirects, so the outbound REQUEST ceiling for
one audit is 33 while the common case is two; each is bounded end to end by the audit fetch timeout,
so the time ceiling is 3x that and not 33x. 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 13 checks, and the exact rule each applies
`score` = passed / 13, equal weight, rounded to 3 decimals. No check is weighted above any
other: a missing `` and a missing `og:image` each cost 1/13. These are the thresholds
in the code — not advice, the actual pass conditions:
Because that flat average cannot tell you WHICH problem you have, every audit also returns two
sub-scores over the same 13 checks, each rounded the same way:
- **`findable`** = passed / 3 over `http_200`, `indexable`, `crawlable` — can this URL be reached
and indexed at all? **Fix these first.** A failure means the page is unreachable, or it carries a
block this audit directly observed; either way it is not tradeable against a social tag. Only
HARD BLOCKS are counted here, never missing hints, so anything below 1.0 is worth acting on
immediately. The `indexable` finding's `detail` names WHERE the block was seen —
`noindex directive in robots metadata`, `in X-Robots-Tag`, or both. It does NOT name a crawler:
the fix is to remove the directive either way, and this audit is in no position to say which
engines still index you.
- **`citable`** = passed / 10 over `canonical`, `canonical_resolves`, `title`, `title_length`,
`meta_description`, `og_title`, `og_image`, `json_ld`, `h1`, `ssr_text` — once reached, can an
engine extract and attribute the page? `canonical` scores here rather than in `findable` because
a page without one is still indexed and still ranks: an engine picks a canonical from other
signals. The tag decides which URL gets CREDITED for the content, which is attribution, not reach.
An unreachable page short-circuits, so only `http_200` is recorded: `findable` and `citable`
are both `0.0`. That is true rather than missing — a page an engine cannot fetch cannot be cited.
| 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`. |
| `canonical_resolves` | the `href` above, resolved against the URL that served the page, answers `200`. A self-referential canonical passes without a second fetch — you are holding its response. A page that declares NO canonical also passes: `canonical` already failed for it, and failing both would price a missing tag above a broken one. `audit_html` passes it unchecked; there is nothing to dial. |
| `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 `