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.
Files changed (97) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +160 -0
  3. package/dist/cli.js +71 -0
  4. package/dist/context.js +43 -0
  5. package/dist/crawlers.js +86 -0
  6. package/dist/index.js +25 -0
  7. package/dist/license.js +448 -0
  8. package/dist/meta.js +28 -0
  9. package/dist/net.js +128 -0
  10. package/dist/server.js +17 -0
  11. package/dist/state.js +81 -0
  12. package/dist/tools/account/account.js +64 -0
  13. package/dist/tools/account/cli.js +21 -0
  14. package/dist/tools/account/index.js +48 -0
  15. package/dist/tools/account/sync.js +142 -0
  16. package/dist/tools/account/upsell.js +290 -0
  17. package/dist/tools/crawl/audit/analyze.js +719 -0
  18. package/dist/tools/crawl/audit/bots.js +114 -0
  19. package/dist/tools/crawl/audit/checks.js +126 -0
  20. package/dist/tools/crawl/audit/html.js +382 -0
  21. package/dist/tools/crawl/audit/index.js +364 -0
  22. package/dist/tools/crawl/audit/model.js +1 -0
  23. package/dist/tools/crawl/audit/report.js +199 -0
  24. package/dist/tools/crawl/audit/robots.js +199 -0
  25. package/dist/tools/crawl/audit/sitemap.js +147 -0
  26. package/dist/tools/crawl/cli.js +106 -0
  27. package/dist/tools/crawl/common/advice.js +61 -0
  28. package/dist/tools/crawl/common/findings.js +57 -0
  29. package/dist/tools/crawl/common/routes.js +80 -0
  30. package/dist/tools/crawl/common/text.js +89 -0
  31. package/dist/tools/crawl/common/urls.js +64 -0
  32. package/dist/tools/crawl/index.js +226 -0
  33. package/dist/tools/crawl/indexnow.js +271 -0
  34. package/dist/tools/crawl/keywords.js +178 -0
  35. package/dist/tools/crawl/scan/framework.js +645 -0
  36. package/dist/tools/crawl/scan/index.js +175 -0
  37. package/dist/tools/crawl/scan/project.js +180 -0
  38. package/dist/tools/crawl/scan/routes.js +354 -0
  39. package/dist/tools/crawl/scan/signals.js +414 -0
  40. package/dist/tools/method/cli.js +42 -0
  41. package/dist/tools/method/core/brief.js +302 -0
  42. package/dist/tools/method/core/extract.js +435 -0
  43. package/dist/tools/method/core/generate.js +504 -0
  44. package/dist/tools/method/core/lint.js +556 -0
  45. package/dist/tools/method/core/plan.js +405 -0
  46. package/dist/tools/method/core/prompt.js +150 -0
  47. package/dist/tools/method/core/questions.js +196 -0
  48. package/dist/tools/method/core/report.js +182 -0
  49. package/dist/tools/method/core/research-run.js +148 -0
  50. package/dist/tools/method/core/research.js +448 -0
  51. package/dist/tools/method/core/snippets.js +292 -0
  52. package/dist/tools/method/core/start.js +277 -0
  53. package/dist/tools/method/data/anatomy.js +141 -0
  54. package/dist/tools/method/data/appendices.js +202 -0
  55. package/dist/tools/method/data/cloudflare.js +192 -0
  56. package/dist/tools/method/data/evidence.js +137 -0
  57. package/dist/tools/method/data/geo-jobs.js +128 -0
  58. package/dist/tools/method/data/index.js +19 -0
  59. package/dist/tools/method/data/landing.js +67 -0
  60. package/dist/tools/method/data/lessons.js +127 -0
  61. package/dist/tools/method/data/measurement.js +142 -0
  62. package/dist/tools/method/data/method.js +141 -0
  63. package/dist/tools/method/data/offsite.js +148 -0
  64. package/dist/tools/method/data/pages.js +167 -0
  65. package/dist/tools/method/data/prompts.js +633 -0
  66. package/dist/tools/method/data/publish.js +113 -0
  67. package/dist/tools/method/data/render.js +117 -0
  68. package/dist/tools/method/data/research.js +131 -0
  69. package/dist/tools/method/data/roadmap.js +105 -0
  70. package/dist/tools/method/data/traps.js +74 -0
  71. package/dist/tools/method/data/verify.js +88 -0
  72. package/dist/tools/method/data/viral-list.js +39 -0
  73. package/dist/tools/method/index.js +209 -0
  74. package/dist/tools/method/lib/calls.js +53 -0
  75. package/dist/tools/method/lib/framework.js +525 -0
  76. package/dist/tools/method/lib/l10n.js +17 -0
  77. package/dist/tools/method/lib/routes.js +118 -0
  78. package/dist/tools/method/lib/state.js +454 -0
  79. package/dist/tools/method/lib/text.js +165 -0
  80. package/dist/tools/method/mcp/prompts.js +79 -0
  81. package/dist/tools/method/mcp/resources.js +50 -0
  82. package/dist/tools/visibility/analyze.js +463 -0
  83. package/dist/tools/visibility/cli.js +86 -0
  84. package/dist/tools/visibility/estimate.js +105 -0
  85. package/dist/tools/visibility/index.js +108 -0
  86. package/dist/tools/visibility/judge.js +156 -0
  87. package/dist/tools/visibility/manual.js +275 -0
  88. package/dist/tools/visibility/models.js +277 -0
  89. package/dist/tools/visibility/monitoring.js +165 -0
  90. package/dist/tools/visibility/openrouter.js +449 -0
  91. package/dist/tools/visibility/render.js +295 -0
  92. package/dist/tools/visibility/run.js +684 -0
  93. package/dist/tools/visibility/summary.js +164 -0
  94. package/dist/types.js +3 -0
  95. package/dist/util.js +92 -0
  96. package/package.json +54 -0
  97. package/server.json +34 -0
