crawlmark-mcp 0.1.0 → 0.1.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/.env.example +7 -8
- package/README.md +92 -26
- package/dist/analysis/tech-issues.js +6 -10
- package/dist/cli-help.js +5 -7
- package/dist/cli.js +5 -4
- package/dist/clients.js +5 -8
- package/dist/config.js +2 -3
- package/dist/lib/clients.js +12 -6
- package/dist/lib/country.js +0 -11
- package/dist/lib/env-file.js +14 -5
- package/dist/lib/issue-snapshots.js +0 -6
- package/dist/lib/provider-data.js +2 -2
- package/dist/lib/vendor-mcp.js +155 -0
- package/dist/prompts/templates/keyword-research.md +1 -1
- package/dist/providers/ahrefs-shape.js +2 -2
- package/dist/providers/semrush-shape.js +20 -314
- package/dist/providers/semrush.js +73 -480
- package/dist/providers/seo-data.js +13 -1
- package/dist/providers/units-billing.js +1 -1
- package/dist/reports/render/sections.js +2 -1
- package/dist/reports/technical-issues.js +3 -11
- package/dist/reports/templates/run.js +8 -4
- package/dist/reports/templates/schema.js +20 -10
- package/dist/reports/templates/write.js +15 -2
- package/dist/setup/apps.js +1 -1
- package/dist/setup/menu.js +2 -1
- package/dist/setup/status-providers.js +1 -3
- package/dist/setup/status.js +16 -22
- package/dist/setup/vendor-mcp-step.js +339 -0
- package/dist/setup/wizard.js +42 -59
- package/dist/tools/competitors.js +1 -1
- package/dist/tools/raw.js +4 -5
- package/dist/tools/reports.js +6 -6
- package/package.json +1 -1
- package/dist/providers/semrush-site-audit.js +0 -175
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `vendor-mcp` step: add Semrush's and Ahrefs' own remote MCP servers to the user's AI apps, for ad-hoc questions.
|
|
3
|
+
* crawlmark never calls these servers and never writes a key for them: sign-in is OAuth in the app. Every change is
|
|
4
|
+
* previewed, asked about and backed up the same way as the crawlmark registration (src/setup/apps.ts), other servers are
|
|
5
|
+
* never touched, and an existing entry with the same URL is "already". A different entry with the same name is replaced
|
|
6
|
+
* only on an explicit yes (the default is no, so --yes never replaces one).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { backupFile, mergeMcpServersJson, readIfExists, writeConfigWithBackup } from "../lib/claude-config.js";
|
|
10
|
+
import { claudeCodeVendorArgs, codexVendorArgs, codexVendorSnippet, cursorVendorEntry, desktopConnectorSteps, parseClaudeVendorGet, parseCodexVendorGet, planCodexVendorTomlEdit, readCodexVendorToml, VENDOR_IDS, VENDORS, } from "../lib/vendor-mcp.js";
|
|
11
|
+
import { APP_LABELS, cmdLine, detectApps, runApp } from "./apps.js";
|
|
12
|
+
import { PromptAbortedError } from "./prompter.js";
|
|
13
|
+
import { uiFor } from "./ui.js";
|
|
14
|
+
import { works } from "./wizard.js";
|
|
15
|
+
const OAUTH_NEXT = {
|
|
16
|
+
"claude-code": "In Claude Code, run /mcp and sign in to {label}.",
|
|
17
|
+
"claude-desktop": "",
|
|
18
|
+
cursor: "Restart Cursor (or reload its MCP servers), then sign in to {label} when it asks.",
|
|
19
|
+
codex: "If Codex needs it, sign in with `codex mcp login {name}`.",
|
|
20
|
+
};
|
|
21
|
+
function signInNote(app, v) {
|
|
22
|
+
return OAUTH_NEXT[app].replace("{label}", v.label).replace("{name}", v.name);
|
|
23
|
+
}
|
|
24
|
+
/** What an existing entry is, for display: its URL or command only (never headers, env or keys). */
|
|
25
|
+
function describeExisting(r) {
|
|
26
|
+
return `${r.url ? `url ${r.url}` : r.command ? `command ${r.command}` : "unknown"}${r.scope ? ` [scope: ${r.scope}]` : ""}`;
|
|
27
|
+
}
|
|
28
|
+
async function addClaudeCode(ctx, probe, v, notes) {
|
|
29
|
+
const addArgs = claudeCodeVendorArgs(v);
|
|
30
|
+
if (!probe.detected) {
|
|
31
|
+
notes.push({ kind: "warn", text: `Claude Code isn't installed (no working \`claude\` command). To add ${v.label} later: ${cmdLine("claude", addArgs)}` });
|
|
32
|
+
return "manual";
|
|
33
|
+
}
|
|
34
|
+
const existing = await ctx.exec("claude", ["mcp", "get", v.name]);
|
|
35
|
+
const add = async () => {
|
|
36
|
+
const r = await runApp(ctx, "claude", addArgs);
|
|
37
|
+
notes.push(r.code === 0 ? { kind: "success", text: `Added ${v.label} to Claude Code. ${signInNote("claude-code", v)}` } : { kind: "fail", text: `\`claude mcp add\` for ${v.label} failed (exit ${r.code}).` });
|
|
38
|
+
return r.code === 0 ? "registered" : "failed";
|
|
39
|
+
};
|
|
40
|
+
if (existing.code !== 0) {
|
|
41
|
+
ctx.out(`This adds ${v.label} to Claude Code for all your projects:`);
|
|
42
|
+
ctx.out(` ${cmdLine("claude", addArgs)}`);
|
|
43
|
+
if (!(await ctx.prompt.confirm(`Add ${v.label}'s MCP server to Claude Code?`, true))) {
|
|
44
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in Claude Code.` });
|
|
45
|
+
return "skipped";
|
|
46
|
+
}
|
|
47
|
+
if (ctx.dryRun) {
|
|
48
|
+
notes.push({ kind: "info", text: `Dry run: ${v.label} not added to Claude Code.` });
|
|
49
|
+
return "dry-run";
|
|
50
|
+
}
|
|
51
|
+
return add();
|
|
52
|
+
}
|
|
53
|
+
const current = parseClaudeVendorGet(existing.stdout);
|
|
54
|
+
if (!current) {
|
|
55
|
+
ctx.out(`Claude Code has a ${v.name} entry the wizard can't read; leaving it alone.`);
|
|
56
|
+
notes.push({ kind: "warn", text: `To replace it yourself: claude mcp remove ${v.name} -s <scope>, then ${cmdLine("claude", addArgs)}` });
|
|
57
|
+
return "manual";
|
|
58
|
+
}
|
|
59
|
+
if (current.url === v.url) {
|
|
60
|
+
notes.push({ kind: "success", text: `Claude Code: ${v.label} is already added.` });
|
|
61
|
+
return "already";
|
|
62
|
+
}
|
|
63
|
+
const scope = current.scope ?? "user";
|
|
64
|
+
ctx.out(`Claude Code already has a ${v.name} server, with a different setting:`);
|
|
65
|
+
ctx.out(` current: ${describeExisting(current)}`);
|
|
66
|
+
ctx.out(` new: url ${v.url} [scope: user]`);
|
|
67
|
+
if (!(await ctx.prompt.confirm(`Replace it (claude mcp remove ${v.name} -s ${scope}, then add)?`, false))) {
|
|
68
|
+
notes.push({ kind: "info", text: `Kept the existing ${v.name} entry in Claude Code.` });
|
|
69
|
+
return "skipped";
|
|
70
|
+
}
|
|
71
|
+
if (ctx.dryRun) {
|
|
72
|
+
notes.push({ kind: "info", text: `Dry run: Claude Code's ${v.name} entry not replaced.` });
|
|
73
|
+
return "dry-run";
|
|
74
|
+
}
|
|
75
|
+
const removed = await runApp(ctx, "claude", ["mcp", "remove", v.name, "-s", scope]);
|
|
76
|
+
if (removed.code !== 0) {
|
|
77
|
+
notes.push({ kind: "fail", text: `\`claude mcp remove ${v.name} -s ${scope}\` failed (exit ${removed.code}); nothing was added.` });
|
|
78
|
+
return "failed";
|
|
79
|
+
}
|
|
80
|
+
return add();
|
|
81
|
+
}
|
|
82
|
+
async function addCursor(ctx, v, notes) {
|
|
83
|
+
const app = APP_LABELS.cursor;
|
|
84
|
+
const path = ctx.paths.cursorConfig;
|
|
85
|
+
if (!path)
|
|
86
|
+
return "manual";
|
|
87
|
+
const entry = cursorVendorEntry(v);
|
|
88
|
+
try {
|
|
89
|
+
const merge = mergeMcpServersJson(readIfExists(path), entry, { app, name: v.name });
|
|
90
|
+
if (!merge.changed) {
|
|
91
|
+
notes.push({ kind: "success", text: `${app} already has ${v.label} (${path}).` });
|
|
92
|
+
return "already";
|
|
93
|
+
}
|
|
94
|
+
const prev = merge.previous;
|
|
95
|
+
const lines = [
|
|
96
|
+
merge.otherServers.length ? `Other servers kept unchanged: ${merge.otherServers.join(", ")}` : "No other servers in the file.",
|
|
97
|
+
...(merge.replaced ? [`Current ${v.name} entry (replaced): ${describeExisting({ url: typeof prev?.url === "string" ? prev.url : undefined, command: typeof prev?.command === "string" ? prev.command : undefined })}`] : []),
|
|
98
|
+
merge.replaced ? `New ${v.name} entry:` : `Adds this ${v.name} entry:`,
|
|
99
|
+
...JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${l}`),
|
|
100
|
+
];
|
|
101
|
+
uiFor(ctx).preview(`${app} config: ${path}`, lines);
|
|
102
|
+
const question = merge.replaced ? `Replace the existing ${v.name} entry? (the current file is kept as .bak)` : "Write this change? (the current file is kept as .bak)";
|
|
103
|
+
if (!(await ctx.prompt.confirm(question, !merge.replaced))) {
|
|
104
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in ${app}.` });
|
|
105
|
+
return "skipped";
|
|
106
|
+
}
|
|
107
|
+
if (ctx.dryRun) {
|
|
108
|
+
notes.push({ kind: "info", text: `Dry run: ${app} config not written.` });
|
|
109
|
+
return "dry-run";
|
|
110
|
+
}
|
|
111
|
+
const backup = writeConfigWithBackup(path, merge.text);
|
|
112
|
+
notes.push({ kind: "success", text: `Added ${v.label} to ${app}'s config${backup ? ` (backup: ${backup})` : ""}. ${signInNote("cursor", v)}` });
|
|
113
|
+
return "registered";
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err instanceof PromptAbortedError)
|
|
117
|
+
throw err;
|
|
118
|
+
notes.push({ kind: "fail", text: `Couldn't update ${app}'s config: ${err.message}` });
|
|
119
|
+
return "failed";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Claude Desktop's JSON config only takes local servers; remote ones are added in its Connectors settings. */
|
|
123
|
+
function addDesktop(ctx, v, notes) {
|
|
124
|
+
ctx.out(`Claude Desktop adds remote servers in its own settings, not in claude_desktop_config.json. To add ${v.label}:`);
|
|
125
|
+
for (const l of desktopConnectorSteps(v))
|
|
126
|
+
ctx.out(` ${l}`);
|
|
127
|
+
notes.push({ kind: "info", text: `Claude Desktop: add ${v.label} yourself in Settings > Connectors > Add custom connector (URL ${v.url}).` });
|
|
128
|
+
return "manual";
|
|
129
|
+
}
|
|
130
|
+
function codexRegistrationFor(ctx, v, get) {
|
|
131
|
+
if (get.code === 0) {
|
|
132
|
+
const parsed = parseCodexVendorGet(get.stdout);
|
|
133
|
+
if (parsed)
|
|
134
|
+
return { kind: "entry", ...parsed };
|
|
135
|
+
return { kind: "unreadable" };
|
|
136
|
+
}
|
|
137
|
+
// A failed get proves nothing by itself: the config file (read, never written here) decides.
|
|
138
|
+
try {
|
|
139
|
+
return readCodexVendorToml(ctx.paths.codexConfig ? readIfExists(ctx.paths.codexConfig) : undefined, v.name);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { kind: "unreadable" };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function addCodexCli(ctx, v, notes) {
|
|
146
|
+
const addArgs = codexVendorArgs(v);
|
|
147
|
+
const state = codexRegistrationFor(ctx, v, await ctx.exec("codex", ["mcp", "get", v.name, "--json"]));
|
|
148
|
+
const backupConfig = () => (ctx.paths.codexConfig && existsSync(ctx.paths.codexConfig) ? backupFile(ctx.paths.codexConfig) : undefined);
|
|
149
|
+
const add = async (backup) => {
|
|
150
|
+
const b = backup ?? backupConfig();
|
|
151
|
+
const r = await runApp(ctx, "codex", addArgs);
|
|
152
|
+
notes.push(r.code === 0
|
|
153
|
+
? { kind: "success", text: `Added ${v.label} to Codex${b ? ` (backup: ${b})` : ""}. ${signInNote("codex", v)}` }
|
|
154
|
+
: { kind: "fail", text: `\`codex mcp add\` for ${v.label} failed (exit ${r.code}).` });
|
|
155
|
+
return r.code === 0 ? "registered" : "failed";
|
|
156
|
+
};
|
|
157
|
+
if (state.kind === "absent") {
|
|
158
|
+
ctx.out(`This adds ${v.label} to Codex:`);
|
|
159
|
+
ctx.out(` ${cmdLine("codex", addArgs)}`);
|
|
160
|
+
if (!(await ctx.prompt.confirm(`Add ${v.label}'s MCP server to Codex?`, true))) {
|
|
161
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in Codex.` });
|
|
162
|
+
return "skipped";
|
|
163
|
+
}
|
|
164
|
+
if (ctx.dryRun) {
|
|
165
|
+
notes.push({ kind: "info", text: `Dry run: ${v.label} not added to Codex.` });
|
|
166
|
+
return "dry-run";
|
|
167
|
+
}
|
|
168
|
+
return add();
|
|
169
|
+
}
|
|
170
|
+
if (state.kind === "unreadable") {
|
|
171
|
+
ctx.out(`Codex has a ${v.name} entry the wizard can't read; leaving it alone.`);
|
|
172
|
+
notes.push({ kind: "warn", text: `To replace it yourself: codex mcp remove ${v.name}, then ${cmdLine("codex", addArgs)}` });
|
|
173
|
+
return "manual";
|
|
174
|
+
}
|
|
175
|
+
if (state.url === v.url) {
|
|
176
|
+
notes.push({ kind: "success", text: `Codex: ${v.label} is already added.` });
|
|
177
|
+
return "already";
|
|
178
|
+
}
|
|
179
|
+
ctx.out(`Codex already has a ${v.name} server, with a different setting:`);
|
|
180
|
+
ctx.out(` current: ${describeExisting(state)}`);
|
|
181
|
+
ctx.out(` new: url ${v.url}`);
|
|
182
|
+
if (!(await ctx.prompt.confirm(`Replace it (codex mcp remove ${v.name}, then add)?`, false))) {
|
|
183
|
+
notes.push({ kind: "info", text: `Kept the existing ${v.name} entry in Codex.` });
|
|
184
|
+
return "skipped";
|
|
185
|
+
}
|
|
186
|
+
if (ctx.dryRun) {
|
|
187
|
+
notes.push({ kind: "info", text: `Dry run: Codex's ${v.name} entry not replaced.` });
|
|
188
|
+
return "dry-run";
|
|
189
|
+
}
|
|
190
|
+
const backup = backupConfig();
|
|
191
|
+
const removed = await runApp(ctx, "codex", ["mcp", "remove", v.name]);
|
|
192
|
+
if (removed.code !== 0) {
|
|
193
|
+
notes.push({ kind: "fail", text: `\`codex mcp remove ${v.name}\` failed (exit ${removed.code}); nothing was added.` });
|
|
194
|
+
return "failed";
|
|
195
|
+
}
|
|
196
|
+
return add(backup);
|
|
197
|
+
}
|
|
198
|
+
/** Codex without a working CLI: append the entry to config.toml. A different existing entry is left for the user. */
|
|
199
|
+
async function addCodexToml(ctx, v, notes) {
|
|
200
|
+
const path = ctx.paths.codexConfig;
|
|
201
|
+
if (!path)
|
|
202
|
+
return "manual";
|
|
203
|
+
let before;
|
|
204
|
+
try {
|
|
205
|
+
before = readIfExists(path);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
notes.push({ kind: "fail", text: `Couldn't read Codex's config: ${err.message}` });
|
|
209
|
+
return "failed";
|
|
210
|
+
}
|
|
211
|
+
const plan = planCodexVendorTomlEdit(before, v);
|
|
212
|
+
if (!plan.ok) {
|
|
213
|
+
notes.push({ kind: "warn", text: `Couldn't update Codex's config (${path}): ${plan.reason} To add ${v.label} yourself, paste this into that file:\n${codexVendorSnippet(v).split("\n").map((l) => ` ${l}`).join("\n")}` });
|
|
214
|
+
return "manual";
|
|
215
|
+
}
|
|
216
|
+
if (!plan.changed) {
|
|
217
|
+
notes.push({ kind: "success", text: `Codex already has ${v.label} (${path}).` });
|
|
218
|
+
return "already";
|
|
219
|
+
}
|
|
220
|
+
uiFor(ctx).preview(`Codex config: ${path}`, [
|
|
221
|
+
plan.otherServers.length ? `Other servers kept unchanged: ${plan.otherServers.join(", ")}` : "No other servers in the file.",
|
|
222
|
+
`Adds this ${v.name} entry:`,
|
|
223
|
+
...codexVendorSnippet(v).split("\n").map((l) => ` ${l}`),
|
|
224
|
+
]);
|
|
225
|
+
if (!(await ctx.prompt.confirm("Write this change? (the current file is kept as .bak)", true))) {
|
|
226
|
+
notes.push({ kind: "info", text: `Skipped ${v.label} in Codex.` });
|
|
227
|
+
return "skipped";
|
|
228
|
+
}
|
|
229
|
+
if (ctx.dryRun) {
|
|
230
|
+
notes.push({ kind: "info", text: "Dry run: Codex config not written." });
|
|
231
|
+
return "dry-run";
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const backup = writeConfigWithBackup(path, plan.text);
|
|
235
|
+
notes.push({ kind: "success", text: `Added ${v.label} to Codex's config${backup ? ` (backup: ${backup})` : ""}. Restart Codex. ${signInNote("codex", v)}` });
|
|
236
|
+
return "registered";
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
notes.push({ kind: "fail", text: `Couldn't write Codex's config: ${err.message}` });
|
|
240
|
+
return "failed";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Vendors whose crawlmark integration works, for pre-ticking when the step is run on its own. */
|
|
244
|
+
async function configuredVendors(ctx) {
|
|
245
|
+
try {
|
|
246
|
+
const report = await ctx.status(["ahrefs", "semrush"]);
|
|
247
|
+
return VENDOR_IDS.filter((id) => works(id, report.integrations.find((i) => i.id === id)));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const names = (ids) => ids.map((id) => VENDORS[id].label).join(" and ");
|
|
254
|
+
export async function stepVendorMcp(ctx, opts = {}) {
|
|
255
|
+
const step = "vendor-mcp";
|
|
256
|
+
const ui = uiFor(ctx);
|
|
257
|
+
const later = `To add them later: ${ctx.cliDisplay} setup --step vendor-mcp`;
|
|
258
|
+
const selectedProviders = VENDOR_IDS.filter((id) => ctx.providers?.selected?.has(id));
|
|
259
|
+
let vendorIds;
|
|
260
|
+
let noticed = false;
|
|
261
|
+
if (!opts.asked) {
|
|
262
|
+
if (ctx.yes)
|
|
263
|
+
return { step, outcome: "skipped", message: `Not added (--yes). ${later}` };
|
|
264
|
+
// Plain and CI runs never get this extra question.
|
|
265
|
+
// The register step set up no apps: nothing to add these to, so don't ask.
|
|
266
|
+
if (opts.apps && opts.apps.length === 0)
|
|
267
|
+
return { step, outcome: "skipped", message: `No apps were set up to add them to. ${later}` };
|
|
268
|
+
if (!ui.fancy || selectedProviders.length === 0)
|
|
269
|
+
return { step, outcome: "skipped", message: selectedProviders.length ? `Not asked in plain mode. ${later}` : "Not needed (no Semrush or Ahrefs picked)." };
|
|
270
|
+
for (const id of selectedProviders)
|
|
271
|
+
ctx.out(VENDORS[id].notice);
|
|
272
|
+
noticed = true;
|
|
273
|
+
if (!(await ctx.prompt.confirm(`Also add ${names(selectedProviders)}'s own MCP server to these apps? (optional)`, false))) {
|
|
274
|
+
return { step, outcome: "skipped", message: `Not added. ${later}` };
|
|
275
|
+
}
|
|
276
|
+
vendorIds = selectedProviders;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
if (ctx.yes)
|
|
280
|
+
return { step, outcome: "skipped", message: `--yes adds no vendor MCP servers; run ${ctx.cliDisplay} setup --step vendor-mcp without it.` };
|
|
281
|
+
const defaults = selectedProviders.length ? selectedProviders : await configuredVendors(ctx);
|
|
282
|
+
const choices = VENDOR_IDS.map((id) => ({ value: id, label: VENDORS[id].label, hint: id === "semrush" ? "OAuth, spends your Semrush API units" : "OAuth, spends your Ahrefs API units" }));
|
|
283
|
+
ctx.out("Semrush and Ahrefs each run their own MCP server. Adding one lets your AI app ask it ad-hoc questions. crawlmark's own Semrush and Ahrefs support (API key, caps, reports) is separate and unchanged.");
|
|
284
|
+
vendorIds = ctx.prompt.multiselect
|
|
285
|
+
? await ctx.prompt.multiselect("Add which vendor MCP servers?", choices, defaults, { summary: `Add: ${defaults.length ? names(defaults) : "none"}. Use these?` })
|
|
286
|
+
: defaults;
|
|
287
|
+
if (!vendorIds.length)
|
|
288
|
+
return { step, outcome: "skipped", message: "No vendor MCP servers picked." };
|
|
289
|
+
}
|
|
290
|
+
const probes = await ui.spin("Looking for AI apps...", () => detectApps(ctx), { plain: false });
|
|
291
|
+
let apps;
|
|
292
|
+
if (opts.apps)
|
|
293
|
+
apps = probes.filter((p) => opts.apps.includes(p.id));
|
|
294
|
+
else {
|
|
295
|
+
const detected = probes.filter((p) => p.detected).map((p) => p.id);
|
|
296
|
+
const choices = probes.map((p) => ({ value: p.id, label: p.label, ...(p.hint ? { hint: p.hint } : {}) }));
|
|
297
|
+
const picked = ctx.prompt.multiselect
|
|
298
|
+
? await ctx.prompt.multiselect("Add them to which apps?", choices, detected, { summary: `Add to: ${probes.filter((p) => p.detected).map((p) => p.label).join(", ") || "none"} (detected). Use these?` })
|
|
299
|
+
: detected;
|
|
300
|
+
apps = probes.filter((p) => picked.includes(p.id));
|
|
301
|
+
}
|
|
302
|
+
if (!apps.length)
|
|
303
|
+
return { step, outcome: "skipped", message: "No apps to add them to." };
|
|
304
|
+
const notes = [];
|
|
305
|
+
const outcomes = [];
|
|
306
|
+
for (const id of vendorIds) {
|
|
307
|
+
const v = VENDORS[id];
|
|
308
|
+
if (!noticed) {
|
|
309
|
+
ctx.out("");
|
|
310
|
+
ctx.out(v.notice);
|
|
311
|
+
}
|
|
312
|
+
for (const probe of apps) {
|
|
313
|
+
let status;
|
|
314
|
+
if (probe.id === "claude-code")
|
|
315
|
+
status = await addClaudeCode(ctx, probe, v, notes);
|
|
316
|
+
else if (probe.id === "claude-desktop")
|
|
317
|
+
status = addDesktop(ctx, v, notes);
|
|
318
|
+
else if (probe.id === "cursor")
|
|
319
|
+
status = await addCursor(ctx, v, notes);
|
|
320
|
+
else
|
|
321
|
+
status = probe.codexCli ? await addCodexCli(ctx, v, notes) : await addCodexToml(ctx, v, notes);
|
|
322
|
+
outcomes.push({ vendor: id, app: probe.id, status });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const n of notes) {
|
|
326
|
+
if (!ui.fancy)
|
|
327
|
+
ctx.out(n.text);
|
|
328
|
+
else
|
|
329
|
+
ui[n.kind](n.text);
|
|
330
|
+
}
|
|
331
|
+
const failed = outcomes.some((o) => o.status === "failed");
|
|
332
|
+
const added = outcomes.some((o) => o.status === "registered" || o.status === "already");
|
|
333
|
+
return {
|
|
334
|
+
step,
|
|
335
|
+
outcome: failed ? "failed" : added ? "done" : "skipped",
|
|
336
|
+
message: outcomes.map((o) => `${VENDORS[o.vendor].label} in ${APP_LABELS[o.app]}: ${o.status}.`).join(" "),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
//# sourceMappingURL=vendor-mcp-step.js.map
|
package/dist/setup/wizard.js
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { APP_LABELS, registerApps } from "./apps.js";
|
|
12
|
+
import { stepVendorMcp } from "./vendor-mcp-step.js";
|
|
12
13
|
import { copyFileAtomic } from "../lib/atomic-file.js";
|
|
13
14
|
import { readEnvFile, writeEnvFile } from "../lib/env-file.js";
|
|
14
15
|
import { expandPath, sourceCheckoutMarker } from "../lib/home.js";
|
|
15
16
|
import { checkAhrefsKey } from "../providers/ahrefs.js";
|
|
16
17
|
import { describeAhrefsAccount } from "../providers/ahrefs-shape.js";
|
|
17
18
|
import { checkDataForSeoCredentials } from "../providers/dataforseo.js";
|
|
18
|
-
import { checkSemrushKey } from "../providers/semrush.js";
|
|
19
19
|
import { displayProviderValue, PROVIDER_LABELS, quoteProviderValue } from "../lib/provider-ids.js";
|
|
20
20
|
import { checkPsiKey } from "./checks.js";
|
|
21
21
|
import { checkCruxKey } from "../providers/crux.js";
|
|
@@ -26,7 +26,7 @@ import { uiFor } from "./ui.js";
|
|
|
26
26
|
import { tryAskingExamples } from "./try-asking.js";
|
|
27
27
|
import { DFS_LIVE_URL, DFS_SANDBOX_URL } from "../lib/env.js";
|
|
28
28
|
import { LINKS } from "./status.js";
|
|
29
|
-
export const STEP_NAMES = ["node", "provider", "dataforseo", "ahrefs", "semrush", "google", "psi", "discovery", "register"];
|
|
29
|
+
export const STEP_NAMES = ["node", "provider", "dataforseo", "ahrefs", "semrush", "google", "psi", "discovery", "register", "vendor-mcp"];
|
|
30
30
|
/**
|
|
31
31
|
* Phase 12: steps run only with --step (branding is also "Set up report branding" in the returning-user menu).
|
|
32
32
|
* They are not in the default flow or the step lists the wizard prints, so plain installer output is unchanged.
|
|
@@ -61,15 +61,17 @@ async function showStatus(ctx, only, opts = {}) {
|
|
|
61
61
|
return map;
|
|
62
62
|
}
|
|
63
63
|
/** Write keys to <home>/.env (0600), or say what would be written in a dry run. Values are never printed. */
|
|
64
|
-
function saveEnv(ctx, updates) {
|
|
64
|
+
function saveEnv(ctx, updates, remove = []) {
|
|
65
65
|
const keys = Object.keys(updates).join(", ");
|
|
66
66
|
const ui = uiFor(ctx);
|
|
67
67
|
if (ctx.dryRun) {
|
|
68
|
-
ui.info(`Dry run: would set ${keys} in ${ctx.paths.envFile} (mode 0600).`);
|
|
68
|
+
ui.info(`Dry run: would set ${keys}${remove.length ? ` and remove ${remove.join(", ")}` : ""} in ${ctx.paths.envFile} (mode 0600).`);
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
|
-
const r = writeEnvFile(ctx.paths.envFile, updates);
|
|
71
|
+
const r = writeEnvFile(ctx.paths.envFile, updates, remove);
|
|
72
72
|
const changed = [...r.updated, ...r.added];
|
|
73
|
+
if (r.removed.length)
|
|
74
|
+
ui.info(`Removed ${r.removed.join(", ")} from ${ctx.paths.envFile}.`);
|
|
73
75
|
ui.success(changed.length ? `Saved ${changed.join(", ")} to ${ctx.paths.envFile} (mode 0600).` : `${ctx.paths.envFile} already had these values.`);
|
|
74
76
|
const shadowed = Object.keys(updates).filter((k) => ctx.shellEnvKeys.has(k));
|
|
75
77
|
if (shadowed.length) {
|
|
@@ -252,7 +254,6 @@ export async function stepAhrefs(ctx, opts = {}) {
|
|
|
252
254
|
}
|
|
253
255
|
return { step, outcome: "failed", message: `No working Ahrefs key after ${MAX_ATTEMPTS} attempts.` };
|
|
254
256
|
}
|
|
255
|
-
const SEMRUSH_API_UNITS = "https://www.semrush.com/accounts/subscription-info/api-units/";
|
|
256
257
|
const GOOGLE_SERVICE_ACCOUNTS = "https://console.cloud.google.com/iam-admin/serviceaccounts";
|
|
257
258
|
const GOOGLE_AUTH_OVERVIEW = "https://console.cloud.google.com/auth/overview";
|
|
258
259
|
export async function stepSemrush(ctx, opts = {}) {
|
|
@@ -263,69 +264,38 @@ export async function stepSemrush(ctx, opts = {}) {
|
|
|
263
264
|
if (!opts.ticked && !(await ctx.prompt.confirm(working ? "Change the Semrush API key?" : "Set up Semrush now?", !working))) {
|
|
264
265
|
return { step, outcome: "skipped", message: working ? "Kept the current Semrush key." : "Semrush not set up." };
|
|
265
266
|
}
|
|
266
|
-
ctx.out("Semrush: keyword
|
|
267
|
-
ctx.out(
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
ctx.out("Semrush: keyword volume and backlinks (needs a Semrush plan with API units: 50,000 come with SEO Classic Pro and Guru, and Semrush One Starter and Pro+).");
|
|
268
|
+
ctx.out("Create a v4 API key under your profile > API keys. Semrush's older v3 keys aren't supported; create a v4 key.");
|
|
269
|
+
const saved = existingEnv(ctx);
|
|
270
|
+
// In 0.1.0 SEMRUSH_API_KEY was the old v3 key and SEMRUSH_V4_API_KEY the v4 key, so the alias wins when both are set.
|
|
271
|
+
// Keeping it saves it as SEMRUSH_API_KEY and removes the alias.
|
|
272
|
+
const current = saved.SEMRUSH_V4_API_KEY || saved.SEMRUSH_API_KEY;
|
|
271
273
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
272
274
|
let key;
|
|
273
275
|
try {
|
|
274
|
-
key = await ctx.prompt.secret(current ? "Semrush API key,
|
|
276
|
+
key = await ctx.prompt.secret(current ? "Semrush API key (v4, from your profile > API keys; Enter keeps the current one)" : "Semrush API key (v4, from your profile > API keys)");
|
|
275
277
|
}
|
|
276
278
|
catch (err) {
|
|
277
279
|
if (err instanceof NeedsInputError) {
|
|
278
280
|
const keySet = Boolean(current) || st?.data?.keySet === true;
|
|
279
|
-
return { step, outcome: "skipped", message: keySet ? `Semrush key is set
|
|
281
|
+
return { step, outcome: "skipped", message: keySet ? `Semrush key is set; run ${ctx.cliDisplay} setup --step semrush to change it` : `Semrush needs a key; run ${ctx.cliDisplay} setup --step semrush` };
|
|
280
282
|
}
|
|
281
283
|
throw err;
|
|
282
284
|
}
|
|
285
|
+
// A key pasted with its quotes from a .env line or a doc.
|
|
286
|
+
key = key.trim().replace(/^(["'])(.*)\1$/, "$2").trim();
|
|
283
287
|
if (!key && current)
|
|
284
288
|
key = current;
|
|
285
289
|
if (!key) {
|
|
286
290
|
ui.fail("No key entered.");
|
|
287
291
|
continue;
|
|
288
292
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
else
|
|
294
|
-
ui.success(`Semrush key accepted ${check.unitsLeft.toLocaleString("en-US")} API units left`);
|
|
295
|
-
saveEnv(ctx, { SEMRUSH_API_KEY: key });
|
|
296
|
-
markWorking(ctx, "semrush");
|
|
297
|
-
await semrushV4(ctx);
|
|
298
|
-
return { step, outcome: "done", message: `Semrush key accepted (${check.unitsLeft.toLocaleString("en-US")} API units left).` };
|
|
299
|
-
}
|
|
300
|
-
if (check.works === false) {
|
|
301
|
-
ui.fail(`Semrush rejected that key${"errorCode" in check && check.errorCode ? ` (ERROR ${check.errorCode})` : ""}. Find the v3 key under Subscription info > API units.`);
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
ui.warn(`Couldn't reach Semrush's balance check, so the key is untested: ${check.error}`);
|
|
305
|
-
if (await ctx.prompt.confirm("Save it anyway?", false)) {
|
|
306
|
-
saveEnv(ctx, { SEMRUSH_API_KEY: key });
|
|
307
|
-
await semrushV4(ctx);
|
|
308
|
-
return { step, outcome: "done", message: `Saved an untested Semrush key; check later with \`${ctx.cliDisplay} setup --step semrush\`.` };
|
|
309
|
-
}
|
|
310
|
-
return { step, outcome: "failed", message: "Semrush unreachable; nothing saved." };
|
|
293
|
+
saveEnv(ctx, { SEMRUSH_API_KEY: key }, ["SEMRUSH_V4_API_KEY"]);
|
|
294
|
+
markWorking(ctx, "semrush");
|
|
295
|
+
ui.info("Saved. Semrush has no free key check, so the key is checked on the first paid call (keyword volume or backlinks).");
|
|
296
|
+
return { step, outcome: "done", message: "Semrush key saved (v4). Semrush has no free key check, so it is checked on the first paid call (keyword volume or backlinks)." };
|
|
311
297
|
}
|
|
312
|
-
return { step, outcome: "failed", message: `No
|
|
313
|
-
}
|
|
314
|
-
/** The optional v4 key (backlinks): saved untested, since Semrush has no free check for it (N15). Skipped under --yes. */
|
|
315
|
-
async function semrushV4(ctx) {
|
|
316
|
-
if (ctx.yes)
|
|
317
|
-
return;
|
|
318
|
-
if (!(await ctx.prompt.confirm("Add a Semrush v4 key too? (backlinks use the v4 overview; without it they use the older v3 report)", false)))
|
|
319
|
-
return;
|
|
320
|
-
const v4 = await ctx.prompt.secret("Semrush API key (v4, from your profile > API keys)");
|
|
321
|
-
if (!v4) {
|
|
322
|
-
uiFor(ctx).info("No v4 key entered; backlinks keep using the v3 report.");
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
saveEnv(ctx, { SEMRUSH_V4_API_KEY: v4 });
|
|
326
|
-
uiFor(ctx).info(ctx.dryRun
|
|
327
|
-
? "Semrush has no free check for v4 keys, so it would be checked the first time backlinks_summary runs."
|
|
328
|
-
: "Saved; Semrush has no free check for v4 keys, so it's checked the first time backlinks_summary runs.");
|
|
298
|
+
return { step, outcome: "failed", message: `No Semrush key entered after ${MAX_ATTEMPTS} attempts.` };
|
|
329
299
|
}
|
|
330
300
|
const GOOGLE_OPTIONS = [
|
|
331
301
|
{ value: "A", label: "A. OAuth with Google Workspace: sign in once, sees every property you can open, sign-ins don't expire" },
|
|
@@ -625,7 +595,7 @@ const PROVIDER_ORDER = ["dataforseo", "ahrefs", "semrush"];
|
|
|
625
595
|
const PROVIDER_HINTS = {
|
|
626
596
|
dataforseo: "pay per call; free sandbox; also live SERPs, on-page, crawls",
|
|
627
597
|
ahrefs: "API v3, Lite plan or higher",
|
|
628
|
-
semrush: "
|
|
598
|
+
semrush: "v4 API key; keyword volume and backlinks",
|
|
629
599
|
};
|
|
630
600
|
/** What the wizard says when a provider isn't set up and --yes can't ask for its key. */
|
|
631
601
|
const NEEDS_KEY = {
|
|
@@ -637,7 +607,7 @@ const NEEDS_KEY = {
|
|
|
637
607
|
const KEY_VARS = {
|
|
638
608
|
dataforseo: "DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD",
|
|
639
609
|
ahrefs: "AHREFS_API_KEY",
|
|
640
|
-
semrush: "SEMRUSH_API_KEY
|
|
610
|
+
semrush: "SEMRUSH_API_KEY",
|
|
641
611
|
};
|
|
642
612
|
const STATE_WORD = { working: "working", configured: "set, untested", error: "error", missing: "not set up" };
|
|
643
613
|
/** Read status rows without printing them (under a spinner in fancy mode). */
|
|
@@ -656,8 +626,8 @@ async function readStatus(ctx, only) {
|
|
|
656
626
|
const providerIds = () => PROVIDER_ORDER.filter((id) => PROVIDER_STEPS.includes(id));
|
|
657
627
|
/** A provider's setup_status row id (every provider with a credential step has one). */
|
|
658
628
|
const rowId = (id) => id;
|
|
659
|
-
/** A provider counts as working: its row is working, or (Semrush
|
|
660
|
-
export const works = (id, s) => s?.state === "working" || (id === "semrush" && s?.state === "configured" &&
|
|
629
|
+
/** A provider counts as working: its row is working, or (Semrush, which has no free key check) its key is set. */
|
|
630
|
+
export const works = (id, s) => s?.state === "working" || (id === "semrush" && s?.state === "configured" && s.data?.keySet === true);
|
|
661
631
|
const hasKey = (id, s) => (id === "dataforseo" ? s !== undefined && s.state !== "missing" : s?.data?.keySet === true);
|
|
662
632
|
function describeDefault(seo) {
|
|
663
633
|
const d = seo?.data;
|
|
@@ -762,13 +732,14 @@ const STEPS = {
|
|
|
762
732
|
branding: { title: "Report branding", run: stepBranding },
|
|
763
733
|
discovery: { title: "Clients", run: stepDiscovery },
|
|
764
734
|
register: { title: "Register with Claude", run: stepRegister },
|
|
735
|
+
"vendor-mcp": { title: "Semrush and Ahrefs MCP servers (optional)", run: (ctx, opts) => stepVendorMcp(ctx, opts ?? {}) },
|
|
765
736
|
};
|
|
766
737
|
const isProviderStep = (p) => PROVIDER_STEPS.includes(p);
|
|
767
738
|
/** The run plan (spec 9.6) for a full run or one --step. */
|
|
768
739
|
export function runPlan(step) {
|
|
769
740
|
const creds = PROVIDER_STEPS;
|
|
770
741
|
if (!step)
|
|
771
|
-
return ["node", "provider:pick", ...creds, "provider:default", "google", "psi", "discovery", "register"];
|
|
742
|
+
return ["node", "provider:pick", ...creds, "provider:default", "google", "psi", "discovery", "register", "vendor-mcp"];
|
|
772
743
|
if (step === "provider")
|
|
773
744
|
return ["provider:pick", ...creds, "provider:default"];
|
|
774
745
|
if (isProviderStep(step))
|
|
@@ -787,6 +758,7 @@ const SUMMARY_LABELS = {
|
|
|
787
758
|
bing: "Bing",
|
|
788
759
|
branding: "Branding",
|
|
789
760
|
discovery: "Clients",
|
|
761
|
+
"vendor-mcp": "Vendor MCP",
|
|
790
762
|
};
|
|
791
763
|
const APP_OUTCOME = {
|
|
792
764
|
registered: "done",
|
|
@@ -888,11 +860,15 @@ export async function runWizard(ctx, opts = {}) {
|
|
|
888
860
|
// The returning-user menu (src/setup/menu.ts) shows the intro itself before it runs a step.
|
|
889
861
|
if (!opts.introShown)
|
|
890
862
|
ui.intro({ command: ctx.command ?? "setup", version: ctx.version ?? "", home: ctx.paths.home, dryRun: ctx.dryRun });
|
|
891
|
-
|
|
863
|
+
let phases = runPlan(opts.step);
|
|
864
|
+
// The vendor MCP question exists only in a fancy, interactive full run; plain, CI and --yes output is unchanged.
|
|
865
|
+
if (!opts.step && (ctx.yes || !ui.fancy))
|
|
866
|
+
phases = phases.filter((p) => p !== "vendor-mcp");
|
|
892
867
|
const results = [];
|
|
893
868
|
let picked;
|
|
894
869
|
let index = 0;
|
|
895
|
-
for (
|
|
870
|
+
for (let pi = 0; pi < phases.length; pi++) {
|
|
871
|
+
const name = phases[pi];
|
|
896
872
|
let result;
|
|
897
873
|
let record = true;
|
|
898
874
|
const run = async (title, fn) => {
|
|
@@ -914,6 +890,9 @@ export async function runWizard(ctx, opts = {}) {
|
|
|
914
890
|
picked = r.statuses;
|
|
915
891
|
return r.result;
|
|
916
892
|
});
|
|
893
|
+
// No Semrush or Ahrefs picked: the vendor MCP question has nothing to ask.
|
|
894
|
+
if (!opts.step && !(ctx.providers?.selected?.has("semrush") || ctx.providers?.selected?.has("ahrefs")))
|
|
895
|
+
phases = phases.filter((p) => p !== "vendor-mcp");
|
|
917
896
|
// The pick's line is shown; the summary's "provider" entry is the default phase's result.
|
|
918
897
|
record = false;
|
|
919
898
|
}
|
|
@@ -936,6 +915,10 @@ export async function runWizard(ctx, opts = {}) {
|
|
|
936
915
|
result = await run(STEPS[name].title, () => STEPS[name].run(ctx, { ticked: true }));
|
|
937
916
|
}
|
|
938
917
|
}
|
|
918
|
+
else if (name === "vendor-mcp") {
|
|
919
|
+
const apps = results.find((r) => r.step === "register")?.apps?.filter((a) => a.status === "registered" || a.status === "already").map((a) => a.id);
|
|
920
|
+
result = await run(STEPS[name].title, () => STEPS[name].run(ctx, opts.step ? { asked: true } : { apps: apps ?? [] }));
|
|
921
|
+
}
|
|
939
922
|
else {
|
|
940
923
|
const step = name;
|
|
941
924
|
result = await run(STEPS[step].title, () => STEPS[step].run(ctx));
|
|
@@ -172,7 +172,7 @@ export function registerCompetitorTools(server) {
|
|
|
172
172
|
"Through the client's data provider only (no fallback to another provider). Call with estimateOnly: true first, state the provider, cost and what's left, and run it only after the user agrees.";
|
|
173
173
|
server.registerTool("organic_visibility", {
|
|
174
174
|
title: "Organic visibility",
|
|
175
|
-
description: "A domain's organic search visibility from the client's data provider: ranking keywords (top 100), keywords by position band (top 3, 4-10, 11-20, 21-100;
|
|
175
|
+
description: "A domain's organic search visibility from the client's data provider: ranking keywords (top 100), keywords by position band (top 3, 4-10, 11-20, 21-100; Semrush's API has no visibility data, so a Semrush client is served by DataForSEO) and estimated organic traffic, labelled by provider (never compare traffic across providers). " +
|
|
176
176
|
"Comparisons with a month and a year ago appear only when stored snapshots (or, with history: true, the provider's own history, paid) have them. " +
|
|
177
177
|
SNAPSHOTS,
|
|
178
178
|
inputSchema: {
|
package/dist/tools/raw.js
CHANGED
|
@@ -188,13 +188,12 @@ export function registerRawTools(server) {
|
|
|
188
188
|
}, handler("ahrefs_status", () => ahrefsStatus()));
|
|
189
189
|
server.registerTool("semrush_status", {
|
|
190
190
|
title: "Semrush status",
|
|
191
|
-
description: "
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"A missing or rejected key is reported in the result, not as a tool error. Change keys in a terminal (setup --step semrush), never through chat.",
|
|
191
|
+
description: "Whether a Semrush API key (v4, from the profile > API keys page) is set, what Semrush serves in crawlmark (keyword volume and backlinks; its v4 API has no rankings, " +
|
|
192
|
+
"competitors, visibility or Site Audit), and the units crawlmark recorded this calendar month against its own cap. Semrush has no free key check, so a rejected key shows " +
|
|
193
|
+
"on the first paid call. Free, never cached, records no spend, never shows a key. Change the key in a terminal (setup --step semrush), never through chat.",
|
|
195
194
|
inputSchema: {},
|
|
196
195
|
annotations: READ_ONLY,
|
|
197
|
-
}, handler("semrush_status", () => semrushStatus()));
|
|
196
|
+
}, handler("semrush_status", async () => semrushStatus()));
|
|
198
197
|
// ---------------------------------------------------- browser checks (free)
|
|
199
198
|
server.registerTool("lighthouse", {
|
|
200
199
|
title: "Lighthouse",
|
package/dist/tools/reports.js
CHANGED
|
@@ -261,17 +261,17 @@ export function registerReportTools(server) {
|
|
|
261
261
|
"The same problem found by two sources is one item, listing both sources with their own severity; `affected` is the largest page count any one source reports. " +
|
|
262
262
|
"`changes` compares with the newest snapshot older than 24 hours: new, persistent, resolved (only when the source that reported it ran again) and unverified (not re-checked, never counted as fixed); null on the first run. " +
|
|
263
263
|
"Each call saves a snapshot (local). Never starts a crawl or audit; stale sources (older than maxAgeDays) are shown with their date. " +
|
|
264
|
-
"Free by default. The ahrefs_site_audit
|
|
265
|
-
"(
|
|
264
|
+
"Free by default. The ahrefs_site_audit source reads the client's existing Ahrefs Site Audit project (clients.yaml ahrefsSiteAuditProject, else matched by domain) and spends units " +
|
|
265
|
+
"(50 for the issues, plus 50 per issueUrls call): it needs allowPaid: true and is read only (never starts a crawl or creates a project). Semrush's API has no Site Audit. " +
|
|
266
266
|
"Call with estimateOnly: true first, state the units and ask before passing allowPaid.",
|
|
267
267
|
inputSchema: {
|
|
268
268
|
client: clientArg,
|
|
269
269
|
sources: z.array(z.enum(ISSUE_SOURCES)).min(1).max(ISSUE_SOURCES.length).optional().describe("Default: every free source (audit, local_crawl, site_hygiene, dataforseo_crawl, gsc_inspection)."),
|
|
270
270
|
minSeverity: z.enum(["critical", "high", "medium", "low"]).optional().describe("Leave out issues below this severity. Default low (everything)."),
|
|
271
271
|
limit: z.number().int().min(1).max(200).optional().describe("Issues listed (counts cover all). Default 50."),
|
|
272
|
-
issueUrls: z.number().int().min(0).max(5).optional().describe("Top issues to fetch up to 3 example URLs for from
|
|
272
|
+
issueUrls: z.number().int().min(0).max(5).optional().describe("Top issues to fetch up to 3 example URLs for from the Ahrefs Site Audit source (paid: one call each). Default 0."),
|
|
273
273
|
maxAgeDays: z.number().int().min(7).max(120).optional().describe("Sources older than this are flagged stale. Default 45."),
|
|
274
|
-
allowPaid: z.boolean().optional().describe("Needed for the Ahrefs
|
|
274
|
+
allowPaid: z.boolean().optional().describe("Needed for the Ahrefs Site Audit source (it spends Ahrefs units); set only after the user agreed to the estimate."),
|
|
275
275
|
estimateOnly: z.boolean().optional().describe("Return the provider units this would spend, read nothing and save nothing."),
|
|
276
276
|
},
|
|
277
277
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -279,11 +279,11 @@ export function registerReportTools(server) {
|
|
|
279
279
|
const c = await resolveClient(client);
|
|
280
280
|
const sources = o.sources ?? [...FREE_ISSUE_SOURCES];
|
|
281
281
|
if ((o.issueUrls ?? 0) > 0 && !sources.some((x) => PAID_ISSUE_SOURCES.has(x)))
|
|
282
|
-
throw new Error("issueUrls needs
|
|
282
|
+
throw new Error("issueUrls needs the ahrefs_site_audit source.");
|
|
283
283
|
const ti = await import("../reports/technical-issues.js");
|
|
284
284
|
if (estimateOnly) {
|
|
285
285
|
const paid = await ti.planPaidIssueSources(c, sources, o.issueUrls ?? 0);
|
|
286
|
-
const units = { ahrefs: paid.
|
|
286
|
+
const units = { ahrefs: paid.reduce((a, p) => a + p.units, 0), semrush: 0 };
|
|
287
287
|
return {
|
|
288
288
|
estimateOnly: true,
|
|
289
289
|
sources,
|