@coppica/persuasion-mcp 1.0.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/data/LICENSE.md +8 -0
  4. package/data/catalog.json +1 -0
  5. package/data/categories.json +1 -0
  6. package/data/trait-evidence.json +187 -0
  7. package/dist/data.d.ts +81 -0
  8. package/dist/data.js +207 -0
  9. package/dist/format.d.ts +28 -0
  10. package/dist/format.js +50 -0
  11. package/dist/hookcheck/index.d.ts +98 -0
  12. package/dist/hookcheck/index.js +294 -0
  13. package/dist/http.d.ts +12 -0
  14. package/dist/http.js +62 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +5 -0
  17. package/dist/roles.d.ts +68 -0
  18. package/dist/roles.js +188 -0
  19. package/dist/schemas.d.ts +7 -0
  20. package/dist/schemas.js +13 -0
  21. package/dist/scorers/claim-framework.d.ts +52 -0
  22. package/dist/scorers/claim-framework.js +363 -0
  23. package/dist/scorers/flow-framework.d.ts +69 -0
  24. package/dist/scorers/flow-framework.js +708 -0
  25. package/dist/scorers/hook-framework.d.ts +58 -0
  26. package/dist/scorers/hook-framework.js +447 -0
  27. package/dist/scorers/string-utils.d.ts +1 -0
  28. package/dist/scorers/string-utils.js +10 -0
  29. package/dist/server.d.ts +7 -0
  30. package/dist/server.js +71 -0
  31. package/dist/stats.d.ts +22 -0
  32. package/dist/stats.js +112 -0
  33. package/dist/tools/catalog.d.ts +2 -0
  34. package/dist/tools/catalog.js +84 -0
  35. package/dist/tools/craft.d.ts +2 -0
  36. package/dist/tools/craft.js +129 -0
  37. package/dist/tools/diagnose.d.ts +5 -0
  38. package/dist/tools/diagnose.js +269 -0
  39. package/dist/tools/plan.d.ts +4 -0
  40. package/dist/tools/plan.js +99 -0
  41. package/dist/tools/testing.d.ts +2 -0
  42. package/dist/tools/testing.js +145 -0
  43. package/dist/tools/why.d.ts +2 -0
  44. package/dist/tools/why.js +63 -0
  45. package/dist/voice.d.ts +33 -0
  46. package/dist/voice.js +139 -0
  47. package/package.json +73 -0