@@ -0,0 +1,165 @@
1
+ import { composeOneSentence } from "../../../util.js";
2
+ export const FILL = "[FILL]";
3
+ const PLACEHOLDER_RE = /\[(?:FILL|DOLDUR)\b[^\]]*\]/i;
4
+ export function hasPlaceholder(s) {
5
+ return typeof s === "string" && PLACEHOLDER_RE.test(s);
6
+ }
7
+ /** A value counts as filled when it is a non-empty string without [FILL]/[DOLDUR]. */
8
+ export function isFilled(s) {
9
+ return typeof s === "string" && s.trim().length > 0 && !hasPlaceholder(s);
10
+ }
11
+ export function orFill(s, fill = FILL) {
12
+ return isFilled(s) ? s.trim() : fill;
13
+ }
14
+ /** The not-X note is optional when the brand isn't confused with anything; "none" and friends mean "checked, no confusion". */
15
+ export function hasNotX(notX) {
16
+ return isFilled(notX) && !/^\s*(none|n\/a|na|-|—|yok|hiçbiri|no confusion)\s*\.?\s*$/i.test(notX);
17
+ }
18
+ const TR_MAP = {
19
+ ç: "c", Ç: "c", ğ: "g", Ğ: "g", ı: "i", İ: "i", ö: "o", Ö: "o", ş: "s", Ş: "s", ü: "u", Ü: "u",
20
+ };
21
+ /** Turkish-aware ASCII folding (ç→c, ı→i …) plus removal of other diacritics. */
22
+ export function asciiFold(s) {
23
+ return s
24
+ .replace(/[çÇğĞıİöÖşŞüÜ]/g, (c) => TR_MAP[c] ?? c)
25
+ .normalize("NFD")
26
+ .replace(/[̀-ͯ]/g, "");
27
+ }
28
+ const SLUG_STOP = new Set([
29
+ "i", "a", "an", "the", "my", "me", "is", "are", "am", "was", "to", "of", "and", "or", "but", "for", "with",
30
+ "on", "in", "at", "it", "its", "there", "that", "this", "can", "do", "how", "what", "which", "any", "some",
31
+ "be", "im", "ive", "we", "our", "your", "you", "so", "just", "have", "has", "had", "would", "should", "want",
32
+ "bir", "ve", "ile", "ama", "icin", "mi", "mu", "bu", "su", "da", "de", "ki", "ben", "benim", "var", "cok",
33
+ ]);
34
+ /** URL slug from a sentence: lowercase ASCII, hyphens, stopwords dropped, at most `maxWords` words. */
35
+ export function slugify(s, maxWords = 7) {
36
+ const words = asciiFold(s.toLowerCase())
37
+ .replace(/['’]/g, "")
38
+ .split(/[^a-z0-9]+/)
39
+ .filter(Boolean);
40
+ const kept = words.filter((w) => !SLUG_STOP.has(w));
41
+ const use = (kept.length >= 2 ? kept : words).slice(0, maxWords);
42
+ return use.join("-").slice(0, 60).replace(/-+$/, "");
43
+ }
44
+ /** Normalise a path: leading slash, no trailing slash (except "/"), lowercase. */
45
+ export function normPath(p) {
46
+ let s = p.trim();
47
+ try {
48
+ if (/^https?:\/\//i.test(s))
49
+ s = new URL(s).pathname;
50
+ }
51
+ catch {
52
+ // keep as is
53
+ }
54
+ s = s.split(/[?#]/)[0] || "/";
55
+ if (!s.startsWith("/"))
56
+ s = "/" + s;
57
+ if (s.length > 1)
58
+ s = s.replace(/\/+$/, "");
59
+ return s.toLowerCase();
60
+ }
61
+ const ABBREV_RE = /\b(e\.g|i\.e|vs|etc|dr|mr|mrs|ms|no|ör|vb|bkz|approx|inc|ltd)\./gi;
62
+ /** Rough sentence splitter that keeps common abbreviations together. */
63
+ export function splitSentences(text) {
64
+ const guarded = text.replace(ABBREV_RE, (m) => m.replace(/\./g, "\u0000"));
65
+ return guarded
66
+ .split(/(?<=[.!?…]["”’)]?)\s+(?=["“‘(]?[\p{Lu}\p{N}])/u)
67
+ .map((p) => p.replace(/\u0000/g, ".").replace(/\s+/g, " ").trim())
68
+ .filter(Boolean);
69
+ }
70
+ export function countWords(s) {
71
+ return (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
72
+ }
73
+ const STOP = new Set([
74
+ // English
75
+ "a", "an", "the", "and", "or", "but", "to", "of", "in", "on", "at", "for", "with", "by", "from", "as", "is", "are",
76
+ "was", "were", "be", "been", "it", "its", "this", "that", "these", "those", "you", "your", "we", "our", "us", "they",
77
+ "their", "i", "me", "my", "he", "she", "his", "her", "them", "do", "does", "did", "can", "will", "would", "should",
78
+ "could", "not", "no", "so", "if", "then", "than", "into", "about", "over", "any", "all", "some", "just", "only",
79
+ "also", "very", "there", "here", "what", "which", "who", "how", "when", "where", "why", "have", "has", "had", "get",
80
+ // Turkish
81
+ "ve", "ile", "bir", "bu", "şu", "o", "da", "de", "ki", "mi", "mı", "mu", "mü", "için", "ama", "gibi", "daha", "çok",
82
+ "en", "her", "hiç", "ne", "nasıl", "var", "yok", "olarak", "olan", "sen", "siz", "biz", "ben", "onu", "bunu",
83
+ ]);
84
+ function stem(w) {
85
+ let x = asciiFold(w);
86
+ if (x.length > 3 && x.endsWith("s") && !x.endsWith("ss"))
87
+ x = x.slice(0, -1);
88
+ return x.length > 5 ? x.slice(0, 5) : x;
89
+ }
90
+ /** Content tokens (stopwords removed, crude stems; Turkish suffixes after an apostrophe dropped). */
91
+ export function stemTokens(s) {
92
+ const lower = s.replace(/İ/g, "i").toLowerCase().replace(/['’][\p{L}]+/gu, "");
93
+ return lower
94
+ .split(/[^\p{L}\p{N}]+/u)
95
+ .filter((w) => w.length > 1 && !STOP.has(w))
96
+ .map(stem);
97
+ }
98
+ /** Share of the reference sentence's content tokens that also appear in `target` (0–1). */
99
+ export function containment(ref, target) {
100
+ const a = new Set(stemTokens(ref));
101
+ if (!a.size)
102
+ return 0;
103
+ const b = new Set(stemTokens(target));
104
+ let hit = 0;
105
+ for (const x of a)
106
+ if (b.has(x))
107
+ hit++;
108
+ return hit / a.size;
109
+ }
110
+ export function pct(x) {
111
+ return `${Math.round(x * 100)}%`;
112
+ }
113
+ /** Join the four one-liner parts into one readable sentence (brings → we do → gets → fear is unnecessary). */
114
+ export function composeSentence(o) {
115
+ return composeOneSentence(o, 4);
116
+ }
117
+ /** The brand's one sentence: the hand-written sentence, else the composed four-part one-liner. */
118
+ export function brandSentence(r) {
119
+ if (!r)
120
+ return undefined;
121
+ if (isFilled(r.brand?.sentence))
122
+ return r.brand.sentence.trim();
123
+ return composeSentence(r.brand?.oneLiner);
124
+ }
125
+ export function firstSentence(s) {
126
+ if (!s)
127
+ return undefined;
128
+ return splitSentences(s)[0];
129
+ }
130
+ export function escapeRegExp(s) {
131
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
132
+ }
133
+ /** Whole-word, case-insensitive test (works with spaces and Unicode letters in the needle). */
134
+ export function containsPhrase(haystack, needle) {
135
+ const n = needle.trim();
136
+ if (!n)
137
+ return false;
138
+ const re = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(n)}(?=$|[^\\p{L}\\p{N}])`, "iu");
139
+ return re.test(haystack);
140
+ }
141
+ export function normKey(s) {
142
+ return asciiFold(s.toLowerCase()).replace(/[^a-z0-9]+/g, " ").trim();
143
+ }
144
+ export function addDays(d, days) {
145
+ return new Date(d.getTime() + days * 86_400_000);
146
+ }
147
+ export function isoDay(d) {
148
+ if (!d)
149
+ return "";
150
+ const x = typeof d === "string" ? new Date(d) : d;
151
+ return Number.isNaN(x.getTime()) ? String(d).slice(0, 10) : x.toISOString().slice(0, 10);
152
+ }
153
+ /** Replace {name} placeholders with known values; unknown or empty values become [FILL]. Other braces stay untouched. */
154
+ export function fillTemplate(text, values, known, fill = FILL) {
155
+ return text.replace(/\{([a-z_]+)\}/g, (m, name) => {
156
+ if (!known.includes(name))
157
+ return m;
158
+ const v = values[name];
159
+ return isFilled(v) ? v : fill;
160
+ });
161
+ }
162
+ export function codeBlock(s, lang = "") {
163
+ const fence = s.includes("```") ? "````" : "```";
164
+ return `${fence}${lang}\n${s.replace(/\n+$/, "")}\n${fence}`;
165
+ }
@@ -0,0 +1,79 @@
1
+ // MCP prompts (slash commands): katman-start, katman-audit, katman-situation-page, katman-measure.
2
+ // Each returns user-role messages that walk the agent through the tools step by step.
3
+ import { z } from "zod";
4
+ const user = (text) => ({ role: "user", content: { type: "text", text } });
5
+ const RULES = `Rules (Katman method):
6
+ - Don't write or change code until you have summarised .katman/research.md and I have approved the page plan (.katman/plan.md).
7
+ - Never invent numbers, reviews, customers, competitor facts or claims. Write [FILL] and ask me.
8
+ - Never put anything into schema, meta tags or llms.txt that isn't visible on the page.
9
+ - Public pages only; don't touch login-only app screens.
10
+ - Never show bots different content than people (cloaking) and never hide instructions for AI in a page.
11
+ - katman_visibility_run spends my OpenRouter credits (default panel "economy" ≈$2.4 per run, "full" ≈$4.6, "probe" ≈$1.8): dry_run first, show me the estimate, wait for my OK.
12
+ - After every change, list the files you changed. A task is done only when its Katman check passes.`;
13
+ export function startPromptText(siteUrl) {
14
+ return `You are working on this project's public website with Katman. Goal: get the site read, quoted and recommended by ChatGPT, Claude, Gemini and Perplexity, and found in Google, Bing and Brave.
15
+
16
+ Do this now:
17
+ 1. Call katman_start with project_root = the absolute path of this workspace${siteUrl ? ` and site_url = "${siteUrl}"` : " (add site_url if you know the live address; otherwise ask me for it)"}.
18
+ 2. Show me its status table in five lines or fewer.
19
+ 3. Do exactly the "Next step" it returns — one step at a time — and call katman_start again after each step.
20
+ 4. When research is needed: fill what the codebase already says (README, landing copy, pricing page, footer) and ask me for my customers' own sentences, competitors and anything you'd otherwise have to guess.
21
+
22
+ ${RULES}`;
23
+ }
24
+ export function auditPromptText(siteUrl) {
25
+ return `Audit this site the way AI crawlers see it (Katman prompt P1). REPORT ONLY: do not create, modify or delete any file in the project.
26
+
27
+ 1. Call katman_start (project_root = the absolute path of this workspace${siteUrl ? `, site_url = "${siteUrl}"` : ""}) to load the state and the site URL. If no site URL is known, ask me for it.
28
+ 2. Call katman_scan_code.
29
+ 3. Call katman_audit_site with the site URL.
30
+ 4. From their output, write the P1 report: one row per public page — URL | SSR/SSG/CSR | title + length | meta description + length | canonical | robots meta | H1 count | images without alt | og:image | JSON-LD types | pages linking to it.
31
+ 5. Answer: do robots.txt and sitemap.xml exist and how are they generated? Does /this-page-does-not-exist return 404? Are links <a href> or onClick? Is the site also live at a preview/staging address? Do GPTBot, OAI-SearchBot, ClaudeBot and PerplexityBot get 200?
32
+ 6. Finish with the 10 most important problems ranked by traffic impact, each with the file that would change and the Katman prompt (P-code) that fixes it.
33
+
34
+ Don't fix anything yet. When I approve, call katman_plan.`;
35
+ }
36
+ export function situationPromptText(situation) {
37
+ const target = situation?.trim() ? `"${situation.trim()}"` : "the next situation in research that has no page yet";
38
+ return `Write one situation page with Katman (prompt P6). Situation: ${target}.
39
+
40
+ 1. Call katman_research with mode "show" (project_root = the absolute path of this workspace). Find the situation by id (s1, s2…) or by its wording. If it isn't there, add it with katman_research mode "update", data.situations = [{ sentence: "<the customer's own words, no brand name>" }].
41
+ 2. Call katman_page_brief with type "situation" and situation_id. Show me the brief. Fill every [FILL] from research or ask me — never invent proof, prices or competitor facts.
42
+ 3. Call katman_prompt with id "P6" and the same situation_id, and follow it: create the page file where the brief says, add it to the sitemap, and link to it from at least one related page (the footer works).
43
+ 4. Call katman_lint_content with file = the new page file, page_type "situation" and situation_id. Fix every FAIL and run it again.
44
+ 5. List the files you changed. After deploy: katman_audit_site with urls = [the page URL] must pass (text visible with JavaScript off, at least 1 internal link in, in the sitemap), then katman_indexnow for this URL only.
45
+
46
+ Don't: put the brand in the H1 or the URL, claim more than the proof, put us first in the alternatives, write "free" for a paid product, or add schema facts that aren't visible on the page.`;
47
+ }
48
+ export function measurePromptText() {
49
+ return `Measure whether AI assistants name, recommend and cite this site (Katman book, ch. 15).
50
+
51
+ 1. Call katman_research with mode "show" (project_root = the absolute path of this workspace) and check the question set: 12 unbranded questions + 2 diagnostic ones ("What is <brand>?"). If it's missing, call katman_research with generate_questions: true and show me the questions to review before the first run. After the first run, don't change them: runs are only comparable with the same questions, models and temperature.
52
+ 2. If OPENROUTER_API_KEY is set: call katman_visibility_run with dry_run: true, show me the calls and the estimated cost (default panel "economy" ≈$2.4 per run; "full" ≈$4.6; "probe" ≈$1.8 — my own OpenRouter credits), and wait for my OK before running it for real. Then run the 3 most important situation questions again with question_ids + samples: 3 — measure how often we're named, not our rank.
53
+ 3. Otherwise (or in addition, to confirm the key questions in the real apps): call katman_manual_check, ask the questions in ChatGPT / Claude / Perplexity logged out with web search on, and paste each answer back.
54
+ 4. Report three separate rates — mentioned, recommended first, cited — with the change since the previous run, the competitors named instead, and the domains cited (those are the off-site targets, ch. 14).
55
+ 5. Tell me the next re-measure date (+14 days) and call katman_report.
56
+
57
+ Don't: add branded questions beyond the 2 diagnostics, measure from a logged-in account, or treat a single run as a ranking (it is a low sample).`;
58
+ }
59
+ export function registerMethodPrompts(server) {
60
+ server.registerPrompt("katman-start", {
61
+ title: "Katman: start / continue",
62
+ description: "Start or continue the Katman method on this project: status of the 4 steps and the next step.",
63
+ argsSchema: { site_url: z.string().optional().describe("The live site URL, if known (e.g. https://example.com)") },
64
+ }, ({ site_url }) => ({ messages: [user(startPromptText(site_url))] }));
65
+ server.registerPrompt("katman-audit", {
66
+ title: "Katman: audit (P1, report only)",
67
+ description: "Audit the site like AI crawlers see it — codebase scan + live audit — and report the top 10 problems without changing code.",
68
+ argsSchema: { site_url: z.string().optional().describe("The live site URL, if known") },
69
+ }, ({ site_url }) => ({ messages: [user(auditPromptText(site_url))] }));
70
+ server.registerPrompt("katman-situation-page", {
71
+ title: "Katman: write a situation page (P6)",
72
+ description: "Brief → write → lint → verify one situation page for a customer situation from research.",
73
+ argsSchema: { situation: z.string().optional().describe("Situation id (e.g. s1) or the customer's sentence; empty = the next situation without a page") },
74
+ }, ({ situation }) => ({ messages: [user(situationPromptText(situation))] }));
75
+ server.registerPrompt("katman-measure", {
76
+ title: "Katman: measure AI visibility",
77
+ description: "Run the question set (API dry run first, or manual checks), report mention / recommendation / citation rates and schedule the next run.",
78
+ }, () => ({ messages: [user(measurePromptText())] }));
79
+ }
@@ -0,0 +1,50 @@
1
+ // MCP resources: the method, checklists, content rules, raw prompts and the current project's research.md.
2
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { resolveRoot } from "../../../context.js";
4
+ import { PROMPTS, getPrompt } from "../data/prompts.js";
5
+ import { contentRulesMd, methodOverviewMd, prepublishChecklistMd, rawPromptMd, siteAuditChecklistMd } from "../data/render.js";
6
+ import { loadConfigSafe, loadResearchSafe, loadRuns } from "../lib/state.js";
7
+ import { renderResearchMd } from "../core/research.js";
8
+ import { licenseOf, requirePro } from "../../../license.js";
9
+ const MD = "text/markdown";
10
+ function md(uri, text) {
11
+ return { contents: [{ uri: uri.href, mimeType: MD, text }] };
12
+ }
13
+ export async function researchResourceText(root) {
14
+ const research = await loadResearchSafe(root);
15
+ if (!research) {
16
+ return `# research.md\n\nNo research yet in ${root}/.katman/. Create it with the tool katman_research (mode "template"), then fill it from the codebase and the user's customer sentences.`;
17
+ }
18
+ const [runs, config] = await Promise.all([loadRuns(root), loadConfigSafe(root)]);
19
+ return renderResearchMd(research, runs, config.lang ?? "en");
20
+ }
21
+ export function registerMethodResources(server, ctx) {
22
+ server.registerResource("katman-method", "katman://method", { title: "The Katman method", description: "3 gates (Read / Quote / Fit), 4 steps, the evidence, the 6 traps, the prompt book P0–P14, the first 30 days.", mimeType: MD }, async (uri) => md(uri, methodOverviewMd("en")));
23
+ server.registerResource("katman-checklist-prepublish", "katman://checklist/prepublish", { title: "Pre-publish checklist", description: "The one-page checklist before publishing (book ch. 19), each line with the Katman check that proves it.", mimeType: MD }, async (uri) => md(uri, prepublishChecklistMd("en")));
24
+ server.registerResource("katman-checklist-site-audit", "katman://checklist/site-audit", { title: "Full site audit checklist", description: "Per-URL and site-wide checks (book appendix E) that katman_audit_site runs.", mimeType: MD }, async (uri) => md(uri, siteAuditChecklistMd("en")));
25
+ server.registerResource("katman-content-rules", "katman://content-rules", { title: "Content rules", description: "Content rules (appendix D), how content is produced without spam, the 11-block situation page anatomy, and the words katman_lint_content flags.", mimeType: MD }, async (uri) => md(uri, contentRulesMd("en")));
26
+ server.registerResource("katman-prompt", new ResourceTemplate("katman://prompts/{id}", {
27
+ list: async () => ({
28
+ resources: PROMPTS.map((p) => ({ uri: `katman://prompts/${p.id}`, name: `${p.id} ${p.title.en}`, title: `${p.id} · ${p.title.en}`, mimeType: MD })),
29
+ }),
30
+ complete: {
31
+ id: (value) => PROMPTS.map((p) => p.id).filter((id) => id.toLowerCase().startsWith((value ?? "").toLowerCase())),
32
+ },
33
+ }), { title: "Katman prompt book (P0–P14)", description: "A raw prompt with its acceptance criterion, reason and the Katman check. Use the tool katman_prompt to get it filled with this project's data.", mimeType: MD }, async (uri, variables) => {
34
+ const raw = Array.isArray(variables.id) ? variables.id[0] : variables.id;
35
+ const p = getPrompt(String(raw ?? ""));
36
+ if (!p)
37
+ return md(uri, `Unknown prompt "${raw}". Valid ids: ${PROMPTS.map((x) => x.id).join(", ")}.`);
38
+ // The prompt book is Pro content, like the katman_prompt tool: titles and acceptance criteria stay listed,
39
+ // the prompt bodies need a Pro or trial license.
40
+ const gate = await requirePro("prompts", licenseOf(ctx));
41
+ if (!gate.allowed) {
42
+ return md(uri, `# ${p.id} · ${p.title.en}\n\nThe prompt text is part of Katman Pro (needs KATMAN_KEY). Start the 7-day free trial at https://katman.pro/app, add the key to your MCP config as KATMAN_KEY, then read this resource again or call katman_prompt {"id": "${p.id}"} to get it filled with this project's data.`);
43
+ }
44
+ return md(uri, rawPromptMd(p, "en"));
45
+ });
46
+ server.registerResource("katman-research", "katman://research", { title: "This project's research.md", description: "The current project's research (brand, one sentence, not-X, situations, competitors, proof, question set, latest measurement), rendered from .katman/research.json.", mimeType: MD }, async (uri) => {
47
+ const root = await resolveRoot(ctx);
48
+ return md(uri, await researchResourceText(root));
49
+ });
50
+ }