@automatelab/ai-seo-mcp 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -18
- package/dist/cli/audit-cli.d.ts +3 -0
- package/dist/cli/audit-cli.d.ts.map +1 -0
- package/dist/cli/audit-cli.js +136 -0
- package/dist/cli/audit-cli.js.map +1 -0
- package/dist/index.js +710 -227
- package/dist/index.js.map +1 -1
- package/dist/lib/cache.d.ts +12 -0
- package/dist/lib/cache.d.ts.map +1 -0
- package/dist/lib/cache.js +51 -0
- package/dist/lib/cache.js.map +1 -0
- package/dist/lib/config.js +1 -1
- package/dist/lib/fetch.d.ts +5 -0
- package/dist/lib/fetch.d.ts.map +1 -1
- package/dist/lib/fetch.js +42 -1
- package/dist/lib/fetch.js.map +1 -1
- package/dist/lib/headless.d.ts +13 -0
- package/dist/lib/headless.d.ts.map +1 -0
- package/dist/lib/headless.js +73 -0
- package/dist/lib/headless.js.map +1 -0
- package/dist/lib/scorecard-html.js +94 -94
- package/dist/output-schemas.d.ts +658 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +265 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/tools/audit-page.d.ts +11 -0
- package/dist/tools/audit-page.d.ts.map +1 -1
- package/dist/tools/audit-page.js +49 -2
- package/dist/tools/audit-page.js.map +1 -1
- package/dist/tools/audit-schema.d.ts +2 -1
- package/dist/tools/audit-schema.d.ts.map +1 -1
- package/dist/tools/audit-schema.js +2 -1
- package/dist/tools/audit-schema.js.map +1 -1
- package/dist/tools/audit-site.d.ts +41 -0
- package/dist/tools/audit-site.d.ts.map +1 -0
- package/dist/tools/audit-site.js +120 -0
- package/dist/tools/audit-site.js.map +1 -0
- package/dist/tools/audit-sitemap.d.ts +66 -0
- package/dist/tools/audit-sitemap.d.ts.map +1 -0
- package/dist/tools/audit-sitemap.js +264 -0
- package/dist/tools/audit-sitemap.js.map +1 -0
- package/dist/tools/check-technical.d.ts +2 -1
- package/dist/tools/check-technical.d.ts.map +1 -1
- package/dist/tools/check-technical.js +2 -1
- package/dist/tools/check-technical.js.map +1 -1
- package/dist/tools/diff-pages.js +2 -2
- package/dist/tools/diff-pages.js.map +1 -1
- package/dist/tools/extract-entities.d.ts +8 -1
- package/dist/tools/extract-entities.d.ts.map +1 -1
- package/dist/tools/extract-entities.js +110 -3
- package/dist/tools/extract-entities.js.map +1 -1
- package/dist/tools/save-audit-report.d.ts +22 -0
- package/dist/tools/save-audit-report.d.ts.map +1 -0
- package/dist/tools/save-audit-report.js +133 -0
- package/dist/tools/save-audit-report.js.map +1 -0
- package/dist/tools/test-citation.d.ts +51 -0
- package/dist/tools/test-citation.d.ts.map +1 -0
- package/dist/tools/test-citation.js +159 -0
- package/dist/tools/test-citation.js.map +1 -0
- package/package.json +8 -1
- package/smithery.yaml +13 -0
package/dist/index.js
CHANGED
|
@@ -17,11 +17,16 @@ import { scoreCitationWorthiness } from "./tools/score-citation-worthiness.js";
|
|
|
17
17
|
import { rewriteForAeo } from "./tools/rewrite-for-aeo.js";
|
|
18
18
|
import { rewriteForGeo } from "./tools/rewrite-for-geo.js";
|
|
19
19
|
import { extractEntities } from "./tools/extract-entities.js";
|
|
20
|
+
import { testCitation } from "./tools/test-citation.js";
|
|
20
21
|
import { diffPages, diffPagesInputSchema } from "./tools/diff-pages.js";
|
|
22
|
+
import { auditSite, auditSiteInputSchema } from "./tools/audit-site.js";
|
|
23
|
+
import { auditSitemap, auditSitemapInputSchema } from "./tools/audit-sitemap.js";
|
|
24
|
+
import { saveAuditReport, saveAuditReportInputSchema } from "./tools/save-audit-report.js";
|
|
25
|
+
import { auditPageOutputShape, auditSchemaOutputShape, auditCanonicalOutputShape, auditSiteOutputShape, auditSitemapOutputShape, checkRobotsOutputShape, checkSitemapOutputShape, checkTechnicalOutputShape, scoreAiOverviewOutputShape, scoreCitationOutputShape, generateLlmsTxtOutputShape, validateLlmsTxtOutputShape, rewriteOutputShape, extractEntitiesOutputShape, testCitationOutputShape, diffPagesOutputShape, saveReportOutputShape, } from "./output-schemas.js";
|
|
21
26
|
import { ToolFetchError } from "./lib/fetch.js";
|
|
22
27
|
const server = new McpServer({
|
|
23
28
|
name: "@automatelab/ai-seo-mcp",
|
|
24
|
-
version: "0.1
|
|
29
|
+
version: "0.4.1",
|
|
25
30
|
});
|
|
26
31
|
/** Serialize a ToolError to MCP error content. */
|
|
27
32
|
function toolError(err) {
|
|
@@ -30,11 +35,17 @@ function toolError(err) {
|
|
|
30
35
|
isError: true,
|
|
31
36
|
};
|
|
32
37
|
}
|
|
33
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Wrap a tool handler:
|
|
40
|
+
* - On success, returns both `content` (JSON text for legacy clients) and
|
|
41
|
+
* `structuredContent` (parsed object validated against the tool's outputSchema).
|
|
42
|
+
* - On failure, returns a typed ToolError as a text-only error response.
|
|
43
|
+
*/
|
|
34
44
|
function wrapHandler(handler) {
|
|
35
45
|
return handler()
|
|
36
46
|
.then((result) => ({
|
|
37
47
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
48
|
+
structuredContent: result,
|
|
38
49
|
}))
|
|
39
50
|
.catch((err) => {
|
|
40
51
|
if (err instanceof ToolFetchError) {
|
|
@@ -45,266 +56,738 @@ function wrapHandler(handler) {
|
|
|
45
56
|
return toolError({ type: "fetch_error", url: "", message });
|
|
46
57
|
});
|
|
47
58
|
}
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Tool naming convention: dot-notation forms a navigable tree.
|
|
61
|
+
// audit.* - composite audits that fetch and analyse a page or site
|
|
62
|
+
// check.* - focused single-dimension checks (robots, sitemap, technical)
|
|
63
|
+
// score.* - numeric scoring of citation / AI-Overview eligibility / cite-or-not
|
|
64
|
+
// llms_txt.* - generate / validate llms.txt files
|
|
65
|
+
// rewrite.* - LLM-assisted content rewrites (delegated via MCP sampling)
|
|
66
|
+
// extract.* - entity / structure extraction
|
|
67
|
+
// diff.* - comparisons between pages
|
|
68
|
+
// report.* - report rendering and storage
|
|
69
|
+
// Renamed in v0.4.0 from the previous flat snake_case names.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// --- audit.page ---
|
|
72
|
+
server.registerTool("audit.page", {
|
|
73
|
+
title: "Audit page (full)",
|
|
74
|
+
description: [
|
|
75
|
+
"Full AI-SEO audit of a single URL: returns categorized findings (info/warning/error) with severity, fix instructions, and a 0-100 composite score plus per-dimension subscores.",
|
|
76
|
+
"Read-only. Fetches the URL once and runs every sub-audit (schema, robots, technical, sitemap, AI-Overview eligibility) against the response. No writes, no third-party APIs, no auth required, no rate limits beyond polite per-host throttling.",
|
|
77
|
+
"Deterministic, rule-based scoring; no LLM calls. Same URL + same input flags returns the same score.",
|
|
78
|
+
"Supports `render: \"static\" | \"headless\"`. Default `static` (fast, raw HTML only). Use `headless` for React/Vue/Angular SPAs — adds 3-10s and requires the optional `playwright-core` peer dep plus a one-time `npx playwright install chromium`.",
|
|
79
|
+
"When to use: the default entry point for `audit any page`. Use this instead of calling check.technical / audit.schema / check.robots / check.sitemap / score.ai_overview_eligibility individually unless you specifically need only one dimension - this tool composes all of them.",
|
|
80
|
+
].join("\n\n"),
|
|
81
|
+
inputSchema: auditPageInputSchema.shape,
|
|
82
|
+
outputSchema: auditPageOutputShape,
|
|
83
|
+
annotations: {
|
|
84
|
+
title: "Audit page (full)",
|
|
85
|
+
readOnlyHint: true,
|
|
86
|
+
destructiveHint: false,
|
|
87
|
+
idempotentHint: true,
|
|
88
|
+
openWorldHint: true,
|
|
89
|
+
},
|
|
90
|
+
}, async (input) => wrapHandler(() => auditPage(input)));
|
|
91
|
+
// --- audit.schema ---
|
|
92
|
+
server.registerTool("audit.schema", {
|
|
93
|
+
title: "Audit JSON-LD schema",
|
|
94
|
+
description: [
|
|
95
|
+
"Validate JSON-LD structured data against Schema.org rules and AI-citation best practices. Accepts either a URL (fetched) or a raw JSON string (parsed directly).",
|
|
96
|
+
"Read-only when given `url` (one HTTP GET). Zero network when given `schema_json`. No writes.",
|
|
97
|
+
"Deterministic, rule-based; no LLM. Validates required/recommended properties, @context correctness, sameAs links, and AI-search-friendly patterns.",
|
|
98
|
+
"When to use: focused JSON-LD audits, or to validate a schema block you're about to ship. For a full page audit that includes schema + everything else, use `audit.page` instead.",
|
|
99
|
+
"Either `url` or `schema_json` must be provided (not both). If both are provided, `schema_json` wins and no fetch happens.",
|
|
100
|
+
].join("\n\n"),
|
|
101
|
+
inputSchema: {
|
|
102
|
+
url: z
|
|
103
|
+
.string()
|
|
104
|
+
.url()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("Public URL to fetch and audit. Either this OR `schema_json` is required. Read-only HTTP GET."),
|
|
107
|
+
schema_json: z
|
|
108
|
+
.string()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe("Raw JSON-LD as a string (the contents of a `<script type=\"application/ld+json\">` block). Use this to validate a schema block offline without fetching a URL. Either this OR `url` is required."),
|
|
111
|
+
respect_robots: z
|
|
112
|
+
.boolean()
|
|
113
|
+
.optional()
|
|
114
|
+
.default(true)
|
|
115
|
+
.describe("If true (default), respect robots.txt before fetching `url`. Ignored when `schema_json` is used."),
|
|
116
|
+
},
|
|
117
|
+
outputSchema: auditSchemaOutputShape,
|
|
118
|
+
annotations: {
|
|
119
|
+
title: "Audit JSON-LD schema",
|
|
120
|
+
readOnlyHint: true,
|
|
121
|
+
destructiveHint: false,
|
|
122
|
+
idempotentHint: true,
|
|
123
|
+
openWorldHint: true,
|
|
124
|
+
},
|
|
77
125
|
}, async (input) => {
|
|
78
126
|
if (!input.url && !input.schema_json) {
|
|
79
127
|
return toolError({ type: "invalid_url", message: "One of url or schema_json is required" });
|
|
80
128
|
}
|
|
81
129
|
return wrapHandler(() => auditSchema(input));
|
|
82
130
|
});
|
|
83
|
-
// ---
|
|
84
|
-
server.
|
|
85
|
-
"Audit
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
].join("\n\n"),
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
.
|
|
146
|
-
|
|
131
|
+
// --- audit.canonical ---
|
|
132
|
+
server.registerTool("audit.canonical", {
|
|
133
|
+
title: "Audit canonical link integrity",
|
|
134
|
+
description: [
|
|
135
|
+
"Audit a page's canonical link integrity: presence, self-reference, cross-domain mismatches, trailing-slash hygiene, and og:url consistency.",
|
|
136
|
+
"Read-only. One HTTP GET to fetch the HEAD section.",
|
|
137
|
+
"Deterministic, rule-based; no LLM.",
|
|
138
|
+
"When to use: a focused canonical-only audit (e.g. debugging a duplicate-content issue). For a full HEAD audit including OpenGraph, hreflang, noindex, title, use `check.technical`. For everything-on-a-page, use `audit.page`.",
|
|
139
|
+
].join("\n\n"),
|
|
140
|
+
inputSchema: auditCanonicalInputSchema.shape,
|
|
141
|
+
outputSchema: auditCanonicalOutputShape,
|
|
142
|
+
annotations: {
|
|
143
|
+
title: "Audit canonical link integrity",
|
|
144
|
+
readOnlyHint: true,
|
|
145
|
+
destructiveHint: false,
|
|
146
|
+
idempotentHint: true,
|
|
147
|
+
openWorldHint: true,
|
|
148
|
+
},
|
|
149
|
+
}, async (input) => wrapHandler(() => auditCanonical(input)));
|
|
150
|
+
// --- check.robots ---
|
|
151
|
+
server.registerTool("check.robots", {
|
|
152
|
+
title: "Check robots.txt crawler posture",
|
|
153
|
+
description: [
|
|
154
|
+
"Fetch and parse a domain's robots.txt; report per-crawler allow/disallow posture for every known AI training crawler (GPTBot, CCBot, Anthropic-AI, Google-Extended, etc.), AI search crawlers (ChatGPT-User, PerplexityBot, OAI-SearchBot), and user-triggered fetchers.",
|
|
155
|
+
"Read-only. One HTTP GET to /robots.txt. No auth, no rate limits applied.",
|
|
156
|
+
"Deterministic, rule-based; no LLM. Returns structured findings with per-crawler status.",
|
|
157
|
+
"When to use: figuring out which AI crawlers a site blocks vs allows. Combine with `check.sitemap` for a full pre-crawl audit. Distinct from `audit.page` which evaluates a single URL; this evaluates a whole-domain policy.",
|
|
158
|
+
].join("\n\n"),
|
|
159
|
+
inputSchema: checkRobotsInputSchema.shape,
|
|
160
|
+
outputSchema: checkRobotsOutputShape,
|
|
161
|
+
annotations: {
|
|
162
|
+
title: "Check robots.txt crawler posture",
|
|
163
|
+
readOnlyHint: true,
|
|
164
|
+
destructiveHint: false,
|
|
165
|
+
idempotentHint: true,
|
|
166
|
+
openWorldHint: true,
|
|
167
|
+
},
|
|
168
|
+
}, async (input) => wrapHandler(() => checkRobots(input)));
|
|
169
|
+
// --- check.sitemap ---
|
|
170
|
+
server.registerTool("check.sitemap", {
|
|
171
|
+
title: "Check XML sitemap health",
|
|
172
|
+
description: [
|
|
173
|
+
"Validate a domain's XML sitemap: presence, accessibility, URL count, lastmod freshness, sitemap-index handling, and image/video sitemap extensions.",
|
|
174
|
+
"Read-only. Issues N+1 HTTP GETs: one for robots.txt + sitemap, then up to `max_urls_to_check` HEADs against sampled URLs.",
|
|
175
|
+
"Deterministic, rule-based; no LLM.",
|
|
176
|
+
"When to use: site-wide indexing audits. Pair with `check.robots` for a full pre-crawl picture. For per-page checks, use `audit.page` or `check.technical` instead.",
|
|
177
|
+
].join("\n\n"),
|
|
178
|
+
inputSchema: checkSitemapInputSchema.shape,
|
|
179
|
+
outputSchema: checkSitemapOutputShape,
|
|
180
|
+
annotations: {
|
|
181
|
+
title: "Check XML sitemap health",
|
|
182
|
+
readOnlyHint: true,
|
|
183
|
+
destructiveHint: false,
|
|
184
|
+
idempotentHint: true,
|
|
185
|
+
openWorldHint: true,
|
|
186
|
+
},
|
|
187
|
+
}, async (input) => wrapHandler(() => checkSitemap(input)));
|
|
188
|
+
// --- check.technical ---
|
|
189
|
+
server.registerTool("check.technical", {
|
|
190
|
+
title: "Check technical HEAD signals",
|
|
191
|
+
description: [
|
|
192
|
+
"Audit a page's HEAD section for technical signals relevant to AI crawlers: HTTPS, canonical, OpenGraph, Twitter Card, hreflang, noindex, and title-vs-H1 hygiene.",
|
|
193
|
+
"Read-only. One HTTP GET, inspects HEAD only (body is not parsed).",
|
|
194
|
+
"Deterministic, rule-based; no LLM.",
|
|
195
|
+
"When to use: when you specifically need HEAD-tag audit findings. For the full page including schema and AI-Overview scoring, use `audit.page`. For canonical-only, use `audit.canonical`.",
|
|
196
|
+
].join("\n\n"),
|
|
197
|
+
inputSchema: checkTechnicalInputSchema.shape,
|
|
198
|
+
outputSchema: checkTechnicalOutputShape,
|
|
199
|
+
annotations: {
|
|
200
|
+
title: "Check technical HEAD signals",
|
|
201
|
+
readOnlyHint: true,
|
|
202
|
+
destructiveHint: false,
|
|
203
|
+
idempotentHint: true,
|
|
204
|
+
openWorldHint: true,
|
|
205
|
+
},
|
|
206
|
+
}, async (input) => wrapHandler(() => checkTechnical(input)));
|
|
207
|
+
// --- score.ai_overview_eligibility ---
|
|
208
|
+
server.registerTool("score.ai_overview_eligibility", {
|
|
209
|
+
title: "Score AI Overview eligibility",
|
|
210
|
+
description: [
|
|
211
|
+
"Score a page's probability of appearing in Google AI Overviews. Returns an overall 0-100 score plus six factor subscores: semantic completeness, structured data, E-E-A-T signals, entity density, freshness, and technical hygiene.",
|
|
212
|
+
"Read-only. One HTTP GET.",
|
|
213
|
+
"Deterministic, rule-based scoring derived from published 2025-2026 AI-Overview correlation studies. No LLM calls. Same URL returns the same score on repeated runs.",
|
|
214
|
+
"When to use: AI-Overview-specific prioritization. For a multi-dimensional audit that includes this scoring plus everything else, use `audit.page`. For citation-worthiness of a specific text passage (rather than a URL ranking probability), use `score.citation_worthiness`.",
|
|
215
|
+
].join("\n\n"),
|
|
216
|
+
inputSchema: scoreAiOverviewEligibilityInputSchema.shape,
|
|
217
|
+
outputSchema: scoreAiOverviewOutputShape,
|
|
218
|
+
annotations: {
|
|
219
|
+
title: "Score AI Overview eligibility",
|
|
220
|
+
readOnlyHint: true,
|
|
221
|
+
destructiveHint: false,
|
|
222
|
+
idempotentHint: true,
|
|
223
|
+
openWorldHint: true,
|
|
224
|
+
},
|
|
225
|
+
}, async (input) => wrapHandler(() => scoreAiOverviewEligibility(input)));
|
|
226
|
+
// --- llms_txt.generate ---
|
|
227
|
+
server.registerTool("llms_txt.generate", {
|
|
228
|
+
title: "Generate llms.txt",
|
|
229
|
+
description: [
|
|
230
|
+
"Generate a spec-compliant llms.txt (and optionally llms-full.txt) for a domain by reading its sitemap, sampling up to `max_pages` pages, and synthesizing a grouped, sectioned summary.",
|
|
231
|
+
"Read-only. Issues one HTTP GET for the sitemap then one per sampled page.",
|
|
232
|
+
"Deterministic; no LLM. Output is the file content as a string - this tool does NOT write to disk or upload anywhere. The caller is responsible for hosting the resulting file at `https://<domain>/llms.txt`.",
|
|
233
|
+
"When to use: bootstrapping llms.txt for a site you own. To check an existing llms.txt, use `llms_txt.validate` instead.",
|
|
234
|
+
].join("\n\n"),
|
|
235
|
+
inputSchema: generateLlmsTxtInputSchema.shape,
|
|
236
|
+
outputSchema: generateLlmsTxtOutputShape,
|
|
237
|
+
annotations: {
|
|
238
|
+
title: "Generate llms.txt",
|
|
239
|
+
readOnlyHint: true,
|
|
240
|
+
destructiveHint: false,
|
|
241
|
+
idempotentHint: true,
|
|
242
|
+
openWorldHint: true,
|
|
243
|
+
},
|
|
244
|
+
}, async (input) => wrapHandler(() => generateLlmsTxtTool(input)));
|
|
245
|
+
// --- llms_txt.validate ---
|
|
246
|
+
server.registerTool("llms_txt.validate", {
|
|
247
|
+
title: "Validate llms.txt",
|
|
248
|
+
description: [
|
|
249
|
+
"Validate an existing llms.txt or llms-full.txt against the spec: structure, section ordering, link format, and (optionally) broken-link detection.",
|
|
250
|
+
"Read-only. One HTTP GET when given `url`; zero network when given `content`. Optional link-check issues HEAD requests against each link if `check_links` is true.",
|
|
251
|
+
"Deterministic; no LLM.",
|
|
252
|
+
"When to use: auditing an llms.txt you already have. To generate one from scratch, use `llms_txt.generate`.",
|
|
253
|
+
"Either `url` or `content` must be provided.",
|
|
254
|
+
].join("\n\n"),
|
|
255
|
+
inputSchema: {
|
|
256
|
+
url: z
|
|
257
|
+
.string()
|
|
258
|
+
.url()
|
|
259
|
+
.optional()
|
|
260
|
+
.describe("Public URL of an existing llms.txt or llms-full.txt to validate (e.g. `https://example.com/llms.txt`). Either this OR `content` is required."),
|
|
261
|
+
content: z
|
|
262
|
+
.string()
|
|
263
|
+
.optional()
|
|
264
|
+
.describe("Raw llms.txt content as a string. Use this to validate a file offline without fetching. Either this OR `url` is required."),
|
|
265
|
+
check_links: z
|
|
266
|
+
.boolean()
|
|
267
|
+
.optional()
|
|
268
|
+
.default(true)
|
|
269
|
+
.describe("If true (default), HEAD each linked URL to detect broken links. Set false to skip link checks for faster, network-light validation of just the structural rules."),
|
|
270
|
+
},
|
|
271
|
+
outputSchema: validateLlmsTxtOutputShape,
|
|
272
|
+
annotations: {
|
|
273
|
+
title: "Validate llms.txt",
|
|
274
|
+
readOnlyHint: true,
|
|
275
|
+
destructiveHint: false,
|
|
276
|
+
idempotentHint: true,
|
|
277
|
+
openWorldHint: true,
|
|
278
|
+
},
|
|
147
279
|
}, async (input) => {
|
|
148
280
|
if (!input.url && !input.content) {
|
|
149
281
|
return toolError({ type: "invalid_url", message: "One of url or content is required" });
|
|
150
282
|
}
|
|
151
283
|
return wrapHandler(() => validateLlmsTxt(input));
|
|
152
284
|
});
|
|
153
|
-
// ---
|
|
154
|
-
server.
|
|
155
|
-
"Score
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
285
|
+
// --- score.citation_worthiness ---
|
|
286
|
+
server.registerTool("score.citation_worthiness", {
|
|
287
|
+
title: "Score AI citation worthiness",
|
|
288
|
+
description: [
|
|
289
|
+
"Score how citable a page or text block is for AI engines (ChatGPT, Claude, Perplexity, Google AI Overviews). Evaluates BLUF (bottom-line-up-front) opening, FAQ patterns, statistic density, entity clarity, and answer-shape fit for the optional `target_query`.",
|
|
290
|
+
"Read-only when given `url` (one HTTP GET). Zero network when given `text`. No writes.",
|
|
291
|
+
"Deterministic, rule-based; no LLM calls. Returns reproducible scores.",
|
|
292
|
+
"When to use: pre-publish content QA, or to triage which existing pages are worth optimizing for AI citation first. Distinct from `score.ai_overview_eligibility` which scores Google-AI-Overview ranking probability for a URL; this scores the inherent citability of a text passage regardless of host.",
|
|
293
|
+
"Either `url` or `text` must be provided.",
|
|
294
|
+
].join("\n\n"),
|
|
295
|
+
inputSchema: {
|
|
296
|
+
url: z
|
|
297
|
+
.string()
|
|
298
|
+
.url()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Public URL to fetch and score. Either this OR `text` is required."),
|
|
301
|
+
text: z
|
|
302
|
+
.string()
|
|
303
|
+
.optional()
|
|
304
|
+
.describe("Raw text/markdown/HTML to score directly without fetching. Either this OR `url` is required."),
|
|
305
|
+
target_query: z
|
|
306
|
+
.string()
|
|
307
|
+
.optional()
|
|
308
|
+
.describe("Optional target search query the content is supposed to answer (e.g. `how to fix CORS errors in Next.js`). When provided, scoring weights answer-shape fit and query-term coverage. Omit if you want a query-agnostic citability score."),
|
|
309
|
+
respect_robots: z
|
|
310
|
+
.boolean()
|
|
311
|
+
.optional()
|
|
312
|
+
.default(true)
|
|
313
|
+
.describe("If true (default), respect robots.txt when fetching `url`. Ignored when `text` is used."),
|
|
314
|
+
},
|
|
315
|
+
outputSchema: scoreCitationOutputShape,
|
|
316
|
+
annotations: {
|
|
317
|
+
title: "Score AI citation worthiness",
|
|
318
|
+
readOnlyHint: true,
|
|
319
|
+
destructiveHint: false,
|
|
320
|
+
idempotentHint: true,
|
|
321
|
+
openWorldHint: true,
|
|
322
|
+
},
|
|
179
323
|
}, async (input) => {
|
|
180
324
|
if (!input.url && !input.text) {
|
|
181
325
|
return toolError({ type: "invalid_url", message: "One of url or text is required" });
|
|
182
326
|
}
|
|
183
327
|
return wrapHandler(() => scoreCitationWorthiness(input));
|
|
184
328
|
});
|
|
185
|
-
// ---
|
|
186
|
-
server.
|
|
187
|
-
"Rewrite
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
329
|
+
// --- rewrite.aeo ---
|
|
330
|
+
server.registerTool("rewrite.aeo", {
|
|
331
|
+
title: "Rewrite for Answer Engine Optimization",
|
|
332
|
+
description: [
|
|
333
|
+
"Rewrite a content block for Answer Engine Optimization. Adds a BLUF opening, FAQ structure, schema additions, and concise question-shaped headings tuned for ChatGPT / Perplexity / Google AI Overviews.",
|
|
334
|
+
"Read-only when given `url` (one HTTP GET). Zero network when given `text`. The tool does NOT write back to the URL - it only returns the rewritten content as a string. No side effects on the source.",
|
|
335
|
+
"This tool delegates the actual rewrite to the calling LLM via MCP sampling - it does not call any external API itself. The MCP host's model produces the rewrite. Same input may produce different output across runs (model-dependent).",
|
|
336
|
+
"When to use: optimizing content for direct-answer surfaces (definitions, how-tos, FAQs). For Generative Engine Optimization (entity-rich, comparison-ready synthesis), use `rewrite.geo` instead.",
|
|
337
|
+
"Either `url` or `text` must be provided. `target_query` is required.",
|
|
338
|
+
].join("\n\n"),
|
|
339
|
+
inputSchema: {
|
|
340
|
+
url: z
|
|
341
|
+
.string()
|
|
342
|
+
.url()
|
|
343
|
+
.optional()
|
|
344
|
+
.describe("Public URL whose content should be fetched and rewritten. Either this OR `text` is required."),
|
|
345
|
+
text: z
|
|
346
|
+
.string()
|
|
347
|
+
.optional()
|
|
348
|
+
.describe("Raw content (markdown or HTML) to rewrite directly. Either this OR `url` is required."),
|
|
349
|
+
target_query: z
|
|
350
|
+
.string()
|
|
351
|
+
.describe("The user query the rewrite should answer (e.g. `what is RAG`, `how to deploy Ghost to Docker`). Required - drives heading shape and BLUF wording."),
|
|
352
|
+
format: z
|
|
353
|
+
.enum(["article", "faq", "howto", "comparison"])
|
|
354
|
+
.default("article")
|
|
355
|
+
.describe("Output shape. `article` for prose-with-headings. `faq` for Q&A list. `howto` for numbered-step procedural content with HowTo schema hints. `comparison` for X-vs-Y tables. Default `article`."),
|
|
356
|
+
max_words: z
|
|
357
|
+
.number()
|
|
358
|
+
.int()
|
|
359
|
+
.min(100)
|
|
360
|
+
.max(5000)
|
|
361
|
+
.optional()
|
|
362
|
+
.default(1500)
|
|
363
|
+
.describe("Soft word budget for the rewrite. Default 1500. Range 100-5000. The rewrite tries to stay under this; very small budgets may force truncation."),
|
|
364
|
+
respect_robots: z
|
|
365
|
+
.boolean()
|
|
366
|
+
.optional()
|
|
367
|
+
.default(true)
|
|
368
|
+
.describe("If true (default), respect robots.txt when fetching `url`. Ignored when `text` is used."),
|
|
369
|
+
},
|
|
370
|
+
outputSchema: rewriteOutputShape,
|
|
371
|
+
annotations: {
|
|
372
|
+
title: "Rewrite for Answer Engine Optimization",
|
|
373
|
+
readOnlyHint: true,
|
|
374
|
+
destructiveHint: false,
|
|
375
|
+
// LLM sampling makes output non-deterministic - same input may produce different output.
|
|
376
|
+
idempotentHint: false,
|
|
377
|
+
openWorldHint: true,
|
|
378
|
+
},
|
|
222
379
|
}, async (input) => {
|
|
223
380
|
if (!input.url && !input.text) {
|
|
224
381
|
return toolError({ type: "invalid_url", message: "One of url or text is required" });
|
|
225
382
|
}
|
|
226
383
|
return wrapHandler(() => rewriteForAeo(input, undefined, undefined, server));
|
|
227
384
|
});
|
|
228
|
-
// ---
|
|
229
|
-
server.
|
|
230
|
-
"Rewrite
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
385
|
+
// --- rewrite.geo ---
|
|
386
|
+
server.registerTool("rewrite.geo", {
|
|
387
|
+
title: "Rewrite for Generative Engine Optimization",
|
|
388
|
+
description: [
|
|
389
|
+
"Rewrite a content block for Generative Engine Optimization: entity-rich, comparison-ready, synthesis-friendly. Tuned for surfaces that summarize across sources (Perplexity, Google AI Mode, Claude search).",
|
|
390
|
+
"Read-only on input. Does NOT write back to the source URL - returns the rewritten content as a string.",
|
|
391
|
+
"This tool delegates the actual rewrite to the calling LLM via MCP sampling - it does not call any external API itself. The MCP host's model produces the rewrite. Output may vary across runs (model-dependent).",
|
|
392
|
+
"When to use: optimizing for synthesis-style answers across multiple sources. For direct-answer (BLUF + FAQ) optimization on a single page, use `rewrite.aeo` instead.",
|
|
393
|
+
"Either `url` or `text` must be provided. `target_query` is required.",
|
|
394
|
+
].join("\n\n"),
|
|
395
|
+
inputSchema: {
|
|
396
|
+
url: z
|
|
397
|
+
.string()
|
|
398
|
+
.url()
|
|
399
|
+
.optional()
|
|
400
|
+
.describe("Public URL whose content should be fetched and rewritten. Either this OR `text` is required."),
|
|
401
|
+
text: z
|
|
402
|
+
.string()
|
|
403
|
+
.optional()
|
|
404
|
+
.describe("Raw content to rewrite directly. Either this OR `url` is required."),
|
|
405
|
+
target_query: z
|
|
406
|
+
.string()
|
|
407
|
+
.describe("The user query the rewrite should answer. Required - drives entity selection and comparison framing."),
|
|
408
|
+
add_comparison_table: z
|
|
409
|
+
.boolean()
|
|
410
|
+
.optional()
|
|
411
|
+
.default(false)
|
|
412
|
+
.describe("If true, inject an explicit X-vs-Y comparison table into the rewrite (useful for `X vs Y` queries). Default false."),
|
|
413
|
+
max_words: z
|
|
414
|
+
.number()
|
|
415
|
+
.int()
|
|
416
|
+
.min(100)
|
|
417
|
+
.max(5000)
|
|
418
|
+
.optional()
|
|
419
|
+
.default(1500)
|
|
420
|
+
.describe("Soft word budget. Default 1500. Range 100-5000."),
|
|
421
|
+
respect_robots: z
|
|
422
|
+
.boolean()
|
|
423
|
+
.optional()
|
|
424
|
+
.default(true)
|
|
425
|
+
.describe("If true (default), respect robots.txt when fetching `url`. Ignored when `text` is used."),
|
|
426
|
+
},
|
|
427
|
+
outputSchema: rewriteOutputShape,
|
|
428
|
+
annotations: {
|
|
429
|
+
title: "Rewrite for Generative Engine Optimization",
|
|
430
|
+
readOnlyHint: true,
|
|
431
|
+
destructiveHint: false,
|
|
432
|
+
idempotentHint: false,
|
|
433
|
+
openWorldHint: true,
|
|
434
|
+
},
|
|
266
435
|
}, async (input) => {
|
|
267
436
|
if (!input.url && !input.text) {
|
|
268
437
|
return toolError({ type: "invalid_url", message: "One of url or text is required" });
|
|
269
438
|
}
|
|
270
439
|
return wrapHandler(() => rewriteForGeo(input, undefined, undefined, server));
|
|
271
440
|
});
|
|
272
|
-
// ---
|
|
273
|
-
server.
|
|
274
|
-
"Extract named entities
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
441
|
+
// --- extract.entities ---
|
|
442
|
+
server.registerTool("extract.entities", {
|
|
443
|
+
title: "Extract named entities and sameAs links",
|
|
444
|
+
description: [
|
|
445
|
+
"Extract named entities, linked concepts, and sameAs graph nodes from a page's content and structured data. Combines body-text NER with JSON-LD `@type` / `sameAs` walking.",
|
|
446
|
+
"Read-only when given `url` (one HTTP GET). Zero network when given `text`.",
|
|
447
|
+
"Primary path: MCP sampling - the host LLM does the NER and returns typed entities with sameAs URIs. Fallback path: deterministic regex-based extractor when sampling is unavailable. The result includes `mode: \"sampling\" | \"regex_fallback\"` so callers can tell which path ran.",
|
|
448
|
+
"When to use: building an entity map for schema generation, or auditing whether a page's entities match its target topic. To validate the JSON-LD itself, use `audit.schema`.",
|
|
449
|
+
"Either `url` or `text` must be provided.",
|
|
450
|
+
].join("\n\n"),
|
|
451
|
+
inputSchema: {
|
|
452
|
+
url: z
|
|
453
|
+
.string()
|
|
454
|
+
.url()
|
|
455
|
+
.optional()
|
|
456
|
+
.describe("Public URL to fetch and analyze. Either this OR `text` is required."),
|
|
457
|
+
text: z
|
|
458
|
+
.string()
|
|
459
|
+
.optional()
|
|
460
|
+
.describe("Raw text/HTML to analyze directly. Either this OR `url` is required."),
|
|
461
|
+
respect_robots: z
|
|
462
|
+
.boolean()
|
|
463
|
+
.optional()
|
|
464
|
+
.default(true)
|
|
465
|
+
.describe("If true (default), respect robots.txt when fetching `url`. Ignored when `text` is used."),
|
|
466
|
+
render: z
|
|
467
|
+
.enum(["static", "headless"])
|
|
468
|
+
.optional()
|
|
469
|
+
.default("static")
|
|
470
|
+
.describe("Rendering mode for `url`. `static` (default) reads raw HTML. `headless` runs Playwright Chromium to capture JS-rendered content (adds 3-10s; requires `playwright-core` + `npx playwright install chromium`). Ignored when `text` is used."),
|
|
471
|
+
},
|
|
472
|
+
outputSchema: extractEntitiesOutputShape,
|
|
473
|
+
annotations: {
|
|
474
|
+
title: "Extract named entities and sameAs links",
|
|
475
|
+
readOnlyHint: true,
|
|
476
|
+
destructiveHint: false,
|
|
477
|
+
// Sampling path is model-dependent; regex fallback is deterministic. Mark as non-idempotent to be safe.
|
|
478
|
+
idempotentHint: false,
|
|
479
|
+
openWorldHint: true,
|
|
480
|
+
},
|
|
294
481
|
}, async (input) => {
|
|
295
482
|
if (!input.url && !input.text) {
|
|
296
483
|
return toolError({ type: "invalid_url", message: "One of url or text is required" });
|
|
297
484
|
}
|
|
298
|
-
return wrapHandler(() => extractEntities(input));
|
|
485
|
+
return wrapHandler(() => extractEntities(input, undefined, undefined, server));
|
|
299
486
|
});
|
|
300
|
-
// ---
|
|
301
|
-
server.
|
|
302
|
-
"
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
487
|
+
// --- score.test_citation ---
|
|
488
|
+
server.registerTool("score.test_citation", {
|
|
489
|
+
title: "Test whether an AI engine would cite this page",
|
|
490
|
+
description: [
|
|
491
|
+
"Simulate `would an AI engine cite this page for this query?`. The host LLM role-plays the chosen engine (chatgpt / claude / perplexity / google_ai_overviews / any), reads the page content, and returns a cite/no-cite verdict with the verbatim excerpt it would surface plus ranked improvements.",
|
|
492
|
+
"Read-only when given `url` (one HTTP GET). Zero network when given `text`.",
|
|
493
|
+
"Primary path uses MCP sampling. If the host doesn't support sampling, falls back to a deterministic heuristic derived from `score.citation_worthiness` (overall_score + per-engine subscore must both clear thresholds). The result includes `mode: \"sampling\" | \"static_heuristic\"` so callers can tell which path ran.",
|
|
494
|
+
"When to use: pre-publish gut-check for a specific query, or auditing whether existing content earns citation surface. Distinct from `score.citation_worthiness` (deterministic 0-100 score) and `audit.page` (whole-page rubric); this returns a binary cite/no-cite verdict tied to one query.",
|
|
495
|
+
"Either `url` or `text` must be provided. `target_query` is required.",
|
|
496
|
+
].join("\n\n"),
|
|
497
|
+
inputSchema: {
|
|
498
|
+
url: z
|
|
499
|
+
.string()
|
|
500
|
+
.url()
|
|
501
|
+
.optional()
|
|
502
|
+
.describe("Public URL to fetch and test. Either this OR `text` is required."),
|
|
503
|
+
text: z
|
|
504
|
+
.string()
|
|
505
|
+
.optional()
|
|
506
|
+
.describe("Raw text/HTML to test directly. Either this OR `url` is required."),
|
|
507
|
+
target_query: z
|
|
508
|
+
.string()
|
|
509
|
+
.min(3)
|
|
510
|
+
.describe("The user query the engine is answering. Required. Example: `how to add JSON-LD to a Next.js app`."),
|
|
511
|
+
engine: z
|
|
512
|
+
.enum(["chatgpt", "claude", "perplexity", "google_ai_overviews", "any"])
|
|
513
|
+
.optional()
|
|
514
|
+
.default("any")
|
|
515
|
+
.describe("Which engine to simulate. `any` (default) uses a generic AI-search persona. Specific engines tune the cite criteria (e.g. perplexity favors statistic-dense excerpts; google_ai_overviews favors schema + freshness)."),
|
|
516
|
+
respect_robots: z
|
|
517
|
+
.boolean()
|
|
518
|
+
.optional()
|
|
519
|
+
.default(true)
|
|
520
|
+
.describe("If true (default), respect robots.txt when fetching `url`. Ignored when `text` is used."),
|
|
521
|
+
},
|
|
522
|
+
outputSchema: testCitationOutputShape,
|
|
523
|
+
annotations: {
|
|
524
|
+
title: "Test whether an AI engine would cite this page",
|
|
525
|
+
readOnlyHint: true,
|
|
526
|
+
destructiveHint: false,
|
|
527
|
+
// Sampling path is model-dependent.
|
|
528
|
+
idempotentHint: false,
|
|
529
|
+
openWorldHint: true,
|
|
530
|
+
},
|
|
531
|
+
}, async (input) => {
|
|
532
|
+
if (!input.url && !input.text) {
|
|
533
|
+
return toolError({ type: "invalid_url", message: "One of url or text is required" });
|
|
534
|
+
}
|
|
535
|
+
return wrapHandler(() => testCitation(input, undefined, undefined, server));
|
|
536
|
+
});
|
|
537
|
+
// --- diff.pages ---
|
|
538
|
+
server.registerTool("diff.pages", {
|
|
539
|
+
title: "Diff two pages for citation-worthiness",
|
|
540
|
+
description: [
|
|
541
|
+
"Compare two URLs for AI citation-worthiness and return a structured breakdown of which page is more likely to be cited and why. Typical use: your page (url_a) vs a competitor's page (url_b).",
|
|
542
|
+
"Read-only. Runs audit.page on both URLs in parallel (2 HTTP fetches per URL), then diffs dimension_scores and findings. No new fetch logic beyond what audit.page already does.",
|
|
543
|
+
"Deterministic, rule-based; no LLM calls. Same two URLs return the same comparison on repeated runs.",
|
|
544
|
+
"When to use: competitive gap analysis - understand exactly which dimensions (schema, structure, robots, entity density, freshness, technical, authority, sitemap) put a competitor ahead, and get prioritized fix_recommendations_for_a to close the gap. For a single-URL audit, use audit.page. For overall scoring of one page, use score.citation_worthiness.",
|
|
545
|
+
"Capped at 2 URLs per call. Heuristic verdict - does not claim to know what AI assistants actually cite; verdict matches audit.page's existing rubric.",
|
|
546
|
+
].join("\n\n"),
|
|
547
|
+
inputSchema: diffPagesInputSchema.shape,
|
|
548
|
+
outputSchema: diffPagesOutputShape,
|
|
549
|
+
annotations: {
|
|
550
|
+
title: "Diff two pages for citation-worthiness",
|
|
551
|
+
readOnlyHint: true,
|
|
552
|
+
destructiveHint: false,
|
|
553
|
+
idempotentHint: true,
|
|
554
|
+
openWorldHint: true,
|
|
555
|
+
},
|
|
556
|
+
}, async (input) => wrapHandler(() => diffPages(input)));
|
|
557
|
+
// --- audit.site ---
|
|
558
|
+
server.registerTool("audit.site", {
|
|
559
|
+
title: "Audit site (homepage + robots + sitemap + schema)",
|
|
560
|
+
description: "Single-call site sweep: runs audit.page (homepage), check.robots, check.sitemap, and audit.schema in parallel and returns an overall grade plus top-5 fixes.",
|
|
561
|
+
inputSchema: auditSiteInputSchema.shape,
|
|
562
|
+
outputSchema: auditSiteOutputShape,
|
|
563
|
+
annotations: {
|
|
564
|
+
title: "Audit site (homepage + robots + sitemap + schema)",
|
|
565
|
+
readOnlyHint: true,
|
|
566
|
+
destructiveHint: false,
|
|
567
|
+
idempotentHint: true,
|
|
568
|
+
openWorldHint: true,
|
|
569
|
+
},
|
|
570
|
+
}, async (input) => wrapHandler(() => auditSite(input)));
|
|
571
|
+
// --- audit.sitemap ---
|
|
572
|
+
server.registerTool("audit.sitemap", {
|
|
573
|
+
title: "Audit a site's content by sampling its sitemap",
|
|
574
|
+
description: [
|
|
575
|
+
"Site-wide content audit: discovers the sitemap, samples N URLs by deterministic uniform stride, runs audit.page on each, and returns score distribution + worst pages + most-common findings.",
|
|
576
|
+
"Read-only. One HTTP GET for sitemap discovery, optionally a few more for sitemap-index children, then `sample_size` × audit.page calls (each one HTTP GET + parsing). Polite throttling is enforced per host.",
|
|
577
|
+
"Deterministic — same domain + same sample_size returns the same set of URLs (uniform-stride sampling). Per-page scoring is rule-based; no LLM.",
|
|
578
|
+
"When to use: portfolio-level health check across a site (\"how does our content score on average?\"). Distinct from `audit.site` (homepage-only composite) and `check.sitemap` (validates sitemap.xml structure, not page content).",
|
|
579
|
+
].join("\n\n"),
|
|
580
|
+
inputSchema: auditSitemapInputSchema.shape,
|
|
581
|
+
outputSchema: auditSitemapOutputShape,
|
|
582
|
+
annotations: {
|
|
583
|
+
title: "Audit a site's content by sampling its sitemap",
|
|
584
|
+
readOnlyHint: true,
|
|
585
|
+
destructiveHint: false,
|
|
586
|
+
idempotentHint: true,
|
|
587
|
+
openWorldHint: true,
|
|
588
|
+
},
|
|
589
|
+
}, async (input) => wrapHandler(() => auditSitemap(input)));
|
|
590
|
+
// --- report.save ---
|
|
591
|
+
server.registerTool("report.save", {
|
|
592
|
+
title: "Save audit report to disk",
|
|
593
|
+
description: "Render an audit.page or audit.site result as a Markdown report and write it to a file under MCP_WORKSPACE_ROOT (defaults to cwd).",
|
|
594
|
+
inputSchema: saveAuditReportInputSchema.shape,
|
|
595
|
+
outputSchema: saveReportOutputShape,
|
|
596
|
+
annotations: {
|
|
597
|
+
title: "Save audit report to disk",
|
|
598
|
+
// Writes a file - not read-only.
|
|
599
|
+
readOnlyHint: false,
|
|
600
|
+
// Overwrite=true (default) replaces existing files; the change is recoverable but writes do occur.
|
|
601
|
+
destructiveHint: true,
|
|
602
|
+
// Same input writes the same content to the same path.
|
|
603
|
+
idempotentHint: true,
|
|
604
|
+
// Operates on the local filesystem only.
|
|
605
|
+
openWorldHint: false,
|
|
606
|
+
},
|
|
607
|
+
}, async (input) => wrapHandler(() => saveAuditReport(input)));
|
|
608
|
+
// --- Prompts: one-click entry points for AI-SEO workflows ---
|
|
609
|
+
// Surfaced by hosts like Claude Desktop as suggested actions. Lowers activation cost
|
|
610
|
+
// vs. asking users to pick among the 16-tool catalog.
|
|
611
|
+
server.prompt("audit_my_homepage", "Run a full AI-SEO audit of a site's homepage.", { domain: z.string().describe("The site domain, e.g. example.com or https://example.com") }, ({ domain }) => ({
|
|
612
|
+
messages: [
|
|
613
|
+
{
|
|
614
|
+
role: "user",
|
|
615
|
+
content: {
|
|
616
|
+
type: "text",
|
|
617
|
+
text: `Call the \`audit.site\` tool with domain="${domain}" and respect_robots=true. ` +
|
|
618
|
+
`When the result returns, summarize the overall_grade, the top_5_fixes (highest-impact first), ` +
|
|
619
|
+
`and the content_quality from the underlying audit.page. Recommend the single most leveraged fix first.`,
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
],
|
|
623
|
+
}));
|
|
624
|
+
server.prompt("find_citation_blockers", "Audit a URL and surface only the critical findings blocking AI citations.", { url: z.string().url().describe("The URL to audit") }, ({ url }) => ({
|
|
625
|
+
messages: [
|
|
626
|
+
{
|
|
627
|
+
role: "user",
|
|
628
|
+
content: {
|
|
629
|
+
type: "text",
|
|
630
|
+
text: `Call \`audit.page\` with url="${url}" and respect_robots=true. ` +
|
|
631
|
+
`From the result, filter findings to severity="critical" only. ` +
|
|
632
|
+
`Group them by category and present a prioritized fix list. ` +
|
|
633
|
+
`Ignore warnings and info-level findings for this pass.`,
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
}));
|
|
638
|
+
server.prompt("generate_llms_txt_for_domain", "Generate a valid llms.txt for a domain.", { domain: z.string().describe("The site domain, e.g. example.com") }, ({ domain }) => ({
|
|
639
|
+
messages: [
|
|
640
|
+
{
|
|
641
|
+
role: "user",
|
|
642
|
+
content: {
|
|
643
|
+
type: "text",
|
|
644
|
+
text: `Call \`llms_txt.generate\` with domain="${domain}", max_pages=50, include_full=false. ` +
|
|
645
|
+
`Return the generated llms.txt verbatim in a code block, then summarize how many pages were indexed.`,
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
}));
|
|
650
|
+
server.prompt("check_ai_crawler_access", "Report which AI training and search crawlers can access a domain.", { domain: z.string().describe("The site domain, e.g. example.com") }, ({ domain }) => ({
|
|
651
|
+
messages: [
|
|
652
|
+
{
|
|
653
|
+
role: "user",
|
|
654
|
+
content: {
|
|
655
|
+
type: "text",
|
|
656
|
+
text: `Call \`check.robots\` with domain="${domain}". ` +
|
|
657
|
+
`From the result, build a table grouped by training_crawlers / search_crawlers / user_triggered, ` +
|
|
658
|
+
`with one row per crawler and an allowed/blocked verdict. ` +
|
|
659
|
+
`Highlight any AI search crawlers that are blocked - those silently kill citation surface.`,
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
}));
|
|
664
|
+
server.prompt("score_my_citation_worthiness", "Score how citable a URL is for AI engines and recommend improvements.", {
|
|
665
|
+
url: z.string().url().describe("The URL to score"),
|
|
666
|
+
target_query: z.string().describe("The query the page should be cited for"),
|
|
667
|
+
}, ({ url, target_query }) => ({
|
|
668
|
+
messages: [
|
|
669
|
+
{
|
|
670
|
+
role: "user",
|
|
671
|
+
content: {
|
|
672
|
+
type: "text",
|
|
673
|
+
text: `Call \`score.citation_worthiness\` with url="${url}", target_query="${target_query}", respect_robots=true. ` +
|
|
674
|
+
`Present overall_score and per-engine scores (Perplexity, ChatGPT, Google AI Overviews, Claude). ` +
|
|
675
|
+
`Then suggest the 3 most impactful structural changes (BLUF opening, FAQ blocks, entity density) to lift the lowest score.`,
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
}));
|
|
680
|
+
// --- Resources: browsable reference data ---
|
|
681
|
+
// Hosts can read these on demand to ground answers about AI-SEO without a tool call.
|
|
682
|
+
const SIGNALS_DOC = `# AI-citation signals
|
|
683
|
+
|
|
684
|
+
The 13 signals AI assistants weigh when deciding what to cite.
|
|
685
|
+
|
|
686
|
+
## 1. JSON-LD structured data
|
|
687
|
+
Article / FAQPage / HowTo schema. With Person/Organization author nodes carrying \`sameAs\` links.
|
|
688
|
+
Example:
|
|
689
|
+
\`\`\`json
|
|
690
|
+
{"@context":"https://schema.org","@type":"Article","author":{"@type":"Person","name":"Jane","sameAs":["https://linkedin.com/in/jane"]}}
|
|
691
|
+
\`\`\`
|
|
692
|
+
|
|
693
|
+
## 2. FAQ structure
|
|
694
|
+
H3 headings ending in "?" with concise answer paragraphs. FAQPage JSON-LD doubles the signal.
|
|
695
|
+
|
|
696
|
+
## 3. BLUF (bottom line up front)
|
|
697
|
+
First 1-2 sentences answer the query directly. No throat-clearing.
|
|
698
|
+
|
|
699
|
+
## 4. Statistic density
|
|
700
|
+
2-3+ specific numbers per 500 words. Source-attributed where possible.
|
|
701
|
+
|
|
702
|
+
## 5. Named entities
|
|
703
|
+
People, products, places, organizations - named explicitly (not "this product" / "they").
|
|
704
|
+
|
|
705
|
+
## 6. Entity links
|
|
706
|
+
External links to authoritative domains: wikipedia.org, .gov, linkedin.com, crunchbase.com.
|
|
707
|
+
|
|
708
|
+
## 7. Comparison tables
|
|
709
|
+
Side-by-side product/option tables. AI engines extract these directly.
|
|
710
|
+
|
|
711
|
+
## 8. Ordered lists
|
|
712
|
+
Numbered step-by-step. Especially "how to X" content.
|
|
713
|
+
|
|
714
|
+
## 9. Schema author identity
|
|
715
|
+
Author as a Person node with profile URL, not a plain string.
|
|
716
|
+
|
|
717
|
+
## 10. Freshness
|
|
718
|
+
\`dateModified\` within 90 days. Stale content is downranked aggressively.
|
|
719
|
+
|
|
720
|
+
## 11. Canonical hygiene
|
|
721
|
+
Self-referencing canonical, no trailing-slash redirects, \`og:url\` matches canonical.
|
|
722
|
+
|
|
723
|
+
## 12. AI crawler allowance
|
|
724
|
+
robots.txt allows OAI-SearchBot, PerplexityBot, Claude-SearchBot. Blocking training-only crawlers (GPTBot, ClaudeBot) is fine; blocking search crawlers kills citation surface.
|
|
725
|
+
|
|
726
|
+
## 13. Sitemap freshness
|
|
727
|
+
XML sitemap present, lastmod >80% coverage, listed in robots.txt.
|
|
728
|
+
|
|
729
|
+
Use \`audit.page\` to score a URL across all 13 signals at once.
|
|
730
|
+
`;
|
|
731
|
+
const CRAWLERS_DOC = `# AI crawlers reference
|
|
732
|
+
|
|
733
|
+
The user-agents AI assistants use, what they do, and how to allow / block them in robots.txt.
|
|
734
|
+
|
|
735
|
+
## Training crawlers
|
|
736
|
+
These scrape content to train future model weights. Blocking them does not affect live AI citations.
|
|
737
|
+
|
|
738
|
+
- **GPTBot** - OpenAI training crawler. \`User-agent: GPTBot\`
|
|
739
|
+
- **ClaudeBot** - Anthropic training crawler. \`User-agent: ClaudeBot\`
|
|
740
|
+
- **CCBot** - Common Crawl. Powers many downstream models. \`User-agent: CCBot\`
|
|
741
|
+
- **Meta-ExternalAgent** - Meta training crawler. \`User-agent: Meta-ExternalAgent\`
|
|
742
|
+
- **Amazonbot** - Amazon training crawler. \`User-agent: Amazonbot\`
|
|
743
|
+
|
|
744
|
+
## Search crawlers
|
|
745
|
+
These power live AI search / answer engines. Blocking these directly suppresses your visibility in AI answers.
|
|
746
|
+
|
|
747
|
+
- **OAI-SearchBot** - ChatGPT search index. \`User-agent: OAI-SearchBot\`
|
|
748
|
+
- **Claude-SearchBot** - Claude live search. \`User-agent: Claude-SearchBot\`
|
|
749
|
+
- **PerplexityBot** - Perplexity search index. \`User-agent: PerplexityBot\`
|
|
750
|
+
- **Bingbot** - Bing, also powers Copilot. \`User-agent: Bingbot\`
|
|
751
|
+
- **DuckAssistBot** - DuckDuckGo AI assist. \`User-agent: DuckAssistBot\`
|
|
752
|
+
|
|
753
|
+
## User-triggered fetchers
|
|
754
|
+
On-demand fetches when a user shares a URL in chat. Blocking these breaks user-pasted links.
|
|
755
|
+
|
|
756
|
+
- **ChatGPT-User** - ChatGPT browsing on user request. \`User-agent: ChatGPT-User\`
|
|
757
|
+
- **Claude-User** - Claude browsing on user request. \`User-agent: Claude-User\`
|
|
758
|
+
- **Google-Agent** - Google Search agent fetches. \`User-agent: Google-Agent\`
|
|
759
|
+
|
|
760
|
+
## Robots-token-only signals
|
|
761
|
+
These are not crawlers - they are tokens that modify whether the existing Googlebot / Applebot crawl is used for AI grounding.
|
|
762
|
+
|
|
763
|
+
- **Google-Extended** - opts out of Google AI training/grounding. \`User-agent: Google-Extended\`
|
|
764
|
+
- **Applebot-Extended** - opts out of Apple AI training. \`User-agent: Applebot-Extended\`
|
|
765
|
+
|
|
766
|
+
## Recommended posture
|
|
767
|
+
For most sites that want AI citation surface:
|
|
768
|
+
\`\`\`
|
|
769
|
+
User-agent: GPTBot
|
|
770
|
+
Disallow: /
|
|
771
|
+
|
|
772
|
+
User-agent: ClaudeBot
|
|
773
|
+
Disallow: /
|
|
774
|
+
|
|
775
|
+
User-agent: CCBot
|
|
776
|
+
Disallow: /
|
|
777
|
+
|
|
778
|
+
User-agent: *
|
|
779
|
+
Allow: /
|
|
780
|
+
\`\`\`
|
|
781
|
+
(Blocks training, allows everything else - including search crawlers.)
|
|
782
|
+
|
|
783
|
+
Use \`check.robots\` to see your current posture per crawler.
|
|
784
|
+
`;
|
|
785
|
+
server.resource("ai-citation-signals", "ai-citation://signals", { mimeType: "text/markdown", description: "The 13 signals AI assistants use to decide what to cite, with examples." }, async (uri) => ({
|
|
786
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: SIGNALS_DOC }],
|
|
787
|
+
}));
|
|
788
|
+
server.resource("ai-crawlers", "ai-citation://crawlers", { mimeType: "text/markdown", description: "Catalog of AI training, search, and user-triggered crawlers with robots.txt syntax." }, async (uri) => ({
|
|
789
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: CRAWLERS_DOC }],
|
|
790
|
+
}));
|
|
308
791
|
// --- Start server ---
|
|
309
792
|
async function main() {
|
|
310
793
|
const transport = new StdioServerTransport();
|