package/dist/stats.js ADDED
@@ -0,0 +1,112 @@
1
+ /** Plain A/B test statistics. No dependencies, no network. */
2
+ /** Standard normal CDF (Abramowitz & Stegun 7.1.26 via erf, |error| < 1.5e-7). */
3
+ export function normCdf(z) {
4
+ const x = Math.abs(z) / Math.SQRT2;
5
+ const t = 1 / (1 + 0.3275911 * x);
6
+ const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
7
+ return z >= 0 ? 0.5 * (1 + y) : 0.5 * (1 - y);
8
+ }
9
+ /** Inverse standard normal CDF (Acklam's rational approximation). */
10
+ export function normInv(p) {
11
+ if (p <= 0 || p >= 1)
12
+ throw new Error('normInv: p must be in (0,1)');
13
+ const a = [-3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2, -3.066479806614716e1, 2.506628277459239];
14
+ const b = [-5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1, -1.328068155288572e1];
15
+ const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
16
+ const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
17
+ const lo = 0.02425;
18
+ if (p < lo) {
19
+ const q = Math.sqrt(-2 * Math.log(p));
20
+ return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
21
+ }
22
+ if (p > 1 - lo) {
23
+ const q = Math.sqrt(-2 * Math.log(1 - p));
24
+ return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
25
+ }
26
+ const q = p - 0.5;
27
+ const r = q * q;
28
+ return ((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
29
+ }
30
+ /** Visitors needed per version to detect a relative lift (two-sided, two-proportion z-test). */
31
+ export function sampleSizePerArm(baseline, relativeLift, alpha, power) {
32
+ const p1 = baseline;
33
+ const p2 = baseline * (1 + relativeLift);
34
+ const za = normInv(1 - alpha / 2);
35
+ const zb = normInv(power);
36
+ const pbar = (p1 + p2) / 2;
37
+ const n = (za * Math.sqrt(2 * pbar * (1 - pbar)) + zb * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2 / (p2 - p1) ** 2;
38
+ return Math.ceil(n);
39
+ }
40
+ /** Two-proportion z-test (pooled SE for the test, unpooled SE for the interval). */
41
+ export function compare(cA, nA, cB, nB, alpha) {
42
+ const rateA = cA / nA;
43
+ const rateB = cB / nB;
44
+ const diff = rateB - rateA;
45
+ const pooled = (cA + cB) / (nA + nB);
46
+ const sePooled = Math.sqrt(pooled * (1 - pooled) * (1 / nA + 1 / nB));
47
+ const z = sePooled > 0 ? diff / sePooled : 0;
48
+ const p = 2 * (1 - normCdf(Math.abs(z)));
49
+ const se = Math.sqrt((rateA * (1 - rateA)) / nA + (rateB * (1 - rateB)) / nB);
50
+ const zc = normInv(1 - alpha / 2);
51
+ return {
52
+ rateA, rateB, diff,
53
+ relLift: rateA > 0 ? diff / rateA : NaN,
54
+ z, p,
55
+ ciLow: diff - zc * se,
56
+ ciHigh: diff + zc * se,
57
+ probBBetter: se > 0 ? normCdf(diff / se) : 0.5,
58
+ };
59
+ }
60
+ /** Regularized upper incomplete gamma Q(a, x) (Numerical Recipes gammq). */
61
+ function gammq(a, x) {
62
+ if (x < 0 || a <= 0)
63
+ return NaN;
64
+ if (x === 0)
65
+ return 1;
66
+ const gln = lnGamma(a);
67
+ if (x < a + 1) {
68
+ let sum = 1 / a, del = sum, ap = a;
69
+ for (let n = 0; n < 200; n++) {
70
+ ap += 1;
71
+ del *= x / ap;
72
+ sum += del;
73
+ if (Math.abs(del) < Math.abs(sum) * 1e-12)
74
+ break;
75
+ }
76
+ return 1 - sum * Math.exp(-x + a * Math.log(x) - gln);
77
+ }
78
+ let b = x + 1 - a, c = 1 / 1e-300, d = 1 / b, h = d;
79
+ for (let i = 1; i < 200; i++) {
80
+ const an = -i * (i - a);
81
+ b += 2;
82
+ d = an * d + b;
83
+ if (Math.abs(d) < 1e-300)
84
+ d = 1e-300;
85
+ c = b + an / c;
86
+ if (Math.abs(c) < 1e-300)
87
+ c = 1e-300;
88
+ d = 1 / d;
89
+ const del = d * c;
90
+ h *= del;
91
+ if (Math.abs(del - 1) < 1e-12)
92
+ break;
93
+ }
94
+ return Math.exp(-x + a * Math.log(x) - gln) * h;
95
+ }
96
+ function lnGamma(x) {
97
+ const cof = [76.18009172947146, -86.50532032941677, 24.01409824083091, -1.231739572450155, 0.1208650973866179e-2, -0.5395239384953e-5];
98
+ let y = x, tmp = x + 5.5;
99
+ tmp -= (x + 0.5) * Math.log(tmp);
100
+ let ser = 1.000000000190015;
101
+ for (const c of cof)
102
+ ser += c / ++y;
103
+ return -tmp + Math.log((2.5066282746310005 * ser) / x);
104
+ }
105
+ /** Sample ratio mismatch: chi-square goodness of fit of observed visitors against the intended split. */
106
+ export function srmPValue(observed, intendedShares) {
107
+ const total = observed.reduce((a, b) => a + b, 0);
108
+ const shareSum = intendedShares.reduce((a, b) => a + b, 0);
109
+ let chi = 0;
110
+ observed.forEach((o, i) => { const e = (total * intendedShares[i]) / shareSum; chi += (o - e) ** 2 / e; });
111
+ return gammq((observed.length - 1) / 2, chi / 2);
112
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerCatalogTools(server: McpServer): void;
@@ -0,0 +1,84 @@
1
+ import { z } from 'zod';
2
+ import { ROLES, roleLabel } from '../roles.js';
3
+ import { searchTechniques, lookupTechnique, resolveCategory, TECHNIQUES } from '../data.js';
4
+ import { formatSchema, categorySchema, roleSchema } from '../schemas.js';
5
+ import { text, errorText, techniqueLine, usageSentence, exampleSource } from '../format.js';
6
+ import { joinList, clipText, innerQuotes } from '../voice.js';
7
+ export function registerCatalogTools(server) {
8
+ server.registerTool('find_persuasion_techniques', {
9
+ title: 'Find persuasion techniques',
10
+ description: `Search the Persuasion Taxonomy, ${TECHNIQUES.length} named persuasion techniques documented from real advertising and organized by the nine reader questions they answer. ` +
11
+ 'Describe what you want the copy to do, like build trust without testimonials, create urgency without fake scarcity, handle a price objection, make an opening less generic, ' +
12
+ 'prove a claim or reframe a problem. It finds the techniques that do it. Each one comes with its name and ID, a one-line definition, a real example, ' +
13
+ 'how common it is in a category if you name one, and a link. So reach for it when a draft relies on the obvious move and you want a less expected one, ' +
14
+ 'or when someone asks for tactics, angles, hooks or ideas.',
15
+ inputSchema: {
16
+ query: z.string().min(2).max(300).describe('What you want the copy to do, in plain words, like "prove it works without testimonials".'),
17
+ question: roleSchema.optional().describe('Set this to see only the techniques that answer one reader question.'),
18
+ format: formatSchema.optional(),
19
+ category: categorySchema.optional(),
20
+ limit: z.number().int().min(1).max(20).default(8),
21
+ },
22
+ annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
23
+ }, async ({ query, question, format, category, limit }) => {
24
+ const cat = resolveCategory(category);
25
+ const role = question ?? null;
26
+ const hits = searchTechniques(query, { role, format: format ?? null, limit });
27
+ if (!hits.length) {
28
+ return text(`Nothing matched "${query}"${role ? ` among the techniques that answer ${roleLabel(role)}` : ''}. ` +
29
+ `Describe the effect you want in plainer words${role ? ', or drop the question filter' : ''}.`);
30
+ }
31
+ const out = [`# Techniques for "${query}"${role ? `, each one a way to answer ${roleLabel(role)}` : ''}`];
32
+ for (const t of hits)
33
+ out.push(techniqueLine(t, { category: cat, after: role ? undefined : `It answers ${roleLabel(t.role)}.` }));
34
+ out.push('');
35
+ out.push('For the full entry, with how it works, when it backfires and more examples, call get_persuasion_technique with the ID.');
36
+ return text(out.join('\n'));
37
+ });
38
+ server.registerTool('get_persuasion_technique', {
39
+ title: 'Get a persuasion technique',
40
+ description: 'Look up one persuasion technique by its ID (like PT-PRV-10000), its slug or its name. You get the full definition, why it works, when it lands and when it backfires, ' +
41
+ 'real examples from advertising, related and opposite techniques, and a link you can cite. ' +
42
+ 'Use it before you apply or recommend a technique so you get it right, and whenever someone asks what a tactic is called or how it works.',
43
+ inputSchema: {
44
+ id: z.string().min(2).max(160).describe('The PT-ID, the slug or the exact name of the technique.'),
45
+ category: categorySchema.optional(),
46
+ },
47
+ annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
48
+ }, async ({ id, category }) => {
49
+ const t = lookupTechnique(id);
50
+ if (!t) {
51
+ const near = searchTechniques(id, { limit: 5 });
52
+ return errorText(`There's no technique called "${id}".${near.length ? ` The closest matches are ${joinList(near.map((n) => `${n.name} (${n.pt_id})`))}.` : ''} ` +
53
+ 'You can search the catalog with find_persuasion_techniques.');
54
+ }
55
+ const cat = resolveCategory(category);
56
+ const out = [];
57
+ out.push(`# ${t.name} (${t.pt_id})`);
58
+ out.push(`It answers ${roleLabel(t.role)}, which is about ${ROLES[t.role].gloss}. ${usageSentence(t, cat)} ${t.url}`);
59
+ out.push('');
60
+ // Catalog fields: whole sentences only (the export cuts long fields), and condition lists read as sentences.
61
+ const prose = (s) => clipText(s.replace(/;\s+(?=[A-Z])/g, '. '), 4000);
62
+ out.push(prose(t.lede));
63
+ if (t.what_it_does)
64
+ out.push(`\n**What it does.** ${prose(t.what_it_does)}`);
65
+ if (t.why_it_works)
66
+ out.push(`\n**Why it works.** ${prose(t.why_it_works)}`);
67
+ if (t.lands_when)
68
+ out.push(`\n**When it lands.** ${prose(t.lands_when)}`);
69
+ if (t.dilutes_when)
70
+ out.push(`\n**When it backfires.** ${prose(t.dilutes_when)}`);
71
+ if (t.examples.length) {
72
+ out.push('\n**Real examples**');
73
+ for (const e of t.examples) {
74
+ out.push(`- From ${exampleSource(e)}: "${innerQuotes(prose(e.headline || e.excerpt))}"${e.why ? ` ${prose(e.why)}` : ''}`);
75
+ }
76
+ }
77
+ if (t.sisters.length)
78
+ out.push(`\nRelated techniques: ${joinList(t.sisters.map((s) => `${s.name} (${s.pt_id})`))}.`);
79
+ if (t.counters.length)
80
+ out.push(`The opposite approach: ${joinList(t.counters.map((s) => `${s.name} (${s.pt_id})`))}.`);
81
+ out.push(`\nCite it as ${t.name} (${t.pt_id}), The Persuasion Taxonomy, Coppica, ${t.url}`);
82
+ return text(out.join('\n'));
83
+ });
84
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerCraftTools(server: McpServer): void;
@@ -0,0 +1,129 @@
1
+ import { z } from 'zod';
2
+ import { awarenessSchema, roleSchema } from '../schemas.js';
3
+ import { text } from '../format.js';
4
+ import { scoreClaims } from '../scorers/claim-framework.js';
5
+ import { detectAISmell } from '../scorers/hook-framework.js';
6
+ import { SLOTS, SLOT_INFO, checkHook, compareHooks, describeCheck, questionsNeeded, article, VERDICT_LABEL, EVIDENCE_SOURCE } from '../hookcheck/index.js';
7
+ import { claimGateSentence, smellSentences, joinList, plural, endSentence } from '../voice.js';
8
+ /** Unproven superlatives the claim scorer can read as "specific" (e.g. "#1"). */
9
+ const SUPERLATIVE = /(#\s?1\b|\bnumber one\b|\bthe best\b|\bbest[- ]in[- ]class\b|\bleading\b|\bworld'?s (best|first|most)\b|\btop[- ]rated\b|\bunmatched\b|\brevolutionary\b|\bgame[- ]chang)/i;
10
+ const BACKED = /\b(according to|rated by|ranked by|verified|audited|source:|by g2|by capterra|survey|study|since \d{4}|\d[\d,.]*\s?(%|customers|users|reviews|stars))/i;
11
+ const RATING = { fail: 'fails', weak: 'is weak', passable: 'is passable', strong: 'is strong', excellent: 'is excellent' };
12
+ const briefSchema = z.object({
13
+ brand: z.string().max(200).optional().describe('The brand or product name.'),
14
+ product: z.string().max(400).optional().describe("What you're selling."),
15
+ offer: z.string().max(400).optional().describe('The deal or the ask, like the price, a discount, a trial or a deadline.'),
16
+ audience: z.string().max(400).optional().describe('Who reads it: their role, their situation and what is theirs.'),
17
+ proof: z.string().max(400).optional().describe('Real proof you can point to, like customer counts, results or names.'),
18
+ }).describe('The brief. The swap test needs it to tell whether a competitor could send the line unchanged, and without it no line can pass.');
19
+ export function registerCraftTools(server) {
20
+ server.registerTool('check_headlines', {
21
+ title: 'Check and compare headlines, hooks, and subject lines',
22
+ description: 'Check headlines, email subject lines, ad openings, video hooks, social post openings and first lines, and compare options against each other. ' +
23
+ 'For each line it tells you whether the line does its job in that spot, meaning which of the nine reader questions it answers, and whether a competitor could send it unchanged. ' +
24
+ 'It also shows exactly what a reader sees before the cutoff, flags anything that fails outright, and brings in evidence from thousands of real headline tests. ' +
25
+ 'Then it tells you what each option is betting on and which two to test. Before you call it, list the reader questions each line answers and quote the exact words doing the work. ' +
26
+ 'Use it whenever you write headlines, hooks or subject lines, whenever you have to choose between them, and whenever someone asks "which one is best?" ' +
27
+ "You won't get a score out of ten. Nobody can predict a winner from the words alone, and that includes this tool.",
28
+ inputSchema: {
29
+ slot: z.enum(SLOTS).describe(`Where the lines will appear: ${SLOTS.join(', ')}. Each spot has its own job, and the check tells you what it is.`),
30
+ lines: z.array(z.object({
31
+ text: z.string().min(2).max(500).describe('The line, exactly as written.'),
32
+ answers: z.array(z.object({
33
+ question: roleSchema,
34
+ words: z.string().min(1).max(200).describe('The exact words in the line that answer it.'),
35
+ })).max(9).describe('The reader questions this line answers, each with the exact words that do it. Leave it empty if the line answers none.'),
36
+ })).min(1).max(20),
37
+ brief: briefSchema.optional(),
38
+ follows: z.string().max(600).optional().describe('What the reader saw just before this line, like the ad before a landing page headline, or the headline or subject before a first line.'),
39
+ awareness_level: awarenessSchema.optional(),
40
+ },
41
+ annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
42
+ }, async ({ slot, lines, brief, follows, awareness_level }) => {
43
+ const info = SLOT_INFO[slot];
44
+ const names = lines.map((_, i) => (lines.length > 1 ? String.fromCharCode(65 + i) : 'This line'));
45
+ const checks = lines.map((l) => checkHook({
46
+ text: l.text, slot, brief, follows, awareness: awareness_level,
47
+ answers: l.answers.map((a) => ({ question: a.question, words: a.words })),
48
+ }));
49
+ const out = [];
50
+ out.push(`# ${lines.length > 1 ? `${lines.length} ${info.label}s` : `One ${info.label}`}, checked`);
51
+ out.push(`The job of ${article(info.label)} is to ${info.job}. ${endSentence(`To do that, it has to answer ${questionsNeeded(checks[0].needs)}`)}` +
52
+ `${info.visibleNote ? ` Keep in mind that ${info.visibleNote}.` : ''}`);
53
+ if (lines.length > 1) {
54
+ out.push('');
55
+ out.push('## Which to use');
56
+ out.push(compareHooks(checks, names));
57
+ }
58
+ checks.forEach((c, i) => {
59
+ out.push('');
60
+ out.push(`## ${lines.length > 1 ? `${names[i]}. ` : ''}"${c.text}" ${VERDICT_LABEL[c.verdict]}`);
61
+ const parts = [describeCheck(c)];
62
+ if (c.visible && c.visible !== c.text && !c.warnings.some((w) => w.includes(c.visible)))
63
+ parts.push(`What readers actually see is "${c.visible}".`);
64
+ if (c.unverified.length) {
65
+ parts.push(`${c.unverified.length === 1 ? "One label didn't" : "Some labels didn't"} count, because the quoted words aren't in the line: ` +
66
+ `${joinList(c.unverified.map((a) => `"${a.words}" for ${a.question}`))}.`);
67
+ }
68
+ out.push(parts.join(' '));
69
+ out.push(c.fix.startsWith('Nothing') ? c.fix : `Here's the fix. ${c.fix}`);
70
+ });
71
+ out.push('');
72
+ if (checks.some((c) => c.evidence.length))
73
+ out.push(EVIDENCE_SOURCE);
74
+ out.push('Nobody can pick the winner from the words alone, and that includes this check. To find out, test two lines that make different bets, and size the test with plan_ab_test.');
75
+ return text(out.join('\n'));
76
+ });
77
+ server.registerTool('check_marketing_claims', {
78
+ title: 'Check marketing claims and CTAs',
79
+ description: 'Check whether marketing claims and calls to action are specific enough to believe. It catches the vague superlative, like "the best" or "#1", that readers discount on sight. ' +
80
+ 'It catches promises with no proof behind them and results with no mechanism, and it flags the generic buttons everyone uses, like "Learn more" and "Get started". ' +
81
+ 'Then it tells you how to fix each one. Use it when you write or review claims like "results in days", "trusted by thousands" or "the #1 tool", and for any button text. ' +
82
+ 'For headlines, check_headlines does the full job.',
83
+ inputSchema: {
84
+ claims: z.array(z.object({
85
+ text: z.string().min(2).max(600).describe('The claim or the button text, exactly as written.'),
86
+ type: z.enum(['headline', 'cta', 'body_claim']).describe('Use headline, cta for a button or call to action, or body_claim for a claim inside the copy.'),
87
+ context: z.string().max(1500).optional().describe('For a claim in the body, the paragraph around it, so a mechanism named nearby still counts.'),
88
+ })).min(1).max(30),
89
+ },
90
+ annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
91
+ }, async ({ claims }) => {
92
+ const out = [`# ${plural(claims.length, 'claim')}, checked`];
93
+ // Headline claims go through the hook check's facts; the old headline scorer did not predict winners.
94
+ const others = claims.filter((c) => c.type !== 'headline');
95
+ const scored = scoreClaims(others.map((c) => ({ text: c.text, type: c.type, context: c.context })));
96
+ let k = 0;
97
+ for (const c of claims) {
98
+ out.push('');
99
+ if (c.type === 'headline') {
100
+ const h = checkHook({ text: c.text, slot: 'landing_page_headline', answers: [] });
101
+ const issues = [...h.hardFails, ...h.warnings, ...h.evidenceAgainst];
102
+ out.push(`**"${c.text}"** (a headline). ${issues.length ? issues.join(' ') : 'The claim itself has no problems.'} ` +
103
+ 'To find out whether it does its job, and whether a competitor could send it, run check_headlines with the brief.');
104
+ continue;
105
+ }
106
+ const r = scored[k++];
107
+ const type = c.type === 'cta' ? 'cta' : 'body_claim';
108
+ const notes = [];
109
+ const stock = r.gates.some((g) => g.name === 'generic_button_text' && g.status === 'fail');
110
+ if (SUPERLATIVE.test(c.text) && !BACKED.test(c.text)) {
111
+ notes.push('It leans on an unproven superlative. Claims like "#1", "the best" and "leading" read as puffery unless the proof is in the same line, ' +
112
+ 'meaning who ranked it and by what measure, so name the source or swap it for a specific fact a reader could check.');
113
+ }
114
+ // A stock button gets one sentence about naming what the reader gets, not two.
115
+ for (const g of r.gates.filter((g) => g.status !== 'pass' && !(stock && g.name === 'first_person_or_outcome')).slice(0, 3)) {
116
+ const s = g.name === 'anti_smell' ? smellSentences(detectAISmell(c.text).violations).join(' ') : claimGateSentence(g.name, c.text, type);
117
+ if (s)
118
+ notes.push(s);
119
+ }
120
+ const label = r.scoreLabel;
121
+ const lead = `**"${r.text}"** (${type === 'cta' ? 'a button' : 'a claim in the body'}) ${RATING[label] ?? label}.`;
122
+ const bridge = notes.length && (label === 'strong' || label === 'excellent') ? (notes.length === 1 ? ' One thing would make it stronger.' : ' A few things would make it stronger.') : '';
123
+ out.push(`${lead}${bridge} ${notes.length ? notes.join(' ') : 'Nothing in it needs fixing.'}`);
124
+ }
125
+ out.push('');
126
+ out.push("A claim persuades when it's specific, it's true and the proof sits right next to it. For ways to prove a claim, search find_persuasion_techniques with the question set to PROVE.");
127
+ return text(out.join('\n'));
128
+ });
129
+ }
@@ -0,0 +1,5 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type Format } from '../roles.js';
3
+ import { type Slot } from '../hookcheck/index.js';
4
+ export declare const OPENING_SLOT: Record<Format, Slot>;
5
+ export declare function registerDiagnoseTool(server: McpServer): void;
@@ -0,0 +1,269 @@
1
+ import { z } from 'zod';
2
+ import { ROLE_NAMES, ROLES, GOAL_REQUIREMENTS, GOAL_NOUN, GOAL_CLAUSE, FORMAT_LABEL, COHERENCE_CHECKS, expectedRoles, roleLabel } from '../roles.js';
3
+ import { pickMoves, resolveCategory } from '../data.js';
4
+ import { goalSchema, formatSchema, awarenessSchema, categorySchema, roleSchema, modeSchema } from '../schemas.js';
5
+ import { text, techniqueLine } from '../format.js';
6
+ import { scoreFlow } from '../scorers/flow-framework.js';
7
+ import { checkHook, describeCheck, VERDICT_LABEL } from '../hookcheck/index.js';
8
+ import { flowGateSentence, joinList, plural } from '../voice.js';
9
+ export const OPENING_SLOT = {
10
+ ad: 'ad_opening', landing_page: 'landing_page_headline', sales_letter: 'sales_letter_headline', email: 'first_line',
11
+ email_subject: 'email_subject', social_post: 'social_post_open', video_script: 'video_hook', headline: 'landing_page_headline',
12
+ };
13
+ /** Long-form pages: score the prose, not the scaffolding. Headings, buttons, placeholders and
14
+ * short label lines otherwise count as sentences and wreck the rhythm checks. */
15
+ function proseOnly(copy, format) {
16
+ if (format !== 'landing_page' && format !== 'sales_letter')
17
+ return { prose: copy, skipped: 0 };
18
+ let skipped = 0;
19
+ const kept = copy.split('\n').filter((raw) => {
20
+ const l = raw.trim();
21
+ if (!l)
22
+ return true;
23
+ const words = l.replace(/[*_#>\-\[\]()]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
24
+ const scaffold = /^#{1,6}\s/.test(l) || /^-{3,}$/.test(l) || /^\[.*\]$/.test(l) || /^\*\*\[.*\]\*\*$/.test(l) ||
25
+ (words <= 6 && !/[.!]$/.test(l.replace(/\*+$/, '')));
26
+ if (scaffold)
27
+ skipped++;
28
+ return !scaffold;
29
+ }).map((l) => l.replace(/^\s*[-*>]\s+/, '').replace(/\*\*/g, ''));
30
+ const prose = kept.join('\n');
31
+ return prose.trim().split(/\s+/).length >= 60 ? { prose, skipped } : { prose: copy, skipped: 0 };
32
+ }
33
+ function flowContext(format, words) {
34
+ switch (format) {
35
+ case 'ad':
36
+ case 'social_post': return 'ad';
37
+ case 'email': return 'email';
38
+ case 'email_subject': return 'subject';
39
+ case 'headline': return 'header';
40
+ case 'sales_letter': return 'long_form';
41
+ default: return words > 1500 ? 'long_form' : 'body';
42
+ }
43
+ }
44
+ const normText = (s) => s.toLowerCase().replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim();
45
+ const STATUS_RANK = { missing: 0, partial: 1, clear: 2 };
46
+ export function registerDiagnoseTool(server) {
47
+ server.registerTool('diagnose_marketing_copy', {
48
+ title: 'Diagnose marketing copy (roast / review)',
49
+ description: "Review, critique or roast any piece of marketing copy, whether it's an ad, a landing page, an email, a sales page, a social post or a script, and find out why it might not persuade. " +
50
+ 'Before you call it, read the copy and label each line with the one reader question it mainly answers, quoting the words exactly. Those labels go in the moves array. ' +
51
+ 'You get back which of the nine questions the copy answers for its goal, which it answers weakly and which it skips, along with the biggest gap and what readers do when it is missing. ' +
52
+ 'It also checks the order, whether the answers agree with each other, and the craft, pointing to the exact words that read as machine-written, like em dashes, ' +
53
+ '"it\'s not X, it\'s Y" lines and sentences that all run the same length. Then it checks the opening line and suggests techniques from the catalog to fill the gap. ' +
54
+ "Use it before you show anyone a draft you wrote, and whenever someone asks \"is this good?\", \"roast this\", \"what's wrong with this copy?\" or \"how do I make it better?\"",
55
+ inputSchema: {
56
+ copy: z.string().min(10).max(30000).describe('The full copy, exactly as written.'),
57
+ goal: goalSchema,
58
+ format: formatSchema,
59
+ moves: z.array(z.object({
60
+ excerpt: z.string().min(2).max(400).describe('An exact quote from the copy, whether a sentence, a line or a short passage.'),
61
+ question: roleSchema,
62
+ clarity: z.enum(['clear', 'partial']).default('clear').describe('Use clear when the line answers the question well, and partial when it only gestures at it.'),
63
+ })).min(1).max(80).describe('Your line-by-line labels. Quote each persuasive line exactly and name the one reader question it mainly answers. Skip lines that are pure filler.'),
64
+ awareness_level: awarenessSchema.optional(),
65
+ category: categorySchema.optional(),
66
+ headline: z.string().max(300).optional().describe("The headline or opening line, if it isn't the first line of the copy."),
67
+ persuasion_mode: modeSchema,
68
+ brief: z.object({
69
+ brand: z.string().max(200).optional(),
70
+ product: z.string().max(400).optional(),
71
+ offer: z.string().max(400).optional(),
72
+ audience: z.string().max(400).optional(),
73
+ proof: z.string().max(400).optional(),
74
+ }).optional().describe('The brief, if you have it: the brand, product, offer, audience and proof. With it, the opening line gets the swap test, which asks whether a competitor could send it unchanged.'),
75
+ },
76
+ annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
77
+ }, async ({ copy, goal, format, moves, awareness_level, category, headline, persuasion_mode, brief }) => {
78
+ const cat = resolveCategory(category);
79
+ const expected = expectedRoles(goal);
80
+ const normCopy = normText(copy);
81
+ const goalNoun = GOAL_NOUN[goal];
82
+ // where each labeled move sits in the copy (falls back to the order given)
83
+ let unmatched = 0;
84
+ const placed = moves.map((m, i) => {
85
+ const ex = normText(m.excerpt);
86
+ let pos = normCopy.indexOf(ex);
87
+ if (pos < 0 && ex.length > 40)
88
+ pos = normCopy.indexOf(ex.slice(0, 40));
89
+ if (pos < 0)
90
+ unmatched++;
91
+ return { ...m, question: m.question, pos: pos < 0 ? Number.MAX_SAFE_INTEGER - (moves.length - i) : pos };
92
+ });
93
+ const ordered = unmatched > moves.length / 2 ? placed : [...placed].sort((a, b) => a.pos - b.pos);
94
+ const status = new Map();
95
+ const count = new Map();
96
+ for (const r of ROLE_NAMES) {
97
+ status.set(r, 'missing');
98
+ count.set(r, 0);
99
+ }
100
+ for (const m of placed) {
101
+ count.set(m.question, (count.get(m.question) ?? 0) + 1);
102
+ const s = m.clarity === 'clear' ? 'clear' : 'partial';
103
+ if (STATUS_RANK[s] > STATUS_RANK[status.get(m.question)])
104
+ status.set(m.question, s);
105
+ }
106
+ const reqs = GOAL_REQUIREMENTS[goal];
107
+ const reqStatus = reqs.map((req) => req.reduce((best, r) => (STATUS_RANK[status.get(r)] > STATUS_RANK[best] ? status.get(r) : best), 'missing'));
108
+ const clearN = reqStatus.filter((s) => s === 'clear').length;
109
+ const partialN = reqStatus.filter((s) => s === 'partial').length;
110
+ const out = [];
111
+ out.push(`# Diagnosis: ${FORMAT_LABEL[format]} that ${GOAL_CLAUSE[goal]}`);
112
+ out.push(`It clearly answers ${clearN} of the ${reqs.length} questions ${goalNoun} needs${partialN ? `, and ${partialN} more only weakly` : ''}. ` +
113
+ "That tells you what the copy covers. It can't tell you how the copy will convert, and only a test can.");
114
+ if (unmatched) {
115
+ out.push(`${unmatched} of the ${plural(moves.length, 'line')} you labeled ${unmatched === 1 ? "doesn't" : "don't"} appear word for word in the copy` +
116
+ `${unmatched > moves.length / 2 ? ', so the order checks follow the order you gave them' : ''}. Quote the copy exactly and the read gets sharper.`);
117
+ }
118
+ out.push('');
119
+ out.push('## The nine questions');
120
+ for (const r of ROLE_NAMES) {
121
+ const s = status.get(r);
122
+ const n = count.get(r);
123
+ const lines = n ? `, in ${plural(n, 'line')}` : '';
124
+ let label;
125
+ if (expected.has(r))
126
+ label = { clear: `Answered clearly${lines}`, partial: `Answered, but only weakly${lines}`, missing: 'Not answered' }[s];
127
+ else
128
+ label = s === 'missing' ? `Not needed for ${goalNoun}` : `${s === 'clear' ? 'Answered clearly' : 'Answered weakly'}${lines}, though ${goalNoun} can do without it`;
129
+ out.push(`${ROLES[r].num}. ${ROLES[r].question} (${r}) ${label}.`);
130
+ }
131
+ // biggest gap: first unmet requirement in canonical order; else first partial; else upgrade
132
+ const idxMissing = reqStatus.findIndex((s) => s === 'missing');
133
+ const idxPartial = reqStatus.findIndex((s) => s === 'partial');
134
+ let gapRole;
135
+ let mode;
136
+ if (idxMissing >= 0) {
137
+ gapRole = reqs[idxMissing][0];
138
+ mode = 'fix';
139
+ }
140
+ else if (idxPartial >= 0) {
141
+ gapRole = reqs[idxPartial][0];
142
+ mode = 'strengthen';
143
+ }
144
+ else {
145
+ gapRole = [...expected].sort((a, b) => (count.get(a) - count.get(b)) || ROLES[a].num - ROLES[b].num)[0];
146
+ mode = 'upgrade';
147
+ }
148
+ const gi = ROLES[gapRole];
149
+ out.push('');
150
+ if (mode === 'upgrade') {
151
+ out.push('## Every question it needs is answered');
152
+ out.push(`The thinnest answer is ${roleLabel(gapRole)}. Deepen it with a less expected move and the copy stops reading like the category default. Here are three ways to do it.`);
153
+ }
154
+ else {
155
+ const alt = reqs[mode === 'fix' ? idxMissing : idxPartial].slice(1);
156
+ out.push(`## ${mode === 'fix' ? 'The biggest gap' : 'The weakest answer'}: ${roleLabel(gapRole)}`);
157
+ out.push(`${mode === 'strengthen' ? "It's answered, but only weakly. " : ''}Leave it unanswered and this is what happens: "${gi.ifMissing}" Right now, ${gi.gap}.` +
158
+ `${alt.length ? ` Answering ${joinList(alt.map((r) => `"${ROLES[r].question}"`), 'or')} would also do.` : ''} Here are three ways to answer it.`);
159
+ }
160
+ for (const p of pickMoves(gapRole, { n: 3, format, category: cat, seed: copy.slice(0, 500), context: copy.slice(0, 4000), mode: persuasion_mode })) {
161
+ out.push(techniqueLine(p.t, { category: cat, kind: p.kind === 'established' ? 'established' : 'less expected' }));
162
+ }
163
+ // order
164
+ const orderNotes = [];
165
+ const first = ordered[0];
166
+ const lateAwareness = ['product_aware', 'most_aware'];
167
+ if (first && !['DISRUPT', 'IDENTIFY'].includes(first.question) && !(awareness_level && lateAwareness.includes(awareness_level))) {
168
+ orderNotes.push(`It opens by answering "${ROLES[first.question].question}" before it has earned attention or shown readers it's for them. ` +
169
+ `Unless they already know the product, open with "${ROLES.DISRUPT.question}" or "${ROLES.IDENTIFY.question}"`);
170
+ }
171
+ if (goal === 'purchase' || goal === 'signup') {
172
+ const firstCompel = ordered.findIndex((m) => m.question === 'COMPEL');
173
+ const firstProve = ordered.findIndex((m) => m.question === 'PROVE');
174
+ if (firstCompel >= 0 && (firstProve < 0 || firstProve > firstCompel))
175
+ orderNotes.push('It asks for action before anything has earned belief. Move the proof ahead of the first ask.');
176
+ }
177
+ if (goal === 'purchase') {
178
+ const lastCompel = ordered.map((m) => m.question).lastIndexOf('COMPEL');
179
+ const resolves = ordered.map((m, i) => (m.question === 'RESOLVE' ? i : -1)).filter((i) => i >= 0);
180
+ if (lastCompel >= 0 && resolves.length && resolves.every((i) => i > lastCompel)) {
181
+ orderNotes.push('It handles the objections after the final ask, so readers reach the ask with their doubts still standing, and that is where they leave. Answer the objections before you close.');
182
+ }
183
+ }
184
+ out.push('');
185
+ out.push('## The order');
186
+ out.push(orderNotes.length ? orderNotes.join(' ') : 'The order works for this goal.');
187
+ // coherence
188
+ const present = (r) => status.get(r) !== 'missing';
189
+ const flags = [];
190
+ if (present('ELEVATE') && !present('PROVE'))
191
+ flags.push("It promises a better life after, but there's no proof, and readers discount any promise they have no reason to believe.");
192
+ if (present('COMPEL') && !present('AGITATE') && !present('ELEVATE'))
193
+ flags.push("It pushes for action now without showing what's at stake, and urgency without stakes reads as pressure.");
194
+ if (present('DISRUPT') && goal !== 'click' && !present('PROVE') && expected.has('PROVE'))
195
+ flags.push("The opening makes a bold move that nothing later backs up, and a hook the body can't support reads as bait.");
196
+ const checks = COHERENCE_CHECKS.filter((c) => present(c.roles[0]) && present(c.roles[1])).slice(0, 4);
197
+ out.push('');
198
+ out.push('## Do the answers agree?');
199
+ if (flags.length)
200
+ out.push(flags.join(' '));
201
+ if (checks.length) {
202
+ out.push(`${flags.length ? 'Beyond that, ask' : 'Nothing contradicts outright, but ask'} yourself these questions before you finish.`);
203
+ for (const c of checks)
204
+ out.push(`- ${c.check}`);
205
+ }
206
+ if (!flags.length && !checks.length)
207
+ out.push('Too few questions are answered yet to check them against each other.');
208
+ // craft + AI tells
209
+ const words = copy.trim().split(/\s+/).length;
210
+ const { prose, skipped } = proseOnly(copy, format);
211
+ const flow = scoreFlow(prose, { context: flowContext(format, words) });
212
+ out.push('');
213
+ out.push(`## How it reads: ${flow.score.toFixed(1)} out of 10, ${flow.scoreLabel}`);
214
+ if (skipped)
215
+ out.push(`This scores the prose only, so it skipped ${plural(skipped, 'heading, button, placeholder or label line', 'headings, buttons, placeholders and label lines')}.`);
216
+ const spans = (pattern) => flow.violations.filter((v) => v.pattern === pattern && v.span && v.span.length <= 160).map((v) => v.span);
217
+ const failing = [...flow.gates.filter((g) => g.status === 'fail'), ...flow.gates.filter((g) => g.status === 'warn')].slice(0, 6);
218
+ for (const g of failing) {
219
+ let s = flowGateSentence(g);
220
+ if (!s)
221
+ continue;
222
+ if (g.name === 'negative_parallelism_cap') {
223
+ const sp = spans('negative_parallelism').slice(0, 2);
224
+ if (sp.length)
225
+ s += ` Look at ${joinList(sp.map((x) => `"${x}"`))}.`;
226
+ }
227
+ if (g.name === 'closing_recap') {
228
+ const [sp] = spans('closing_recap');
229
+ if (sp)
230
+ s += ` It starts with "${sp}".`;
231
+ }
232
+ out.push(`- ${s}`);
233
+ }
234
+ if (!failing.length)
235
+ out.push('The craft is clean, with no machine-written tells.');
236
+ // headline
237
+ const hl = (headline ?? copy.split('\n').map((l) => l.trim().replace(/^#+\s*/, '').replace(/\*\*/g, '')).find((l) => l.length > 0) ?? '').slice(0, 300);
238
+ if (hl) {
239
+ const nh = normText(hl);
240
+ const answers = placed.flatMap((m) => {
241
+ const ne = normText(m.excerpt);
242
+ if (nh.includes(ne))
243
+ return [{ question: m.question, words: m.excerpt }];
244
+ if (ne.includes(nh))
245
+ return [{ question: m.question, words: hl }];
246
+ return [];
247
+ });
248
+ const hc = checkHook({ text: hl, slot: OPENING_SLOT[format], answers, brief, context: copy.replace(hl, ''), awareness: awareness_level });
249
+ out.push('');
250
+ out.push(`## The opening line ${VERDICT_LABEL[hc.verdict]}`);
251
+ out.push(`"${hl}"`);
252
+ out.push(describeCheck(hc, { evidence: false }));
253
+ if (hc.evidence.length)
254
+ out.push(`${hc.evidence.join(' ')} Those figures come from thousands of real headline tests on news stories, so treat them as a nudge.`);
255
+ out.push(hc.fix.startsWith('Nothing') ? hc.fix : `Here's the fix. ${hc.fix}`);
256
+ }
257
+ const steps = [`Start by ${mode === 'upgrade' ? 'deepening' : 'answering'} "${gi.question}" with one of the moves above.`];
258
+ if (orderNotes.length)
259
+ steps.push('Then fix the order.');
260
+ if (failing.length)
261
+ steps.push(`${orderNotes.length ? 'After that, fix' : 'Then fix'} the first problem in how it reads.`);
262
+ steps.push("One pass is usually enough, so only check again if you changed the structure, and don't chase the craft score.");
263
+ out.push('');
264
+ out.push('## What to do next');
265
+ out.push(steps.join(' '));
266
+ out.push('When you want to know what actually converts, test two different answers to the same question, and size the test with plan_ab_test.');
267
+ return text(out.join('\n'));
268
+ });
269
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type Role } from '../roles.js';
3
+ export declare function registerPlanTool(server: McpServer): void;
4
+ export type { Role };