katman-mcp 0.1.0
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/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/cli.js +71 -0
- package/dist/context.js +43 -0
- package/dist/crawlers.js +86 -0
- package/dist/index.js +25 -0
- package/dist/license.js +448 -0
- package/dist/meta.js +28 -0
- package/dist/net.js +128 -0
- package/dist/server.js +17 -0
- package/dist/state.js +81 -0
- package/dist/tools/account/account.js +64 -0
- package/dist/tools/account/cli.js +21 -0
- package/dist/tools/account/index.js +48 -0
- package/dist/tools/account/sync.js +142 -0
- package/dist/tools/account/upsell.js +290 -0
- package/dist/tools/crawl/audit/analyze.js +719 -0
- package/dist/tools/crawl/audit/bots.js +114 -0
- package/dist/tools/crawl/audit/checks.js +126 -0
- package/dist/tools/crawl/audit/html.js +382 -0
- package/dist/tools/crawl/audit/index.js +364 -0
- package/dist/tools/crawl/audit/model.js +1 -0
- package/dist/tools/crawl/audit/report.js +199 -0
- package/dist/tools/crawl/audit/robots.js +199 -0
- package/dist/tools/crawl/audit/sitemap.js +147 -0
- package/dist/tools/crawl/cli.js +106 -0
- package/dist/tools/crawl/common/advice.js +61 -0
- package/dist/tools/crawl/common/findings.js +57 -0
- package/dist/tools/crawl/common/routes.js +80 -0
- package/dist/tools/crawl/common/text.js +89 -0
- package/dist/tools/crawl/common/urls.js +64 -0
- package/dist/tools/crawl/index.js +226 -0
- package/dist/tools/crawl/indexnow.js +271 -0
- package/dist/tools/crawl/keywords.js +178 -0
- package/dist/tools/crawl/scan/framework.js +645 -0
- package/dist/tools/crawl/scan/index.js +175 -0
- package/dist/tools/crawl/scan/project.js +180 -0
- package/dist/tools/crawl/scan/routes.js +354 -0
- package/dist/tools/crawl/scan/signals.js +414 -0
- package/dist/tools/method/cli.js +42 -0
- package/dist/tools/method/core/brief.js +302 -0
- package/dist/tools/method/core/extract.js +435 -0
- package/dist/tools/method/core/generate.js +504 -0
- package/dist/tools/method/core/lint.js +556 -0
- package/dist/tools/method/core/plan.js +405 -0
- package/dist/tools/method/core/prompt.js +150 -0
- package/dist/tools/method/core/questions.js +196 -0
- package/dist/tools/method/core/report.js +182 -0
- package/dist/tools/method/core/research-run.js +148 -0
- package/dist/tools/method/core/research.js +448 -0
- package/dist/tools/method/core/snippets.js +292 -0
- package/dist/tools/method/core/start.js +277 -0
- package/dist/tools/method/data/anatomy.js +141 -0
- package/dist/tools/method/data/appendices.js +202 -0
- package/dist/tools/method/data/cloudflare.js +192 -0
- package/dist/tools/method/data/evidence.js +137 -0
- package/dist/tools/method/data/geo-jobs.js +128 -0
- package/dist/tools/method/data/index.js +19 -0
- package/dist/tools/method/data/landing.js +67 -0
- package/dist/tools/method/data/lessons.js +127 -0
- package/dist/tools/method/data/measurement.js +142 -0
- package/dist/tools/method/data/method.js +141 -0
- package/dist/tools/method/data/offsite.js +148 -0
- package/dist/tools/method/data/pages.js +167 -0
- package/dist/tools/method/data/prompts.js +633 -0
- package/dist/tools/method/data/publish.js +113 -0
- package/dist/tools/method/data/render.js +117 -0
- package/dist/tools/method/data/research.js +131 -0
- package/dist/tools/method/data/roadmap.js +105 -0
- package/dist/tools/method/data/traps.js +74 -0
- package/dist/tools/method/data/verify.js +88 -0
- package/dist/tools/method/data/viral-list.js +39 -0
- package/dist/tools/method/index.js +209 -0
- package/dist/tools/method/lib/calls.js +53 -0
- package/dist/tools/method/lib/framework.js +525 -0
- package/dist/tools/method/lib/l10n.js +17 -0
- package/dist/tools/method/lib/routes.js +118 -0
- package/dist/tools/method/lib/state.js +454 -0
- package/dist/tools/method/lib/text.js +165 -0
- package/dist/tools/method/mcp/prompts.js +79 -0
- package/dist/tools/method/mcp/resources.js +50 -0
- package/dist/tools/visibility/analyze.js +463 -0
- package/dist/tools/visibility/cli.js +86 -0
- package/dist/tools/visibility/estimate.js +105 -0
- package/dist/tools/visibility/index.js +108 -0
- package/dist/tools/visibility/judge.js +156 -0
- package/dist/tools/visibility/manual.js +275 -0
- package/dist/tools/visibility/models.js +277 -0
- package/dist/tools/visibility/monitoring.js +165 -0
- package/dist/tools/visibility/openrouter.js +449 -0
- package/dist/tools/visibility/render.js +295 -0
- package/dist/tools/visibility/run.js +684 -0
- package/dist/tools/visibility/summary.js +164 -0
- package/dist/types.js +3 -0
- package/dist/util.js +92 -0
- package/package.json +54 -0
- package/server.json +34 -0
package/dist/license.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// Katman Pro licensing (docs/07-APP-SPEC.md §6).
|
|
2
|
+
//
|
|
3
|
+
// - KATMAN_KEY comes from the environment (the MCP config). It is sent only to KATMAN_API_URL (default
|
|
4
|
+
// https://katman.pro), in the JSON body of POST /api/web/mcp/verify and /api/web/mcp/sync, and is never
|
|
5
|
+
// printed, logged or written anywhere.
|
|
6
|
+
// - Verification runs at most once per `checkAgainAfter` (default 24 h). The answer is cached in
|
|
7
|
+
// ~/.katman/license.json under the key's SHA-256 hash (never the key).
|
|
8
|
+
// - Offline grace: when katman.pro can't be reached, the last good answer keeps working for 7 days.
|
|
9
|
+
// - Free tools never call into this module's network path; Pro tools go through withPro() at registration.
|
|
10
|
+
// - Tests inject a LicenseProvider (createServer({ license }) / runCli(args, { license })); there is no env
|
|
11
|
+
// switch that turns the gate off.
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { promises as fs } from "node:fs";
|
|
16
|
+
import { resolveRoot } from "./context.js";
|
|
17
|
+
import { HOMEPAGE, VERSION } from "./meta.js";
|
|
18
|
+
import { ok } from "./util.js";
|
|
19
|
+
import { proNotice, projectFacts, renderUpsell, statusLine, upsellStructured } from "./tools/account/upsell.js";
|
|
20
|
+
/** License features (spec §6). Trial = Pro. */
|
|
21
|
+
export const PRO_FEATURES = ["plan", "prompts", "briefs", "generate", "lint", "visibility", "manual_check", "indexnow", "report", "monitoring", "sync"];
|
|
22
|
+
/** Tools that never need a key (the free diagnosis). */
|
|
23
|
+
export const FREE_TOOLS = ["katman_start", "katman_research", "katman_scan_code", "katman_audit_site", "katman_keywords", "katman_account"];
|
|
24
|
+
/** Pro tool → license feature. */
|
|
25
|
+
export const PRO_TOOLS = {
|
|
26
|
+
katman_plan: "plan",
|
|
27
|
+
katman_prompt: "prompts",
|
|
28
|
+
katman_page_brief: "briefs",
|
|
29
|
+
katman_generate: "generate",
|
|
30
|
+
katman_lint_content: "lint",
|
|
31
|
+
katman_visibility_run: "visibility",
|
|
32
|
+
katman_manual_check: "manual_check",
|
|
33
|
+
katman_indexnow: "indexnow",
|
|
34
|
+
katman_report: "report",
|
|
35
|
+
katman_setup_monitoring: "monitoring",
|
|
36
|
+
katman_sync: "sync",
|
|
37
|
+
};
|
|
38
|
+
/** Appended to every Pro tool's description. */
|
|
39
|
+
export const PRO_NOTE = " (Katman Pro — needs KATMAN_KEY; 7-day free trial at katman.pro/app)";
|
|
40
|
+
export function toolForFeature(feature) {
|
|
41
|
+
return Object.keys(PRO_TOOLS).find((t) => PRO_TOOLS[t] === feature) ?? `katman_${feature}`;
|
|
42
|
+
}
|
|
43
|
+
export function allows(status, feature) {
|
|
44
|
+
return status.state === "valid" && status.features.includes(feature);
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
47
|
+
// Environment
|
|
48
|
+
const KEY_RE = /^km_[a-z]+_[A-Za-z0-9]{16,128}$/;
|
|
49
|
+
export function readKey(env) {
|
|
50
|
+
const raw = (env.KATMAN_KEY ?? "").trim();
|
|
51
|
+
if (!raw)
|
|
52
|
+
return {};
|
|
53
|
+
// Unfilled config placeholders: ${user_config.katman_key}, ${env:KATMAN_KEY}, ${input:katman_key}.
|
|
54
|
+
if (/^\$\{[^}]*\}$/.test(raw))
|
|
55
|
+
return { hint: `KATMAN_KEY has no value (the MCP config passes the placeholder ${raw} unfilled).` };
|
|
56
|
+
return { key: raw };
|
|
57
|
+
}
|
|
58
|
+
/** "km_live_abcd…" — the same short prefix katman.pro shows in the key list. */
|
|
59
|
+
export function keyHint(key) {
|
|
60
|
+
const m = /^(km_[a-z]+_)(.{0,4})/.exec(key);
|
|
61
|
+
return m ? `${m[1]}${m[2]}…` : "(not a km_ key)";
|
|
62
|
+
}
|
|
63
|
+
export function hashKey(key) {
|
|
64
|
+
return createHash("sha256").update(key, "utf8").digest("hex");
|
|
65
|
+
}
|
|
66
|
+
export function apiBase(env) {
|
|
67
|
+
const raw = (env.KATMAN_API_URL ?? "").trim() || HOMEPAGE;
|
|
68
|
+
let u;
|
|
69
|
+
try {
|
|
70
|
+
u = new URL(raw);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { error: `KATMAN_API_URL "${raw}" is not a URL.` };
|
|
74
|
+
}
|
|
75
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname) || u.hostname.endsWith(".localhost");
|
|
76
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) {
|
|
77
|
+
return { error: "KATMAN_API_URL must start with https:// (plain http only for localhost), so the key never travels unencrypted." };
|
|
78
|
+
}
|
|
79
|
+
return { url: `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}` };
|
|
80
|
+
}
|
|
81
|
+
/** POST JSON. Redirects are not followed (a 3xx must never carry the key to another host). Never throws. */
|
|
82
|
+
export const postJson = async (url, body, { timeoutMs }) => {
|
|
83
|
+
const ctrl = new AbortController();
|
|
84
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(url, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
redirect: "manual",
|
|
89
|
+
signal: ctrl.signal,
|
|
90
|
+
headers: {
|
|
91
|
+
"content-type": "application/json; charset=utf-8",
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
"user-agent": `katman-mcp/${VERSION} (+${HOMEPAGE}/mcp)`,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
});
|
|
97
|
+
const text = (await res.text()).slice(0, 100_000);
|
|
98
|
+
return { status: res.status, body: text };
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return { status: 0, body: "", error: e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : String(e) };
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
108
|
+
// Cache: ~/.katman/license.json → { version: 1, entries: { <sha256(key)>: CacheEntry } }
|
|
109
|
+
const DAY_S = 86_400;
|
|
110
|
+
const DEFAULT_TTL_S = DAY_S;
|
|
111
|
+
/** How long a rejection (invalid / revoked) is remembered before asking again. */
|
|
112
|
+
const REJECTED_TTL_S = 3_600;
|
|
113
|
+
export const GRACE_MS = 7 * DAY_S * 1000;
|
|
114
|
+
/** After a failed attempt, wait this long before trying the network again while the grace covers us. */
|
|
115
|
+
const RETRY_AFTER_FAILURE_MS = 10 * 60 * 1000;
|
|
116
|
+
const TIMEOUT_MS = 8_000;
|
|
117
|
+
export function defaultCacheFile() {
|
|
118
|
+
return path.join(os.homedir(), ".katman", "license.json");
|
|
119
|
+
}
|
|
120
|
+
async function readCache(file) {
|
|
121
|
+
try {
|
|
122
|
+
const j = JSON.parse(await fs.readFile(file, "utf8"));
|
|
123
|
+
if (j && j.version === 1 && j.entries && typeof j.entries === "object" && !Array.isArray(j.entries))
|
|
124
|
+
return { version: 1, entries: j.entries };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// missing or unreadable: start empty
|
|
128
|
+
}
|
|
129
|
+
return { version: 1, entries: {} };
|
|
130
|
+
}
|
|
131
|
+
async function writeCache(file, data) {
|
|
132
|
+
try {
|
|
133
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
134
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
135
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
136
|
+
await fs.rename(tmp, file);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// read-only home (CI, sandbox): work without a cache
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Keep the file small: drop entries of other keys not checked for 30 days, at most 20 entries. */
|
|
143
|
+
function prune(entries, keep, now) {
|
|
144
|
+
const out = Object.entries(entries).filter(([h, e]) => {
|
|
145
|
+
if (h === keep)
|
|
146
|
+
return true;
|
|
147
|
+
const t = Date.parse(e.checkedAt ?? e.failedAt ?? "");
|
|
148
|
+
return Number.isFinite(t) && now - t < 30 * DAY_S * 1000;
|
|
149
|
+
});
|
|
150
|
+
out.sort((a, b) => Date.parse(b[1].checkedAt ?? "") - Date.parse(a[1].checkedAt ?? ""));
|
|
151
|
+
return Object.fromEntries(out.slice(0, 20));
|
|
152
|
+
}
|
|
153
|
+
function ttlSeconds(x) {
|
|
154
|
+
const n = Number(x);
|
|
155
|
+
return Number.isFinite(n) && n > 0 ? Math.min(Math.max(Math.round(n), 60), 7 * DAY_S) : DEFAULT_TTL_S;
|
|
156
|
+
}
|
|
157
|
+
function toIso(x) {
|
|
158
|
+
if (typeof x === "string" && x.trim()) {
|
|
159
|
+
const t = Date.parse(x);
|
|
160
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
|
|
161
|
+
}
|
|
162
|
+
if (typeof x === "number" && Number.isFinite(x) && x > 0)
|
|
163
|
+
return new Date(x < 1e12 ? x * 1000 : x).toISOString();
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
/** Parse a /api/web/mcp/verify response (spec §6). Only a 401 (or an explicit ok:false) is a rejection. */
|
|
167
|
+
export function parseVerify(res) {
|
|
168
|
+
let j;
|
|
169
|
+
try {
|
|
170
|
+
const x = JSON.parse(res.body);
|
|
171
|
+
if (x && typeof x === "object" && !Array.isArray(x))
|
|
172
|
+
j = x;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
j = undefined;
|
|
176
|
+
}
|
|
177
|
+
if (res.status === 200 && j?.ok === true) {
|
|
178
|
+
const plan = j.plan === "trial" || j.plan === "pro" ? j.plan : "free";
|
|
179
|
+
const features = Array.isArray(j.features)
|
|
180
|
+
? j.features.filter((f) => typeof f === "string").slice(0, 64)
|
|
181
|
+
: plan === "free"
|
|
182
|
+
? []
|
|
183
|
+
: [...PRO_FEATURES];
|
|
184
|
+
const account = j.account && typeof j.account === "object" ? j.account : {};
|
|
185
|
+
const emailMasked = typeof account.emailMasked === "string" ? account.emailMasked.slice(0, 120) : undefined;
|
|
186
|
+
const trialEndsAt = toIso(j.trialEndsAt);
|
|
187
|
+
return { kind: "good", good: { plan, features, checkAgainAfter: ttlSeconds(j.checkAgainAfter), ...(trialEndsAt ? { trialEndsAt } : {}), ...(emailMasked ? { emailMasked } : {}) } };
|
|
188
|
+
}
|
|
189
|
+
if (res.status === 401 || (j?.ok === false && (j.error === "invalid_key" || j.error === "revoked"))) {
|
|
190
|
+
return { kind: "rejected", error: j?.error === "revoked" ? "revoked" : "invalid_key" };
|
|
191
|
+
}
|
|
192
|
+
if (res.status === 0)
|
|
193
|
+
return { kind: "failed", error: res.error || "network error" };
|
|
194
|
+
if (res.status >= 300 && res.status < 400)
|
|
195
|
+
return { kind: "failed", error: `unexpected redirect (HTTP ${res.status}); check KATMAN_API_URL` };
|
|
196
|
+
return { kind: "failed", error: `HTTP ${res.status}${res.status === 200 ? " (unexpected answer)" : ""}` };
|
|
197
|
+
}
|
|
198
|
+
export function createLicenseProvider(deps = {}) {
|
|
199
|
+
const env = () => deps.env ?? process.env;
|
|
200
|
+
const post = deps.post ?? postJson;
|
|
201
|
+
const cacheFile = deps.cacheFile ?? defaultCacheFile();
|
|
202
|
+
const now = deps.now ?? (() => new Date());
|
|
203
|
+
const version = deps.version ?? VERSION;
|
|
204
|
+
const timeoutMs = deps.timeoutMs ?? TIMEOUT_MS;
|
|
205
|
+
const inflight = new Map();
|
|
206
|
+
/** Never let the key appear in an error string (belt and braces: it's only ever in a request body). */
|
|
207
|
+
const scrub = (s, key) => (key ? s.split(key).join(keyHint(key)) : s);
|
|
208
|
+
function fromGood(g, source, at, extra = {}) {
|
|
209
|
+
// A trial past its end date is free, even if the cached answer still says trial (no offline extension).
|
|
210
|
+
const ended = g.plan !== "pro" && !!g.trialEndsAt && Date.parse(g.trialEndsAt) <= at;
|
|
211
|
+
const cutOff = ended && g.plan === "trial";
|
|
212
|
+
return {
|
|
213
|
+
state: "valid",
|
|
214
|
+
plan: cutOff ? "free" : g.plan,
|
|
215
|
+
features: cutOff ? [] : [...g.features],
|
|
216
|
+
...(g.trialEndsAt ? { trialEndsAt: g.trialEndsAt } : {}),
|
|
217
|
+
...(ended ? { trialEnded: true } : {}),
|
|
218
|
+
...(g.emailMasked ? { emailMasked: g.emailMasked } : {}),
|
|
219
|
+
checkedAt: g.checkedAt,
|
|
220
|
+
nextCheckAt: new Date(Date.parse(g.checkedAt) + g.checkAgainAfter * 1000).toISOString(),
|
|
221
|
+
source,
|
|
222
|
+
...extra,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function rejected(error, source, key) {
|
|
226
|
+
return { state: error === "revoked" ? "revoked" : "invalid", plan: "free", features: [], source, keyHint: keyHint(key) };
|
|
227
|
+
}
|
|
228
|
+
function fresh(e, at) {
|
|
229
|
+
if (!e.result || !e.checkedAt)
|
|
230
|
+
return false;
|
|
231
|
+
const age = at - Date.parse(e.checkedAt);
|
|
232
|
+
if (!(age >= 0))
|
|
233
|
+
return false; // unparsable or in the future (clock change): check again
|
|
234
|
+
if (!e.result.ok)
|
|
235
|
+
return age < REJECTED_TTL_S * 1000;
|
|
236
|
+
if (e.result.plan === "trial" && e.result.trialEndsAt && Date.parse(e.result.trialEndsAt) <= at)
|
|
237
|
+
return false;
|
|
238
|
+
return age < e.result.checkAgainAfter * 1000;
|
|
239
|
+
}
|
|
240
|
+
function withinGrace(e, at) {
|
|
241
|
+
if (!e.good)
|
|
242
|
+
return false;
|
|
243
|
+
const age = at - Date.parse(e.good.checkedAt);
|
|
244
|
+
return age >= 0 && age <= GRACE_MS;
|
|
245
|
+
}
|
|
246
|
+
async function verify(key, hash, refresh) {
|
|
247
|
+
const at = now().getTime();
|
|
248
|
+
const base = apiBase(env());
|
|
249
|
+
const server = base.url ?? HOMEPAGE;
|
|
250
|
+
const cache = await readCache(cacheFile);
|
|
251
|
+
const cached = cache.entries[hash];
|
|
252
|
+
const entry = cached && (cached.base ?? HOMEPAGE) === server ? cached : {};
|
|
253
|
+
const hint = keyHint(key);
|
|
254
|
+
if (!refresh && fresh(entry, at)) {
|
|
255
|
+
const r = entry.result;
|
|
256
|
+
return r.ok ? { ...fromGood({ ...r, checkedAt: entry.checkedAt }, "cache", at), keyHint: hint } : rejected(r.error, "cache", key);
|
|
257
|
+
}
|
|
258
|
+
// Offline and recently failed: don't wait for another timeout on every tool call.
|
|
259
|
+
const failedAt = Date.parse(entry.failedAt ?? "");
|
|
260
|
+
if (!refresh && Number.isFinite(failedAt) && at - failedAt >= 0 && at - failedAt < RETRY_AFTER_FAILURE_MS && withinGrace(entry, at)) {
|
|
261
|
+
return { ...fromGood(entry.good, "offline-grace", at, { graceUntil: new Date(Date.parse(entry.good.checkedAt) + GRACE_MS).toISOString(), error: entry.failure }), keyHint: hint };
|
|
262
|
+
}
|
|
263
|
+
const res = base.url
|
|
264
|
+
? await post(`${base.url}/api/web/mcp/verify`, { key, mcpVersion: version }, { timeoutMs })
|
|
265
|
+
: { status: 0, body: "", error: base.error };
|
|
266
|
+
const parsed = parseVerify(res);
|
|
267
|
+
const checkedAt = new Date(at).toISOString();
|
|
268
|
+
let status;
|
|
269
|
+
if (parsed.kind === "good") {
|
|
270
|
+
cache.entries[hash] = { base: server, checkedAt, result: { ok: true, ...parsed.good }, good: { ...parsed.good, checkedAt } };
|
|
271
|
+
status = { ...fromGood({ ...parsed.good, checkedAt }, "network", at), keyHint: hint };
|
|
272
|
+
}
|
|
273
|
+
else if (parsed.kind === "rejected") {
|
|
274
|
+
cache.entries[hash] = { base: server, checkedAt, result: { ok: false, error: parsed.error } };
|
|
275
|
+
status = rejected(parsed.error, "network", key);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const error = scrub(parsed.error, key);
|
|
279
|
+
cache.entries[hash] = { ...entry, base: server, failedAt: checkedAt, failure: error };
|
|
280
|
+
status = withinGrace(entry, at)
|
|
281
|
+
? { ...fromGood(entry.good, "offline-grace", at, { graceUntil: new Date(Date.parse(entry.good.checkedAt) + GRACE_MS).toISOString(), error }), keyHint: hint }
|
|
282
|
+
: {
|
|
283
|
+
state: "unreachable",
|
|
284
|
+
plan: "free",
|
|
285
|
+
features: [],
|
|
286
|
+
source: "network",
|
|
287
|
+
error,
|
|
288
|
+
keyHint: hint,
|
|
289
|
+
...(entry.good ? { checkedAt: entry.good.checkedAt } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
cache.entries = prune(cache.entries, hash, at);
|
|
293
|
+
await writeCache(cacheFile, cache);
|
|
294
|
+
return status;
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
hasKey: () => !!readKey(env()).key,
|
|
298
|
+
baseUrl: () => apiBase(env()).url ?? HOMEPAGE,
|
|
299
|
+
async status(opts = {}) {
|
|
300
|
+
const { key, hint } = readKey(env());
|
|
301
|
+
if (!key)
|
|
302
|
+
return { state: "no_key", plan: "free", features: [], source: "none", ...(hint ? { hint } : {}) };
|
|
303
|
+
if (!KEY_RE.test(key)) {
|
|
304
|
+
return {
|
|
305
|
+
state: "invalid",
|
|
306
|
+
plan: "free",
|
|
307
|
+
features: [],
|
|
308
|
+
source: "local",
|
|
309
|
+
keyHint: keyHint(key),
|
|
310
|
+
hint: "KATMAN_KEY doesn't look like a Katman key: keys start with km_live_ followed by letters and digits (no quotes, spaces or line breaks). It was not sent anywhere.",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const hash = hashKey(key);
|
|
314
|
+
const refresh = !!opts.refresh;
|
|
315
|
+
const flightKey = `${hash}:${refresh ? "r" : ""}`;
|
|
316
|
+
const running = inflight.get(flightKey);
|
|
317
|
+
if (running)
|
|
318
|
+
return running;
|
|
319
|
+
const p = verify(key, hash, refresh).finally(() => inflight.delete(flightKey));
|
|
320
|
+
inflight.set(flightKey, p);
|
|
321
|
+
return p;
|
|
322
|
+
},
|
|
323
|
+
async sync(body) {
|
|
324
|
+
const { key } = readKey(env());
|
|
325
|
+
if (!key || !KEY_RE.test(key))
|
|
326
|
+
return { ok: false, status: 0, error: key ? "invalid_key" : "no_key" };
|
|
327
|
+
const base = apiBase(env());
|
|
328
|
+
if (!base.url)
|
|
329
|
+
return { ok: false, status: 0, error: "network", message: base.error };
|
|
330
|
+
const res = await post(`${base.url}/api/web/mcp/sync`, { key, ...body }, { timeoutMs: 15_000 });
|
|
331
|
+
let j = {};
|
|
332
|
+
try {
|
|
333
|
+
const x = JSON.parse(res.body);
|
|
334
|
+
if (x && typeof x === "object" && !Array.isArray(x))
|
|
335
|
+
j = x;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// not JSON
|
|
339
|
+
}
|
|
340
|
+
const message = typeof j.message === "string" ? scrub(j.message.slice(0, 300), key) : undefined;
|
|
341
|
+
if (res.status >= 200 && res.status < 300 && j.ok !== false) {
|
|
342
|
+
const project = j.project && typeof j.project === "object" ? j.project : {};
|
|
343
|
+
const idRaw = j.projectId ?? project.id;
|
|
344
|
+
const projectId = typeof idRaw === "string" || typeof idRaw === "number" ? String(idRaw) : undefined;
|
|
345
|
+
return { ok: true, status: res.status, dashboardUrl: dashboardUrl(base.url, j.url ?? j.dashboardUrl, projectId), ...(projectId ? { projectId } : {}), ...(message ? { message } : {}) };
|
|
346
|
+
}
|
|
347
|
+
const code = typeof j.error === "string" ? j.error.slice(0, 60) : undefined;
|
|
348
|
+
const error = res.status === 0 ? "network" : res.status === 401 ? (code === "revoked" ? "revoked" : "invalid_key") : res.status === 402 || res.status === 403 ? code ?? "not_pro" : code ?? `HTTP ${res.status}`;
|
|
349
|
+
return { ok: false, status: res.status, error, ...(res.status === 0 && res.error ? { message: scrub(res.error, key) } : message ? { message } : {}) };
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/** The project page on the dashboard: the server's link when it points at the same host, else /app/projects(?id=). */
|
|
354
|
+
function dashboardUrl(base, given, projectId) {
|
|
355
|
+
if (typeof given === "string" && given) {
|
|
356
|
+
try {
|
|
357
|
+
const u = new URL(given, base + "/");
|
|
358
|
+
if (u.origin === new URL(base).origin)
|
|
359
|
+
return u.toString();
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// ignore
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return `${base}/app/projects${projectId ? `?id=${encodeURIComponent(projectId)}` : ""}`;
|
|
366
|
+
}
|
|
367
|
+
let processProvider;
|
|
368
|
+
/** The real provider for this process (environment + ~/.katman/license.json). */
|
|
369
|
+
export function defaultLicense() {
|
|
370
|
+
processProvider ??= createLicenseProvider();
|
|
371
|
+
return processProvider;
|
|
372
|
+
}
|
|
373
|
+
export function licenseOf(ctx) {
|
|
374
|
+
return ctx.license ?? defaultLicense();
|
|
375
|
+
}
|
|
376
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
377
|
+
// Gate
|
|
378
|
+
export async function requirePro(feature, provider) {
|
|
379
|
+
let status;
|
|
380
|
+
try {
|
|
381
|
+
status = await provider.status();
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
status = { state: "unreachable", plan: "free", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
|
|
385
|
+
}
|
|
386
|
+
return { allowed: allows(status, feature), status };
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Wrap a Pro tool's handler at registration. Without a Pro/trial license it returns a friendly upsell
|
|
390
|
+
* (not isError, so the agent relays it) instead of running the tool.
|
|
391
|
+
*/
|
|
392
|
+
export function withPro(ctx, feature, handler) {
|
|
393
|
+
const tool = toolForFeature(feature);
|
|
394
|
+
return async (args, extra) => {
|
|
395
|
+
const gate = await requirePro(feature, licenseOf(ctx));
|
|
396
|
+
if (gate.allowed)
|
|
397
|
+
return handler(args, extra);
|
|
398
|
+
let facts;
|
|
399
|
+
try {
|
|
400
|
+
const pr = args?.project_root;
|
|
401
|
+
facts = await projectFacts(await resolveRoot(ctx, typeof pr === "string" ? pr : undefined));
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
facts = undefined;
|
|
405
|
+
}
|
|
406
|
+
const a = (args && typeof args === "object" ? args : {});
|
|
407
|
+
return ok(renderUpsell({ tool, feature, status: gate.status, facts, args: a, surface: "mcp" }), upsellStructured(tool, feature, gate.status));
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* For katman_start: a one-line plan status when KATMAN_KEY is set (no line and no network without it), and a
|
|
412
|
+
* notice when the recommended next tool is a Pro tool the license doesn't cover.
|
|
413
|
+
*/
|
|
414
|
+
export async function startLicense(ctx) {
|
|
415
|
+
const provider = licenseOf(ctx);
|
|
416
|
+
let status;
|
|
417
|
+
try {
|
|
418
|
+
status = await provider.status();
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
status = { state: "unreachable", plan: "free", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
|
|
422
|
+
}
|
|
423
|
+
const keySet = provider.hasKey();
|
|
424
|
+
return {
|
|
425
|
+
...(keySet || status.hint ? { line: statusLine(status) } : {}),
|
|
426
|
+
notice: (tool) => {
|
|
427
|
+
const feature = PRO_TOOLS[tool];
|
|
428
|
+
return feature && !allows(status, feature) ? proNotice(tool, status) : undefined;
|
|
429
|
+
},
|
|
430
|
+
structured: { keySet, state: status.state, plan: status.plan, ...(status.trialEndsAt ? { trialEndsAt: status.trialEndsAt } : {}) },
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
434
|
+
// CLI
|
|
435
|
+
/** Exit code of a Pro CLI command without a license. */
|
|
436
|
+
export const EXIT_NEEDS_PRO = 4;
|
|
437
|
+
/** CLI command → feature: plan, report, check, and indexnow only with --submit. */
|
|
438
|
+
export function cliFeature(cmd, rest) {
|
|
439
|
+
if (rest.includes("--help") || rest.includes("-h"))
|
|
440
|
+
return undefined;
|
|
441
|
+
switch (cmd) {
|
|
442
|
+
case "plan": return "plan";
|
|
443
|
+
case "report": return "report";
|
|
444
|
+
case "check": return "visibility";
|
|
445
|
+
case "indexnow": return rest.includes("--submit") ? "indexnow" : undefined;
|
|
446
|
+
default: return undefined;
|
|
447
|
+
}
|
|
448
|
+
}
|
package/dist/meta.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const VERSION = "0.1.0";
|
|
2
|
+
export const HOMEPAGE = "https://katman.pro";
|
|
3
|
+
/** Katman Pro (docs/07-APP-SPEC.md §1): 7-day trial without a card, then the founding price. */
|
|
4
|
+
export const APP_URL = `${HOMEPAGE}/app`;
|
|
5
|
+
export const KEYS_URL = `${APP_URL}/mcp`;
|
|
6
|
+
export const BILLING_URL = `${APP_URL}/billing`;
|
|
7
|
+
export const TRIAL_DAYS = 7;
|
|
8
|
+
export const PRO_PRICE = "$29/month";
|
|
9
|
+
export const PRO_PRICE_NOTE = "$29/month founding price, locked for the first 100 customers";
|
|
10
|
+
/** Sent to the client model at initialization. Keep it short and imperative. */
|
|
11
|
+
export const SERVER_INSTRUCTIONS = `Katman makes this project's public website readable, quotable and recommendable by AI assistants (ChatGPT, Claude, Gemini, Perplexity, Copilot) and findable in Google, Bing and Brave.
|
|
12
|
+
|
|
13
|
+
It follows the Katman method: 4 steps (1 Research → 2 Plan → 3 Build → 4 Verify) and 3 gates every public page must pass:
|
|
14
|
+
- Read: bots can reach the page (robots.txt, CDN/firewall) and its text is in the server HTML, not only in JavaScript.
|
|
15
|
+
- Quote: the first 1–3 sentences answer the question on their own, with the brand's one-sentence description.
|
|
16
|
+
- Fit: there is a page written for the situation the customer describes to the assistant.
|
|
17
|
+
|
|
18
|
+
Free vs Pro: katman_start, katman_research, katman_scan_code, katman_audit_site, katman_keywords and katman_account are free (the diagnosis). Every other tool is Katman Pro and needs KATMAN_KEY in the MCP config (7-day free trial, no card: katman.pro/app); without it the tool returns a short explanation instead of running — pass it on to the user.
|
|
19
|
+
Never try to bypass or work around the license (no editing Katman's code or cache, no made-up keys); the free tools keep working either way.
|
|
20
|
+
|
|
21
|
+
How to work:
|
|
22
|
+
1. Always call katman_start first; it tells you the next step. Pass project_root (absolute path of the project) and site_url when you know them.
|
|
23
|
+
2. Never invent numbers, reviews, customers, competitor facts or claims. Write [FILL] and ask the user.
|
|
24
|
+
3. Never put information into schema, meta tags or llms.txt that is not visible on the page itself.
|
|
25
|
+
4. Only public pages. Don't touch login-only app screens.
|
|
26
|
+
5. After each change, list the files you changed. Don't call a task done until its Katman check passes (every prompt has an acceptance criterion).
|
|
27
|
+
6. katman_visibility_run spends the user's OpenRouter credits: run it with dry_run first, show the estimate, and get the user's OK. If your client times out during a run, the run keeps going in the background; results land in .katman/measurements.md. API answers are a proxy for the consumer apps; confirm key questions with katman_manual_check.
|
|
28
|
+
7. katman_sync sends only aggregates (gate counts, visibility rates, plan progress, issue titles) to the user's katman.pro dashboard: show its dry run first.`;
|
package/dist/net.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Minimal HTTP helpers for auditing live sites. Node >= 20 global fetch.
|
|
2
|
+
export const KATMAN_UA = "Mozilla/5.0 (compatible; KatmanAudit/0.1; +https://katman.pro/bot)";
|
|
3
|
+
export async function httpGet(url, opts = {}) {
|
|
4
|
+
const started = Date.now();
|
|
5
|
+
const ctrl = new AbortController();
|
|
6
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 20000);
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(url, {
|
|
9
|
+
method: opts.method ?? "GET",
|
|
10
|
+
redirect: opts.redirect ?? "manual",
|
|
11
|
+
signal: ctrl.signal,
|
|
12
|
+
headers: {
|
|
13
|
+
"user-agent": opts.ua ?? KATMAN_UA,
|
|
14
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
15
|
+
"accept-language": "en-US,en;q=0.9",
|
|
16
|
+
...(opts.headers ?? {}),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const headers = {};
|
|
20
|
+
res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
|
|
21
|
+
let body = "";
|
|
22
|
+
let bytes = 0;
|
|
23
|
+
if ((opts.method ?? "GET") === "GET" && res.body) {
|
|
24
|
+
const max = opts.maxBytes ?? 3_000_000;
|
|
25
|
+
const reader = res.body.getReader();
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done)
|
|
30
|
+
break;
|
|
31
|
+
bytes += value.byteLength;
|
|
32
|
+
if (bytes <= max)
|
|
33
|
+
chunks.push(value);
|
|
34
|
+
else {
|
|
35
|
+
await reader.cancel();
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
body = new TextDecoder("utf-8", { fatal: false }).decode(concat(chunks));
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
url,
|
|
43
|
+
finalUrl: res.url || url,
|
|
44
|
+
status: res.status,
|
|
45
|
+
headers,
|
|
46
|
+
body,
|
|
47
|
+
bytes,
|
|
48
|
+
ms: Date.now() - started,
|
|
49
|
+
location: headers["location"],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return {
|
|
54
|
+
url,
|
|
55
|
+
finalUrl: url,
|
|
56
|
+
status: 0,
|
|
57
|
+
headers: {},
|
|
58
|
+
body: "",
|
|
59
|
+
bytes: 0,
|
|
60
|
+
ms: Date.now() - started,
|
|
61
|
+
error: e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : String(e),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function concat(chunks) {
|
|
69
|
+
const len = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
70
|
+
const out = new Uint8Array(len);
|
|
71
|
+
let off = 0;
|
|
72
|
+
for (const c of chunks) {
|
|
73
|
+
out.set(c, off);
|
|
74
|
+
off += c.byteLength;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Run fn over items with at most `limit` in flight; preserves order. */
|
|
79
|
+
export async function mapLimit(items, limit, fn) {
|
|
80
|
+
const out = new Array(items.length);
|
|
81
|
+
let next = 0;
|
|
82
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
83
|
+
for (;;) {
|
|
84
|
+
const i = next++;
|
|
85
|
+
if (i >= items.length)
|
|
86
|
+
return;
|
|
87
|
+
out[i] = await fn(items[i], i);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
await Promise.all(workers);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
export function sleep(ms) {
|
|
94
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
95
|
+
}
|
|
96
|
+
/** POST a JSON body (used by IndexNow). Same result shape as httpGet; never throws. */
|
|
97
|
+
export async function httpPostJson(url, data, opts = {}) {
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
const ctrl = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 20000);
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(url, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
redirect: "follow",
|
|
105
|
+
signal: ctrl.signal,
|
|
106
|
+
headers: {
|
|
107
|
+
"user-agent": opts.ua ?? KATMAN_UA,
|
|
108
|
+
"content-type": "application/json; charset=utf-8",
|
|
109
|
+
accept: "application/json,text/plain,*/*",
|
|
110
|
+
...(opts.headers ?? {}),
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify(data),
|
|
113
|
+
});
|
|
114
|
+
const headers = {};
|
|
115
|
+
res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
|
|
116
|
+
const body = (await res.text()).slice(0, opts.maxBytes ?? 100_000);
|
|
117
|
+
return { url, finalUrl: res.url || url, status: res.status, headers, body, bytes: body.length, ms: Date.now() - started };
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return {
|
|
121
|
+
url, finalUrl: url, status: 0, headers: {}, body: "", bytes: 0, ms: Date.now() - started,
|
|
122
|
+
error: e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : String(e),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The MCP server factory (no side effects on import, so tests can build a server with an injected license).
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { VERSION, SERVER_INSTRUCTIONS } from "./meta.js";
|
|
4
|
+
import { defaultLicense } from "./license.js";
|
|
5
|
+
import { registerMethodTools } from "./tools/method/index.js";
|
|
6
|
+
import { registerCrawlTools } from "./tools/crawl/index.js";
|
|
7
|
+
import { registerVisibilityTools } from "./tools/visibility/index.js";
|
|
8
|
+
import { registerAccountTools } from "./tools/account/index.js";
|
|
9
|
+
export function createServer(opts = {}) {
|
|
10
|
+
const server = new McpServer({ name: "katman", title: "Katman — AI visibility for your site", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
11
|
+
const ctx = { server, version: VERSION, license: opts.license ?? defaultLicense() };
|
|
12
|
+
registerMethodTools(server, ctx);
|
|
13
|
+
registerCrawlTools(server, ctx);
|
|
14
|
+
registerVisibilityTools(server, ctx);
|
|
15
|
+
registerAccountTools(server, ctx);
|
|
16
|
+
return server;
|
|
17
|
+
}
|