@crawlseo/mcp 0.0.0-stage → 0.3.5

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -2
  3. package/dist/cli.js +598 -0
  4. package/package.json +38 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 crawlseo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # @crawlseo/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The MCP (Model Context Protocol) server for [crawlseo](https://github.com/crawlseo/crawlseo), the open-source SEO monitoring app. It lets an AI agent such as Claude Code read your sites' Search Console data, crawl issues, Core Web Vitals and SEO opportunities.
4
+
5
+ **Read-only.** Every tool reads through your crawlseo instance's API (`/api/v1`) with an access key, and only sends GET requests. No tool writes, queues or triggers anything: there is no tool to start a crawl or a sync. The server never connects to a database, and the key sees the sites of its own account only.
6
+
7
+ ## Get a key
8
+
9
+ In crawlseo, open **Settings > MCP**, turn MCP on, and create a key. The key is shown once; copy it then. Revoke it there when you no longer need it.
10
+
11
+ ## Claude Code
12
+
13
+ Over stdio, against your own instance:
14
+
15
+ ```bash
16
+ claude mcp add crawlseo -e CRAWLSEO_URL=http://localhost:3000 -e CRAWLSEO_API_KEY=<key> -- npx -y @crawlseo/mcp
17
+ ```
18
+
19
+ A crawlseo instance also serves MCP itself at `/mcp`, so you may not need this package at all:
20
+
21
+ ```bash
22
+ claude mcp add --transport http crawlseo https://seo.example.com/mcp --header "Authorization: Bearer <key>"
23
+ ```
24
+
25
+ ## Environment
26
+
27
+ | Variable | Meaning |
28
+ |---|---|
29
+ | `CRAWLSEO_URL` | Your crawlseo instance (default `http://localhost:3000`) |
30
+ | `CRAWLSEO_API_KEY` | An access key from Settings > MCP (stdio mode) |
31
+
32
+ ## HTTP mode
33
+
34
+ ```bash
35
+ CRAWLSEO_URL=http://localhost:3000 npx -y @crawlseo/mcp --http --port 3333
36
+ ```
37
+
38
+ Serves streamable HTTP on `http://127.0.0.1:3333/mcp` and a health check on `/health`. The server holds no key: each client sends its own as `Authorization: Bearer <key>`, and a request without a key, or with a key the instance refuses, gets 401 (403 while MCP is off for that account). `--host` changes the address it listens on (default `127.0.0.1`).
39
+
40
+ ## Tools
41
+
42
+ | Tool | What it reads |
43
+ |---|---|
44
+ | `list_sites` | Your sites with their IDs and Search Console properties |
45
+ | `get_site_overview` | Clicks, impressions, position and CTR over 28 days against the 28 before, the latest crawl and vitals |
46
+ | `get_keywords` | Top Search Console queries, for the last N days or a date range |
47
+ | `get_pages` | Top pages, for the last N days or a date range |
48
+ | `get_traffic` | Daily clicks and impressions, for the last N days or a date range |
49
+ | `list_crawls` | The latest crawls with status, pages, issues and health score |
50
+ | `get_crawl_issues` | Issues of a crawl (the latest completed one by default), by severity |
51
+ | `get_vitals` | Core Web Vitals reports: performance score, LCP, CLS, INP, TTFB |
52
+ | `get_opportunities` | Striking distance, low CTR, content decay and cannibalization |
53
+
54
+ ## License
55
+
56
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // package.json
7
+ var version = "0.3.5";
8
+
9
+ // src/api-client.ts
10
+ var ApiRequestError = class extends Error {
11
+ constructor(status, message) {
12
+ super(message);
13
+ this.status = status;
14
+ }
15
+ };
16
+ function createApiClient(opts) {
17
+ const base = opts.baseUrl.replace(/\/+$/, "");
18
+ const send2 = opts.fetch ?? ((req) => fetch(req));
19
+ return {
20
+ async get(path, query = {}) {
21
+ const url = new URL(`${base}/api/v1${path}`);
22
+ for (const [k, v] of Object.entries(query)) if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
23
+ let res;
24
+ try {
25
+ res = await send2(
26
+ new Request(url, {
27
+ method: "GET",
28
+ headers: {
29
+ Authorization: `Bearer ${opts.apiKey}`,
30
+ Accept: "application/json",
31
+ ...opts.userAgent && { "User-Agent": opts.userAgent }
32
+ },
33
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 3e4)
34
+ })
35
+ );
36
+ } catch (err) {
37
+ throw new ApiRequestError(0, `Could not reach crawlseo at ${base}: ${err instanceof Error ? err.message : String(err)}`);
38
+ }
39
+ const body = await res.json().catch(() => null);
40
+ if (!res.ok) {
41
+ const reason = typeof body?.error === "string" ? body.error : `HTTP ${res.status}`;
42
+ throw new ApiRequestError(res.status, explain(res.status, reason));
43
+ }
44
+ if (body === null) throw new ApiRequestError(res.status, `crawlseo at ${base} did not answer with JSON`);
45
+ return body;
46
+ }
47
+ };
48
+ }
49
+ function explain(status, reason) {
50
+ if (status === 401) return `${reason}. Check the key, or create a new one in crawlseo under Settings > MCP.`;
51
+ if (status === 404 && reason === "Not found") return `${reason}. Is this a crawlseo instance with the /api/v1 API?`;
52
+ return reason;
53
+ }
54
+
55
+ // src/http.ts
56
+ import { createHash } from "crypto";
57
+ import { createServer } from "http";
58
+
59
+ // src/streamable.ts
60
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
61
+
62
+ // src/tools.ts
63
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
64
+ import { z } from "zod";
65
+
66
+ // src/formatters.ts
67
+ function formatTable(headers, rows) {
68
+ const widths = headers.map(
69
+ (h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
70
+ );
71
+ const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
72
+ const sep = widths.map((w) => "-".repeat(w)).join(" | ");
73
+ const headerLine = headers.map((h, i) => pad(h, widths[i])).join(" | ");
74
+ const dataLines = rows.map(
75
+ (row) => row.map((cell, i) => pad(cell, widths[i])).join(" | ")
76
+ );
77
+ return [headerLine, sep, ...dataLines].join("\n");
78
+ }
79
+ function num(n) {
80
+ return n.toLocaleString("en-US");
81
+ }
82
+ function pct(n) {
83
+ return `${(n * 100).toFixed(1)}%`;
84
+ }
85
+ function pos(n) {
86
+ if (!Number.isFinite(n) || n <= 0) return "-";
87
+ return n.toFixed(1);
88
+ }
89
+ function minute(d) {
90
+ return d ? new Date(d).toISOString().slice(0, 16).replace("T", " ") : "-";
91
+ }
92
+ function period(p) {
93
+ return `${p.startDate} to ${p.endDate}`;
94
+ }
95
+ function formatSiteOverview(site) {
96
+ const lines = [];
97
+ lines.push(`Site: ${site.domain}`);
98
+ lines.push(`ID: ${site.id}`);
99
+ if (site.gscProperty) lines.push(`GSC Property: ${site.gscProperty}`);
100
+ lines.push("");
101
+ if (site.metrics) {
102
+ const m = site.metrics;
103
+ lines.push(`-- Last ${site.metricsDays ?? 28} days vs the ${site.metricsDays ?? 28} days before --`);
104
+ lines.push(`Clicks: ${num(m.current.clicks)} (${m.deltas.clicks > 0 ? "+" : ""}${m.deltas.clicks}%)`);
105
+ lines.push(`Impressions: ${num(m.current.impressions)} (${m.deltas.impressions > 0 ? "+" : ""}${m.deltas.impressions}%)`);
106
+ lines.push(`Avg Position: ${pos(m.current.avgPosition)} (${m.deltas.avgPosition > 0 ? "+" : ""}${m.deltas.avgPosition.toFixed(1)} improvement)`);
107
+ lines.push(`Avg CTR: ${pct(m.current.avgCtr)} (${m.deltas.avgCtr > 0 ? "+" : ""}${m.deltas.avgCtr}%)`);
108
+ lines.push(`Keywords: ${num(m.current.uniqueKeywords)}`);
109
+ }
110
+ if (site.latestCrawl) {
111
+ const c = site.latestCrawl;
112
+ lines.push("");
113
+ lines.push("-- Latest Crawl --");
114
+ lines.push(`ID: ${c.id}`);
115
+ lines.push(`Status: ${c.status} | Health: ${c.healthScore ?? "-"}/100`);
116
+ if (c.status === "FAILED" && c.error) lines.push(`Error: ${c.error}`);
117
+ lines.push(`Pages: ${num(c.pagesFound)} | Issues: ${num(c.issuesFound)}`);
118
+ if (c.finishedAt) lines.push(`Finished: ${minute(c.finishedAt)}`);
119
+ }
120
+ if (site.latestVitals) {
121
+ const v = site.latestVitals;
122
+ lines.push("");
123
+ lines.push("-- Latest Vitals --");
124
+ lines.push(`Perf Score: ${v.perfScore ?? "-"}/100 | Device: ${v.device}`);
125
+ if (v.lcp != null) lines.push(`LCP: ${v.lcp.toFixed(2)}s`);
126
+ if (v.cls != null) lines.push(`CLS: ${v.cls.toFixed(3)}`);
127
+ if (v.inp != null) lines.push(`INP: ${v.inp.toFixed(0)}ms`);
128
+ if (v.ttfb != null) lines.push(`TTFB: ${v.ttfb.toFixed(2)}s`);
129
+ }
130
+ return lines.join("\n");
131
+ }
132
+ function formatKeywords(keywords, p) {
133
+ if (keywords.length === 0) return `No keywords found for ${period(p)}.`;
134
+ const headers = ["Keyword", "Clicks", "Impressions", "Position", "CTR"];
135
+ const rows = keywords.map((k) => [
136
+ k.query.length > 50 ? k.query.slice(0, 47) + "..." : k.query,
137
+ num(k.clicks),
138
+ num(k.impressions),
139
+ pos(k.position),
140
+ pct(k.ctr)
141
+ ]);
142
+ return `Top ${keywords.length} keywords, ${period(p)}:
143
+
144
+ ` + formatTable(headers, rows);
145
+ }
146
+ function formatPages(pages, p) {
147
+ if (pages.length === 0) return `No pages found for ${period(p)}.`;
148
+ const headers = ["URL", "Clicks", "Impressions", "Position", "CTR"];
149
+ const rows = pages.map((p2) => {
150
+ let url = p2.url;
151
+ try {
152
+ url = new URL(p2.url).pathname;
153
+ } catch {
154
+ }
155
+ if (url.length > 60) url = url.slice(0, 57) + "...";
156
+ return [url, num(p2.clicks), num(p2.impressions), pos(p2.position), pct(p2.ctr)];
157
+ });
158
+ return `Top ${pages.length} pages, ${period(p)}:
159
+
160
+ ` + formatTable(headers, rows);
161
+ }
162
+ function formatTraffic(traffic, p) {
163
+ if (traffic.length === 0) return `No traffic data found for ${period(p)}.`;
164
+ const headers = ["Date", "Clicks", "Impressions"];
165
+ const rows = traffic.map((t) => [t.date, num(t.clicks), num(t.impressions)]);
166
+ const totalClicks = traffic.reduce((s, t) => s + t.clicks, 0);
167
+ const totalImpressions = traffic.reduce((s, t) => s + t.impressions, 0);
168
+ return `Daily traffic, ${period(p)} (${traffic.length} days with data):
169
+ Total: ${num(totalClicks)} clicks, ${num(totalImpressions)} impressions
170
+
171
+ ` + formatTable(headers, rows);
172
+ }
173
+ function formatCrawls(crawls) {
174
+ if (crawls.length === 0) return "No crawls yet.";
175
+ const headers = ["Crawl ID", "Status", "Started", "Finished", "Pages", "Issues", "Health"];
176
+ const rows = crawls.map((c) => [
177
+ c.id,
178
+ c.status,
179
+ minute(c.startedAt),
180
+ minute(c.finishedAt),
181
+ num(c.pagesFound),
182
+ num(c.issuesFound),
183
+ c.healthScore != null ? `${c.healthScore}/100` : "-"
184
+ ]);
185
+ const failed = crawls.filter((c) => c.status === "FAILED" && c.error).map((c) => `${c.id}: ${c.error}`);
186
+ return `${crawls.length} crawl(s), newest first:
187
+
188
+ ` + formatTable(headers, rows) + (failed.length ? `
189
+
190
+ Failed:
191
+ ${failed.join("\n")}` : "");
192
+ }
193
+ function formatCrawlIssues(result) {
194
+ const { crawl, total, bySeverity, noindexPages, noindexWarning, issues } = result;
195
+ const noindex = (noindexWarning?.message ? `WARNING: ${noindexWarning.message} The health score leaves them out.
196
+ ` : "") + (noindexPages ? `${num(noindexPages)} noindex pages crawled but not scored (no issues, not in the health score)
197
+ ` : "");
198
+ const head = `Crawl ${crawl.id} (${crawl.status}, finished ${minute(crawl.finishedAt)}, health ${crawl.healthScore ?? "-"}/100)
199
+ ${num(total)} issues: ${num(bySeverity.CRITICAL ?? 0)} critical, ${num(bySeverity.WARNING ?? 0)} warning, ${num(bySeverity.INFO ?? 0)} info
200
+ ` + noindex + "\n";
201
+ if (issues.length === 0) return head + "No crawl issues found.";
202
+ const headers = ["Severity", "Type", "URL", "Message"];
203
+ const rows = issues.map((i) => [
204
+ i.severity,
205
+ i.type,
206
+ i.url.length > 40 ? i.url.slice(0, 37) + "..." : i.url,
207
+ i.message.length > 50 ? i.message.slice(0, 47) + "..." : i.message
208
+ ]);
209
+ return head + `Showing ${issues.length}:
210
+
211
+ ` + formatTable(headers, rows);
212
+ }
213
+ function formatVitals(vitals) {
214
+ if (vitals.length === 0) return "No vitals reports found.";
215
+ const headers = ["Date", "Device", "Perf", "LCP", "CLS", "INP", "TTFB"];
216
+ const rows = vitals.map((v) => [
217
+ new Date(v.date).toISOString().slice(0, 10),
218
+ v.device,
219
+ v.perfScore != null ? String(v.perfScore) : "-",
220
+ v.lcp != null ? `${v.lcp.toFixed(2)}s` : "-",
221
+ v.cls != null ? v.cls.toFixed(3) : "-",
222
+ v.inp != null ? `${v.inp.toFixed(0)}ms` : "-",
223
+ v.ttfb != null ? `${v.ttfb.toFixed(2)}s` : "-"
224
+ ]);
225
+ return `${vitals.length} vitals reports:
226
+
227
+ ` + formatTable(headers, rows);
228
+ }
229
+ function formatOpportunities(opportunities) {
230
+ const lines = [];
231
+ const s = opportunities.summary;
232
+ lines.push("SEO Opportunities Summary");
233
+ lines.push(`Striking distance: ${s.strikingDistance} | Low CTR: ${s.lowCtr} | Content decay: ${s.contentDecay} | Cannibalization: ${s.cannibalization}`);
234
+ lines.push("");
235
+ if (opportunities.feed && opportunities.feed.length > 0) {
236
+ const headers = ["Type", "Severity", "Title", "Detail"];
237
+ const rows = opportunities.feed.map((o) => [
238
+ o.type,
239
+ o.severity,
240
+ (o.title || "").length > 35 ? (o.title || "").slice(0, 32) + "..." : o.title || "",
241
+ (o.detail || "").length > 55 ? (o.detail || "").slice(0, 52) + "..." : o.detail || ""
242
+ ]);
243
+ lines.push(formatTable(headers, rows));
244
+ } else {
245
+ lines.push("No actionable opportunities found.");
246
+ }
247
+ return lines.join("\n");
248
+ }
249
+
250
+ // src/tools.ts
251
+ var READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
252
+ var siteId = z.string().describe("The site ID, from list_sites");
253
+ var date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD");
254
+ var periodShape = {
255
+ days: z.number().int().min(1).max(490).optional().describe("The last N days. Ignored when startDate and endDate are set"),
256
+ startDate: date.optional().describe("First day, YYYY-MM-DD. Use with endDate"),
257
+ endDate: date.optional().describe("Last day, YYYY-MM-DD, included. Use with startDate")
258
+ };
259
+ function periodQuery(p) {
260
+ return p.startDate || p.endDate ? { startDate: p.startDate, endDate: p.endDate } : { days: p.days };
261
+ }
262
+ function text(t) {
263
+ return { content: [{ type: "text", text: t }] };
264
+ }
265
+ async function run(fn) {
266
+ try {
267
+ return text(await fn());
268
+ } catch (err) {
269
+ if (err instanceof ApiRequestError) return { ...text(err.message), isError: true };
270
+ throw err;
271
+ }
272
+ }
273
+ function registerTools(server, api) {
274
+ server.registerTool(
275
+ "list_sites",
276
+ {
277
+ title: "List sites",
278
+ description: "List your monitored sites with their IDs, domains and Search Console properties.",
279
+ inputSchema: {},
280
+ annotations: READ_ONLY
281
+ },
282
+ () => run(async () => {
283
+ const { sites } = await api.get("/sites");
284
+ if (sites.length === 0) return "No sites found.";
285
+ const lines = sites.map(
286
+ (s) => `${s.domain} (id: ${s.id})
287
+ GSC: ${s.gscProperty ?? "not connected"} | Crawls: ${s.crawls} | Vitals reports: ${s.vitalsReports}
288
+ Added: ${s.createdAt.slice(0, 10)}`
289
+ );
290
+ return `${sites.length} site(s):
291
+
292
+ ${lines.join("\n\n")}`;
293
+ })
294
+ );
295
+ server.registerTool(
296
+ "get_site_overview",
297
+ {
298
+ title: "Site overview",
299
+ description: "Overview of a site: clicks, impressions, average position and CTR over the last 28 days against the 28 days before, the latest crawl with its health score, and the latest Core Web Vitals.",
300
+ inputSchema: { siteId },
301
+ annotations: READ_ONLY
302
+ },
303
+ ({ siteId: siteId2 }) => run(async () => {
304
+ const o = await api.get(`/sites/${encodeURIComponent(siteId2)}`);
305
+ return formatSiteOverview({ ...o.site, metricsDays: o.metricsDays, metrics: o.metrics, latestCrawl: o.latestCrawl, latestVitals: o.latestVitals });
306
+ })
307
+ );
308
+ server.registerTool(
309
+ "get_keywords",
310
+ {
311
+ title: "Search Console keywords",
312
+ description: "Top Google Search Console queries for a site, by clicks: clicks, impressions, average position and CTR. Default period: the last 28 days.",
313
+ inputSchema: {
314
+ siteId,
315
+ ...periodShape,
316
+ limit: z.number().int().min(1).max(1e3).optional().default(25).describe("Most keywords to return (default 25)")
317
+ },
318
+ annotations: READ_ONLY
319
+ },
320
+ ({ siteId: siteId2, limit, ...p }) => run(async () => {
321
+ const r = await api.get(
322
+ `/sites/${encodeURIComponent(siteId2)}/keywords`,
323
+ { ...periodQuery(p), limit }
324
+ );
325
+ return formatKeywords(r.keywords, r);
326
+ })
327
+ );
328
+ server.registerTool(
329
+ "get_pages",
330
+ {
331
+ title: "Search Console pages",
332
+ description: "Top pages of a site in Google Search Console, by clicks: clicks, impressions, average position and CTR. Default period: the last 28 days.",
333
+ inputSchema: {
334
+ siteId,
335
+ ...periodShape,
336
+ limit: z.number().int().min(1).max(1e3).optional().default(25).describe("Most pages to return (default 25)")
337
+ },
338
+ annotations: READ_ONLY
339
+ },
340
+ ({ siteId: siteId2, limit, ...p }) => run(async () => {
341
+ const r = await api.get(
342
+ `/sites/${encodeURIComponent(siteId2)}/pages`,
343
+ { ...periodQuery(p), limit }
344
+ );
345
+ return formatPages(r.pages, r);
346
+ })
347
+ );
348
+ server.registerTool(
349
+ "get_traffic",
350
+ {
351
+ title: "Daily traffic",
352
+ description: "Daily Search Console clicks and impressions for a site. Default period: the last 90 days.",
353
+ inputSchema: { siteId, ...periodShape },
354
+ annotations: READ_ONLY
355
+ },
356
+ ({ siteId: siteId2, ...p }) => run(async () => {
357
+ const r = await api.get(
358
+ `/sites/${encodeURIComponent(siteId2)}/traffic`,
359
+ periodQuery(p)
360
+ );
361
+ return formatTraffic(r.days, r);
362
+ })
363
+ );
364
+ server.registerTool(
365
+ "list_crawls",
366
+ {
367
+ title: "List crawls",
368
+ description: "The latest crawls of a site, newest first: status, pages, issues and health score. Crawls are started in crawlseo, not here.",
369
+ inputSchema: {
370
+ siteId,
371
+ limit: z.number().int().min(1).max(100).optional().default(10).describe("Most crawls to return (default 10)")
372
+ },
373
+ annotations: READ_ONLY
374
+ },
375
+ ({ siteId: siteId2, limit }) => run(async () => {
376
+ const r = await api.get(`/sites/${encodeURIComponent(siteId2)}/crawls`, { limit });
377
+ return formatCrawls(r.crawls);
378
+ })
379
+ );
380
+ server.registerTool(
381
+ "get_crawl_issues",
382
+ {
383
+ title: "Crawl issues",
384
+ description: "Issues a crawl found on a site (broken links, missing titles, slow pages and more), with counts per severity. noindex pages are crawled but have no issues and do not count toward the health score; the result says how many there were. Defaults to the latest completed crawl.",
385
+ inputSchema: {
386
+ siteId,
387
+ crawlId: z.string().optional().describe("A crawl ID from list_crawls. Default: the latest completed crawl"),
388
+ severity: z.enum(["CRITICAL", "WARNING", "INFO"]).optional().describe("Only this severity"),
389
+ limit: z.number().int().min(1).max(500).optional().default(50).describe("Most issues to return (default 50)")
390
+ },
391
+ annotations: READ_ONLY
392
+ },
393
+ ({ siteId: siteId2, crawlId, severity, limit }) => run(async () => {
394
+ const r = await api.get(
395
+ `/sites/${encodeURIComponent(siteId2)}/crawls/${encodeURIComponent(crawlId || "latest")}/issues`,
396
+ { severity, limit }
397
+ );
398
+ return formatCrawlIssues(r);
399
+ })
400
+ );
401
+ server.registerTool(
402
+ "get_vitals",
403
+ {
404
+ title: "Core Web Vitals",
405
+ description: "Core Web Vitals reports for a site, newest first: performance score, LCP, CLS, INP and TTFB.",
406
+ inputSchema: {
407
+ siteId,
408
+ device: z.enum(["MOBILE", "DESKTOP"]).optional().describe("Only this device"),
409
+ limit: z.number().int().min(1).max(100).optional().default(10).describe("Most reports to return (default 10)")
410
+ },
411
+ annotations: READ_ONLY
412
+ },
413
+ ({ siteId: siteId2, device, limit }) => run(async () => {
414
+ const r = await api.get(
415
+ `/sites/${encodeURIComponent(siteId2)}/vitals`,
416
+ { device, limit }
417
+ );
418
+ return formatVitals(r.reports);
419
+ })
420
+ );
421
+ server.registerTool(
422
+ "get_opportunities",
423
+ {
424
+ title: "SEO opportunities",
425
+ description: "SEO opportunities for a site from its Search Console data: striking-distance keywords, low-CTR keywords, content decay and keyword cannibalization.",
426
+ inputSchema: { siteId },
427
+ annotations: READ_ONLY
428
+ },
429
+ ({ siteId: siteId2 }) => run(async () => {
430
+ const r = await api.get(`/sites/${encodeURIComponent(siteId2)}/opportunities`);
431
+ return formatOpportunities(r);
432
+ })
433
+ );
434
+ }
435
+ function createMcpServer(api, version2) {
436
+ const server = new McpServer({ name: "crawlseo", version: version2 });
437
+ registerTools(server, api);
438
+ return server;
439
+ }
440
+
441
+ // src/streamable.ts
442
+ function jsonRpcError(status, message, headers = {}) {
443
+ return Response.json({ jsonrpc: "2.0", error: { code: -32e3, message }, id: null }, { status, headers });
444
+ }
445
+ function refuseKey(status, message, headers = {}) {
446
+ return jsonRpcError(status, message, headers);
447
+ }
448
+ async function handleMcpRequest(req, api, version2) {
449
+ if (req.method !== "POST") return jsonRpcError(405, "Method not allowed: use POST", { Allow: "POST" });
450
+ const server = createMcpServer(api, version2);
451
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true });
452
+ await server.connect(transport);
453
+ try {
454
+ return await transport.handleRequest(req);
455
+ } finally {
456
+ await server.close();
457
+ }
458
+ }
459
+
460
+ // src/http.ts
461
+ var MAX_BODY_BYTES = 1e6;
462
+ var KEY_PREFIX = "crawlseo_";
463
+ var KEY_CACHE_MS = 6e4;
464
+ function bearer(header) {
465
+ const match = header?.match(/^Bearer\s+(\S+)\s*$/i);
466
+ return match ? match[1] : null;
467
+ }
468
+ async function readBody(req) {
469
+ const chunks = [];
470
+ let size = 0;
471
+ for await (const chunk of req) {
472
+ size += chunk.length;
473
+ if (size <= MAX_BODY_BYTES) chunks.push(chunk);
474
+ }
475
+ return size > MAX_BODY_BYTES ? null : Buffer.concat(chunks);
476
+ }
477
+ async function send(res, response) {
478
+ const headers = {};
479
+ response.headers.forEach((value, key) => headers[key] = value);
480
+ res.writeHead(response.status, headers);
481
+ res.end(Buffer.from(await response.arrayBuffer()));
482
+ }
483
+ function startHttpServer(opts) {
484
+ const accepted = /* @__PURE__ */ new Map();
485
+ const userAgent = `crawlseo-mcp/${opts.version}`;
486
+ async function checkKey(key) {
487
+ if (!key.startsWith(KEY_PREFIX)) return refuseKey(401, "Invalid API key", { "WWW-Authenticate": 'Bearer error="invalid_token"' });
488
+ const id = createHash("sha256").update(key).digest("hex");
489
+ if ((accepted.get(id) ?? 0) > Date.now()) return null;
490
+ try {
491
+ await createApiClient({ baseUrl: opts.baseUrl, apiKey: key, userAgent }).get("/key");
492
+ if (accepted.size > 1e3) accepted.clear();
493
+ accepted.set(id, Date.now() + KEY_CACHE_MS);
494
+ return null;
495
+ } catch (err) {
496
+ accepted.delete(id);
497
+ if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403 || err.status === 429)) {
498
+ return refuseKey(err.status, err.message);
499
+ }
500
+ console.error("[crawlseo-mcp] key check failed:", err instanceof Error ? err.message : err);
501
+ return refuseKey(502, "crawlseo could not be reached");
502
+ }
503
+ }
504
+ async function handle(req, res) {
505
+ const url = new URL(req.url ?? "/", "http://localhost");
506
+ if (url.pathname === "/health") {
507
+ return send(res, Response.json({ status: "ok", version: opts.version }));
508
+ }
509
+ if (url.pathname !== "/mcp") return send(res, Response.json({ error: "Not found" }, { status: 404 }));
510
+ const key = bearer(req.headers.authorization);
511
+ if (!key) return send(res, refuseKey(401, "Missing API key", { "WWW-Authenticate": "Bearer" }));
512
+ const refusal = await checkKey(key);
513
+ if (refusal) return send(res, refusal);
514
+ const headers = new Headers();
515
+ for (const [name, value] of Object.entries(req.headers)) {
516
+ if (typeof value === "string") headers.set(name, value);
517
+ else if (Array.isArray(value)) headers.set(name, value.join(", "));
518
+ }
519
+ const body = req.method === "POST" ? await readBody(req) : void 0;
520
+ if (body === null) return send(res, refuseKey(413, "Request body too large"));
521
+ const webReq = new Request(new URL(url.pathname + url.search, "http://localhost"), {
522
+ method: req.method,
523
+ headers,
524
+ body: body ? new Uint8Array(body) : void 0
525
+ });
526
+ const api = createApiClient({ baseUrl: opts.baseUrl, apiKey: key, userAgent });
527
+ return send(res, await handleMcpRequest(webReq, api, opts.version));
528
+ }
529
+ const server = createServer((req, res) => {
530
+ handle(req, res).catch((err) => {
531
+ console.error("[crawlseo-mcp]", err);
532
+ if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
533
+ res.end(JSON.stringify({ error: "Internal error" }));
534
+ });
535
+ });
536
+ return new Promise((resolve, reject) => {
537
+ server.once("error", reject);
538
+ server.listen(opts.port, opts.host, () => resolve(server));
539
+ });
540
+ }
541
+
542
+ // src/cli.ts
543
+ var USAGE = `crawlseo-mcp ${version}: read-only MCP server for crawlseo
544
+
545
+ Usage:
546
+ crawlseo-mcp stdio, for an MCP client that starts the server
547
+ crawlseo-mcp --http streamable HTTP on /mcp, plus /health
548
+
549
+ Options (HTTP mode):
550
+ --port <n> default 3333
551
+ --host <address> default 127.0.0.1
552
+
553
+ Environment:
554
+ CRAWLSEO_URL your crawlseo instance (default http://localhost:3000)
555
+ CRAWLSEO_API_KEY an access key from Settings > MCP (stdio mode; in HTTP
556
+ mode each client sends its own key as a Bearer token)
557
+ `;
558
+ function option(args, name) {
559
+ const i = args.indexOf(name);
560
+ return i >= 0 ? args[i + 1] : void 0;
561
+ }
562
+ async function main() {
563
+ const args = process.argv.slice(2);
564
+ if (args.includes("--help") || args.includes("-h")) {
565
+ process.stdout.write(USAGE);
566
+ return;
567
+ }
568
+ if (args.includes("--version")) {
569
+ process.stdout.write(`${version}
570
+ `);
571
+ return;
572
+ }
573
+ const baseUrl = process.env.CRAWLSEO_URL?.trim() || "http://localhost:3000";
574
+ if (args.includes("--http")) {
575
+ const port = Number(option(args, "--port") ?? 3333);
576
+ const host = option(args, "--host") ?? "127.0.0.1";
577
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid --port: ${option(args, "--port")}`);
578
+ const server = await startHttpServer({ baseUrl, host, port, version });
579
+ const { port: bound } = server.address();
580
+ console.error(`CrawlSEO MCP server on http://${host}:${bound}/mcp, reading from ${baseUrl}`);
581
+ const stop = () => server.close(() => process.exit(0));
582
+ process.on("SIGINT", stop);
583
+ process.on("SIGTERM", stop);
584
+ return;
585
+ }
586
+ const apiKey = process.env.CRAWLSEO_API_KEY?.trim();
587
+ if (!apiKey) {
588
+ console.error("CRAWLSEO_API_KEY is not set. Create an access key in crawlseo under Settings > MCP.");
589
+ process.exit(1);
590
+ }
591
+ const api = createApiClient({ baseUrl, apiKey, userAgent: `crawlseo-mcp/${version}` });
592
+ await createMcpServer(api, version).connect(new StdioServerTransport());
593
+ console.error(`CrawlSEO MCP server running on stdio, reading from ${baseUrl}`);
594
+ }
595
+ main().catch((err) => {
596
+ console.error("Failed to start the crawlseo MCP server:", err instanceof Error ? err.message : err);
597
+ process.exit(1);
598
+ });
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@crawlseo/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.5",
4
+ "description": "Read-only MCP server for crawlseo: your Search Console data, crawl issues, Core Web Vitals and SEO opportunities for AI agents, through your own instance's API.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "seo",
9
+ "google-search-console",
10
+ "crawlseo"
11
+ ],
12
+ "homepage": "https://github.com/crawlseo/crawlseo#mcp",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/crawlseo/crawlseo.git",
16
+ "directory": "packages/mcp"
17
+ },
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "bin": {
21
+ "crawlseo-mcp": "dist/cli.js"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "scripts": {
30
+ "build": "node build.mjs",
31
+ "prepack": "node build.mjs"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "zod": "^4.4.3"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }