@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/data/LICENSE.md +8 -0
- package/data/catalog.json +1 -0
- package/data/categories.json +1 -0
- package/data/trait-evidence.json +187 -0
- package/dist/data.d.ts +81 -0
- package/dist/data.js +207 -0
- package/dist/format.d.ts +28 -0
- package/dist/format.js +50 -0
- package/dist/hookcheck/index.d.ts +98 -0
- package/dist/hookcheck/index.js +294 -0
- package/dist/http.d.ts +12 -0
- package/dist/http.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/roles.d.ts +68 -0
- package/dist/roles.js +188 -0
- package/dist/schemas.d.ts +7 -0
- package/dist/schemas.js +13 -0
- package/dist/scorers/claim-framework.d.ts +52 -0
- package/dist/scorers/claim-framework.js +363 -0
- package/dist/scorers/flow-framework.d.ts +69 -0
- package/dist/scorers/flow-framework.js +708 -0
- package/dist/scorers/hook-framework.d.ts +58 -0
- package/dist/scorers/hook-framework.js +447 -0
- package/dist/scorers/string-utils.d.ts +1 -0
- package/dist/scorers/string-utils.js +10 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +71 -0
- package/dist/stats.d.ts +22 -0
- package/dist/stats.js +112 -0
- package/dist/tools/catalog.d.ts +2 -0
- package/dist/tools/catalog.js +84 -0
- package/dist/tools/craft.d.ts +2 -0
- package/dist/tools/craft.js +129 -0
- package/dist/tools/diagnose.d.ts +5 -0
- package/dist/tools/diagnose.js +269 -0
- package/dist/tools/plan.d.ts +4 -0
- package/dist/tools/plan.js +99 -0
- package/dist/tools/testing.d.ts +2 -0
- package/dist/tools/testing.js +145 -0
- package/dist/tools/why.d.ts +2 -0
- package/dist/tools/why.js +63 -0
- package/dist/voice.d.ts +33 -0
- package/dist/voice.js +139 -0
- package/package.json +73 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ROLE_NAMES, ROLES, GOAL_REQUIREMENTS, GOAL_NOUN, GOAL_CLAUSE, FORMAT_LABEL, AWARENESS_WEIGHT, COHERENCE_CHECKS, expectedRoles, roleLabel } from '../roles.js';
|
|
3
|
+
import { pickMoves, resolveCategory } from '../data.js';
|
|
4
|
+
import { goalSchema, formatSchema, awarenessSchema, categorySchema, modeSchema } from '../schemas.js';
|
|
5
|
+
import { text, techniqueLine } from '../format.js';
|
|
6
|
+
import { cap, joinList } from '../voice.js';
|
|
7
|
+
const NUM = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
|
|
8
|
+
export function registerPlanTool(server) {
|
|
9
|
+
server.registerTool('plan_marketing_copy', {
|
|
10
|
+
title: 'Plan marketing copy (the nine reader questions)',
|
|
11
|
+
description: "Plan any piece of marketing copy before you write it, whether it's an ad, a landing page, a sales page or sales letter, an email, a subject line, a social post, a headline or a video script. " +
|
|
12
|
+
'Give it the goal, the reader and the offer, and it tells you which of the nine questions every reader silently asks this piece has to answer, which ones deserve the most words for this reader, ' +
|
|
13
|
+
'and a few ways to answer each one, drawn from real advertising and linked to the Persuasion Taxonomy. One option is always the established move and the rest are less expected, ' +
|
|
14
|
+
'because left to itself a model answers every question with the move everyone else makes, and readers have learned to skim past it. ' +
|
|
15
|
+
"So call it before you draft, even when you're sure you know how to write the piece, and when the draft is done, check it with diagnose_marketing_copy.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
goal: goalSchema,
|
|
18
|
+
format: formatSchema,
|
|
19
|
+
audience: z.string().min(2).max(600).describe('Who will read it, as specifically as you can put it: their role, their situation and what they already believe.'),
|
|
20
|
+
offer: z.string().min(2).max(600).describe("What you're selling or asking for, and the main promise."),
|
|
21
|
+
awareness_level: awarenessSchema.optional(),
|
|
22
|
+
category: categorySchema.optional(),
|
|
23
|
+
persuasion_mode: modeSchema,
|
|
24
|
+
options_per_question: z.number().int().min(2).max(6).default(4).describe('How many ways to answer each question you want to see. The default is 4.'),
|
|
25
|
+
},
|
|
26
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
27
|
+
}, async ({ goal, format, audience, offer, awareness_level, category, persuasion_mode, options_per_question }) => {
|
|
28
|
+
const cat = resolveCategory(category);
|
|
29
|
+
const awareness = awareness_level ?? 'problem_aware';
|
|
30
|
+
const weight = AWARENESS_WEIGHT[awareness];
|
|
31
|
+
const expected = expectedRoles(goal);
|
|
32
|
+
const reqs = GOAL_REQUIREMENTS[goal];
|
|
33
|
+
const seed = `${goal}|${format}|${audience}|${offer}|${cat ?? ''}`;
|
|
34
|
+
const context = `${audience} ${offer}`;
|
|
35
|
+
const mode = persuasion_mode;
|
|
36
|
+
const used = new Set();
|
|
37
|
+
const piece = FORMAT_LABEL[format].replace(/^an? /, '');
|
|
38
|
+
const lines = [];
|
|
39
|
+
lines.push(`# Copy plan: ${FORMAT_LABEL[format]} that ${GOAL_CLAUSE[goal]}`);
|
|
40
|
+
lines.push(`The reader: ${audience}`);
|
|
41
|
+
lines.push(`The offer: ${offer}`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push(awareness_level
|
|
44
|
+
? weight.note
|
|
45
|
+
: `${weight.note} This plan assumes the reader is at that stage, so pass awareness_level if you know otherwise.`);
|
|
46
|
+
if (category && !cat)
|
|
47
|
+
lines.push(`There isn't enough data on "${category}" to compare against, so this plan skips the category comparison.`);
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push(`Every reader is silently asking nine questions. For ${GOAL_NOUN[goal]}, this ${piece} has to answer ${reqs.length === 9 ? 'all nine' : `${NUM[reqs.length]} of them`}, ` +
|
|
50
|
+
'roughly in the order below, and the ones marked as a priority deserve the most words for this reader.');
|
|
51
|
+
lines.push(`Under each question you'll find a few ways to answer it, ranked for this brief in ${mode} mode. Pick the one that fits this reader and this offer best, ` +
|
|
52
|
+
"and make sure at least one of your picks is a less expected move, because that's where the copy stops sounding like everyone else in the category. " +
|
|
53
|
+
'To see real examples of a technique, and when it backfires, call get_persuasion_technique with its ID.');
|
|
54
|
+
const either = reqs.filter((req) => req.length > 1);
|
|
55
|
+
for (const role of ROLE_NAMES) {
|
|
56
|
+
if (!expected.has(role))
|
|
57
|
+
continue;
|
|
58
|
+
const info = ROLES[role];
|
|
59
|
+
const alt = either.find((req) => req.includes(role));
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(`## ${info.num}. ${info.question} (${role})`);
|
|
62
|
+
let body = `Leave it unanswered and this is what happens: "${info.ifMissing}"`;
|
|
63
|
+
if (weight.heavy.includes(role))
|
|
64
|
+
body += ' This is a priority for this reader, so give it more words than most.';
|
|
65
|
+
if (alt)
|
|
66
|
+
body += ` You can answer this one or ${joinList(alt.filter((r) => r !== role).map((r) => `"${ROLES[r].question}"`), 'or')}, and one of them is enough.`;
|
|
67
|
+
lines.push(body);
|
|
68
|
+
const picks = pickMoves(role, { n: options_per_question, format, category: cat, seed, context, mode, exclude: used });
|
|
69
|
+
for (const p of picks) {
|
|
70
|
+
used.add(p.t.slug);
|
|
71
|
+
lines.push(techniqueLine(p.t, { category: cat, kind: p.kind === 'established' ? 'established' : 'less expected', example: false }));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const optional = ROLE_NAMES.filter((r) => !expected.has(r));
|
|
75
|
+
if (optional.length) {
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push('## The other questions');
|
|
78
|
+
lines.push(`${cap(GOAL_NOUN[goal])} doesn't strictly need these, but one well-placed line can still help.`);
|
|
79
|
+
for (const role of optional) {
|
|
80
|
+
const [p] = pickMoves(role, { n: 1, format, category: cat, seed, context, mode, exclude: used });
|
|
81
|
+
lines.push(`- ${roleLabel(role)}${p ? ` One way in is **${p.t.name}** (${p.t.pt_id}). ${p.t.url}` : ''}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const checks = COHERENCE_CHECKS.filter((c) => expected.has(c.roles[0]) && expected.has(c.roles[1])).slice(0, 4);
|
|
85
|
+
if (checks.length) {
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push('## Make the answers agree');
|
|
88
|
+
lines.push("Readers hold each answer up against the others. When two don't match, they can't always say why, but they feel that something is off, " +
|
|
89
|
+
'and everything else loses weight. Before you finish, ask yourself these questions.');
|
|
90
|
+
for (const c of checks)
|
|
91
|
+
lines.push(`- ${c.check}`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push('## Then write it');
|
|
95
|
+
lines.push("Choose one move for each question. The less expected choices are where the copy stops sounding like everyone else, so don't play every one safe, " +
|
|
96
|
+
'and keep every claim true. When the draft is done, label each line with the question it answers and run diagnose_marketing_copy before you show it to anyone.');
|
|
97
|
+
return text(lines.join('\n'));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ROLES } from '../roles.js';
|
|
3
|
+
import { roleSchema } from '../schemas.js';
|
|
4
|
+
import { text, errorText } from '../format.js';
|
|
5
|
+
import { sampleSizePerArm, compare, srmPValue } from '../stats.js';
|
|
6
|
+
import { joinList, plural } from '../voice.js';
|
|
7
|
+
const pct = (v, d = 2) => `${(v * 100).toFixed(d)}%`;
|
|
8
|
+
const points = (v) => `${(v * 100).toFixed(2)}`;
|
|
9
|
+
const fmt = (n) => n.toLocaleString('en-US');
|
|
10
|
+
const pValue = (p) => (p < 0.0001 ? 'below 0.0001' : p.toFixed(4));
|
|
11
|
+
export function registerTestingTools(server) {
|
|
12
|
+
server.registerTool('plan_ab_test', {
|
|
13
|
+
title: 'Plan an A/B test',
|
|
14
|
+
description: 'Plan an A/B test for marketing copy. Give it the current conversion rate, the smallest lift worth detecting and the daily traffic, ' +
|
|
15
|
+
'and it tells you how many visitors each version needs and how many days to run. It also frames the test as two different answers to the same reader question, ' +
|
|
16
|
+
'so the result teaches you something you can reuse, and not just that "B won". ' +
|
|
17
|
+
'Use it when someone asks how long to run a test, how much traffic they need, whether a test is worth running, or what to test next.',
|
|
18
|
+
inputSchema: {
|
|
19
|
+
baseline_conversion_rate_percent: z.number().positive().lt(100).describe('The current conversion rate, in percent, so 3 means 3% and 0.8 means 0.8%.'),
|
|
20
|
+
minimum_detectable_lift_percent: z.number().positive().max(500).describe('The smallest relative lift worth detecting, in percent. 20 means a 20% lift, like going from 3% to 3.6%.'),
|
|
21
|
+
daily_visitors: z.number().positive().describe('How many visitors a day enter the test, across all versions.'),
|
|
22
|
+
versions: z.number().int().min(2).max(6).default(2).describe('How many versions, counting the original.'),
|
|
23
|
+
confidence: z.number().min(0.8).max(0.999).default(0.95),
|
|
24
|
+
power: z.number().min(0.5).max(0.99).default(0.8),
|
|
25
|
+
question: roleSchema.optional().describe('The reader question both versions answer, each in its own way.'),
|
|
26
|
+
version_a: z.string().max(600).optional().describe('How version A answers it.'),
|
|
27
|
+
version_b: z.string().max(600).optional().describe('How version B answers it.'),
|
|
28
|
+
},
|
|
29
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
30
|
+
}, async (a) => {
|
|
31
|
+
const base = a.baseline_conversion_rate_percent / 100;
|
|
32
|
+
const lift = a.minimum_detectable_lift_percent / 100;
|
|
33
|
+
if (base * (1 + lift) >= 1)
|
|
34
|
+
return errorText('That lift would push the conversion rate past 100%, so use a smaller lift.');
|
|
35
|
+
const comparisons = a.versions - 1;
|
|
36
|
+
const alpha = (1 - a.confidence) / comparisons;
|
|
37
|
+
const perArm = sampleSizePerArm(base, lift, alpha, a.power);
|
|
38
|
+
const total = perArm * a.versions;
|
|
39
|
+
const days = Math.ceil(total / a.daily_visitors);
|
|
40
|
+
const weeks = Math.max(1, Math.ceil(days / 7));
|
|
41
|
+
const out = [];
|
|
42
|
+
out.push(`# A/B test plan for ${a.versions} versions`);
|
|
43
|
+
out.push(`To detect a ${pct(lift, 0)} lift on a ${pct(base)} conversion rate, which means going from ${pct(base)} to ${pct(base * (1 + lift))}, ` +
|
|
44
|
+
`at ${pct(a.confidence, 0)} confidence and ${pct(a.power, 0)} power${comparisons > 1 ? `, corrected for ${comparisons} comparisons` : ''}, ` +
|
|
45
|
+
`you need ${fmt(perArm)} visitors for each version, or ${fmt(total)} in total.`);
|
|
46
|
+
out.push(`At ${fmt(a.daily_visitors)} visitors a day, that takes about ${plural(days, 'day')}. Run it in whole weeks, so ${plural(weeks, 'week')}, ` +
|
|
47
|
+
'and weekdays and weekends both get counted.');
|
|
48
|
+
out.push(`Expect about ${fmt(Math.round(perArm * base))} conversions for each version. Decide now when you'll stop, and don't stop early because one day looks good.`);
|
|
49
|
+
if (days > 56) {
|
|
50
|
+
out.push('');
|
|
51
|
+
out.push('That is more than eight weeks, which is a long time to wait, and you have three ways to shorten it. You can test a bigger difference, meaning two genuinely different answers ' +
|
|
52
|
+
'to a reader question instead of a change in wording. You can test a step with more traffic, like clicks instead of purchases. ' +
|
|
53
|
+
`Or you can accept that you'll only catch a bigger lift, since a ${pct(Math.min(lift * 2, 4), 0)} lift needs roughly a quarter of the traffic.`);
|
|
54
|
+
}
|
|
55
|
+
out.push('');
|
|
56
|
+
out.push('## Make the test teach you something');
|
|
57
|
+
if (a.question) {
|
|
58
|
+
const r = a.question;
|
|
59
|
+
out.push(`Your hypothesis is that for this audience, ${a.version_b ? `"${a.version_b}"` : 'version B'} is a better answer to "${ROLES[r].question}" than ${a.version_a ? `"${a.version_a}"` : 'version A'}. ` +
|
|
60
|
+
'Change only that answer between the versions. Whichever wins tells you something about these readers that carries over to your next ad, page and email.');
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
out.push('Pick one reader question and test two different answers to it, like proof by demonstration against proof by testimonial, and keep everything else the same. ' +
|
|
64
|
+
`"Version B won" teaches you nothing. "For these buyers, a demonstration answers '${ROLES.PROVE.question}' better than a testimonial" carries over to everything you write next.`);
|
|
65
|
+
}
|
|
66
|
+
out.push('When the test is done, read the result with read_ab_test_result.');
|
|
67
|
+
return text(out.join('\n'));
|
|
68
|
+
});
|
|
69
|
+
server.registerTool('read_ab_test_result', {
|
|
70
|
+
title: 'Read an A/B test result',
|
|
71
|
+
description: "Read the result of an A/B test. You get each version's conversion rate and lift, whether the difference is statistically significant, the confidence interval, " +
|
|
72
|
+
'and the chance each version really beats the original. It also warns you about the things that make a test result lie. Those include too few conversions, ' +
|
|
73
|
+
"traffic that didn't split the way it should (a sample ratio mismatch), stopping the moment it looked good, and testing too many versions at once. " +
|
|
74
|
+
"So use it whenever someone shares test numbers, or asks \"did my test win?\", \"is this significant?\", \"which version won?\" or \"should I keep it running?\"",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
versions: z.array(z.object({
|
|
77
|
+
name: z.string().min(1).max(120),
|
|
78
|
+
visitors: z.number().int().positive(),
|
|
79
|
+
conversions: z.number().int().min(0),
|
|
80
|
+
})).min(2).max(6).describe('Each version with its visitors and conversions. Put the original, the control, first.'),
|
|
81
|
+
intended_split: z.array(z.number().positive()).optional().describe('How you meant to split the traffic, like [50, 50]. The default is an even split.'),
|
|
82
|
+
confidence: z.number().min(0.8).max(0.999).default(0.95),
|
|
83
|
+
question: roleSchema.optional().describe('The reader question the versions answered in different ways.'),
|
|
84
|
+
},
|
|
85
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
86
|
+
}, async ({ versions, intended_split, confidence, question }) => {
|
|
87
|
+
if (versions.some((v) => v.conversions > v.visitors))
|
|
88
|
+
return errorText('One of the versions has more conversions than visitors, so check the numbers.');
|
|
89
|
+
if (intended_split && intended_split.length !== versions.length)
|
|
90
|
+
return errorText('intended_split needs one share for each version.');
|
|
91
|
+
const [ctrl, ...rest] = versions;
|
|
92
|
+
const comparisons = rest.length;
|
|
93
|
+
const alpha = (1 - confidence) / comparisons;
|
|
94
|
+
const out = [];
|
|
95
|
+
out.push(`# A/B test result for ${versions.length} versions, with "${ctrl.name}" as the original`);
|
|
96
|
+
out.push(`"${ctrl.name}" converted ${fmt(ctrl.conversions)} of ${fmt(ctrl.visitors)} visitors, or ${pct(ctrl.conversions / ctrl.visitors)}.`);
|
|
97
|
+
let anyWinner = false;
|
|
98
|
+
for (const v of rest) {
|
|
99
|
+
const c = compare(ctrl.conversions, ctrl.visitors, v.conversions, v.visitors, alpha);
|
|
100
|
+
const sig = c.p < alpha;
|
|
101
|
+
if (sig && c.diff > 0)
|
|
102
|
+
anyWinner = true;
|
|
103
|
+
const verdict = sig
|
|
104
|
+
? (c.diff > 0 ? "That's a statistically significant win." : "That's a statistically significant loss.")
|
|
105
|
+
: "That difference isn't statistically significant yet.";
|
|
106
|
+
out.push('');
|
|
107
|
+
out.push(`"${v.name}" converted ${fmt(v.conversions)} of ${fmt(v.visitors)}, or ${pct(c.rateB)}, which is a ${pct(Math.abs(c.relLift), 1)} ${c.relLift >= 0 ? 'lift' : 'drop'}. ${verdict} ` +
|
|
108
|
+
`The p-value is ${pValue(c.p)}${comparisons > 1 ? `, against a threshold of ${alpha.toFixed(4)} after correcting for ${comparisons} comparisons` : ''}. ` +
|
|
109
|
+
`At ${pct(confidence, 0)} confidence${comparisons > 1 ? ' (corrected)' : ''}, the true difference sits somewhere between ${points(c.ciLow)} and ${points(c.ciHigh)} percentage points, ` +
|
|
110
|
+
`and the chance it really beats the original is ${pct(c.probBBetter, 0)}.`);
|
|
111
|
+
}
|
|
112
|
+
const warnings = [];
|
|
113
|
+
const shares = intended_split ?? versions.map(() => 1);
|
|
114
|
+
const srm = srmPValue(versions.map((v) => v.visitors), shares);
|
|
115
|
+
if (srm < 0.001) {
|
|
116
|
+
warnings.push(`The traffic didn't split the way you intended, which is called a sample ratio mismatch (p-value ${pValue(srm)}). ` +
|
|
117
|
+
"It means something in the assignment or the tracking is broken, so don't trust this result until you find it.");
|
|
118
|
+
}
|
|
119
|
+
const lowConv = versions.filter((v) => v.conversions < 30);
|
|
120
|
+
if (lowConv.length) {
|
|
121
|
+
warnings.push(`${joinList(lowConv.map((v) => `"${v.name}"`))} ${lowConv.length === 1 ? 'has' : 'have'} fewer than 30 conversions. ` +
|
|
122
|
+
'Results that small swing wildly, so keep the test running to the sample you planned.');
|
|
123
|
+
}
|
|
124
|
+
if (anyWinner)
|
|
125
|
+
warnings.push("Big lifts from small tests usually shrink once you roll them out. It's called the winner's curse, so expect less than the lift you measured.");
|
|
126
|
+
warnings.push('And if you checked the results over and over and stopped when they looked good, the p-value overstates how sure you can be. Stop at the sample size you planned with plan_ab_test.');
|
|
127
|
+
out.push('');
|
|
128
|
+
out.push('## Before you act');
|
|
129
|
+
out.push(warnings.join(' '));
|
|
130
|
+
out.push('');
|
|
131
|
+
out.push('## What you learned');
|
|
132
|
+
if (question) {
|
|
133
|
+
const r = question;
|
|
134
|
+
out.push(anyWinner
|
|
135
|
+
? `Write it down. For this audience, the winning answer to "${ROLES[r].question}" beat the original. Use it in your next ad, page and email, and test a new challenger against it.`
|
|
136
|
+
: `These versions answer "${ROLES[r].question}" in different ways, and so far there's no reliable difference between them. Either both answers work about equally well for this audience, ` +
|
|
137
|
+
'or the test needs more traffic. Next time, try an answer that is more different.');
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
out.push('Name the reader question the versions answered differently, and the technique each one used. That turns this result into a rule you can reuse, instead of a one-off winner.');
|
|
141
|
+
}
|
|
142
|
+
out.push('To find your next challenger, use find_persuasion_techniques.');
|
|
143
|
+
return text(out.join('\n'));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ROLES, SYMPTOMS, SYMPTOM_MAP, roleLabel } from '../roles.js';
|
|
3
|
+
import { pickMoves, resolveCategory } from '../data.js';
|
|
4
|
+
import { formatSchema, categorySchema, modeSchema } from '../schemas.js';
|
|
5
|
+
import { text, techniqueLine } from '../format.js';
|
|
6
|
+
export function registerWhyTool(server) {
|
|
7
|
+
server.registerTool('explain_why_not_converting', {
|
|
8
|
+
title: 'Explain why marketing is not converting',
|
|
9
|
+
description: "Work out why an ad, a landing page, an email or a whole funnel isn't converting. Start from what you can see. " +
|
|
10
|
+
"Maybe people don't click (a low CTR), or they click and bounce, or they read and don't buy. Maybe they abandon the checkout or the form, the leads never buy, " +
|
|
11
|
+
"customers buy once and leave, or emails don't get opened or get opened and nobody clicks. " +
|
|
12
|
+
"Whatever the symptom, you get back the reader questions most likely going unanswered, what to check in the copy first, and the problems copy can't fix that you should rule out, " +
|
|
13
|
+
'along with techniques to try and the test that would confirm the diagnosis. ' +
|
|
14
|
+
"So use it whenever someone says their marketing \"isn't working\", \"isn't converting\" or \"isn't selling\", or asks why results dropped.",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
symptom: z.enum(SYMPTOMS).describe('What you can see happening. Use low_click_through when people see it and don\'t click, high_bounce when they click and leave fast, reads_but_no_action when they read and don\'t convert, ' +
|
|
17
|
+
'starts_but_abandons when they abandon the checkout or form, leads_dont_buy when signups never buy, buys_once_no_return for refunds, churn and no repeat orders, ' +
|
|
18
|
+
'low_open_rate when emails don\'t get opened, and opens_no_clicks when they get opened but not clicked.'),
|
|
19
|
+
format: formatSchema.optional(),
|
|
20
|
+
details: z.string().max(2000).optional().describe('Anything you know: the numbers, what changed recently, the offer and the audience.'),
|
|
21
|
+
category: categorySchema.optional(),
|
|
22
|
+
persuasion_mode: modeSchema,
|
|
23
|
+
},
|
|
24
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
25
|
+
}, async ({ symptom, format, details, category, persuasion_mode }) => {
|
|
26
|
+
const info = SYMPTOM_MAP[symptom];
|
|
27
|
+
const cat = resolveCategory(category);
|
|
28
|
+
const out = [];
|
|
29
|
+
out.push(`# Why it isn't converting: ${info.label}`);
|
|
30
|
+
if (details)
|
|
31
|
+
out.push(`The situation: ${details}`);
|
|
32
|
+
out.push('');
|
|
33
|
+
out.push(`Before you touch the copy, rule out the things copy can't fix. ${info.ruleOutFirst}`);
|
|
34
|
+
out.push('');
|
|
35
|
+
out.push('If those check out, the problem is most likely a question the copy leaves unanswered. Here are the suspects, most likely first.');
|
|
36
|
+
info.suspects.forEach((s, i) => {
|
|
37
|
+
out.push('');
|
|
38
|
+
if (s.role === 'COHERENCE') {
|
|
39
|
+
out.push(`## ${i + 1}. The answers don't agree with each other`);
|
|
40
|
+
out.push(`${s.why} ${s.check}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const role = s.role;
|
|
44
|
+
out.push(`## ${i + 1}. ${roleLabel(role)}`);
|
|
45
|
+
out.push(`Leave it unanswered and this is what happens: "${ROLES[role].ifMissing}" ${s.why} ${s.check}`);
|
|
46
|
+
if (i < 2) {
|
|
47
|
+
out.push('Here are two ways to answer it.');
|
|
48
|
+
for (const p of pickMoves(role, { n: 2, format: format ?? null, category: cat, seed: `${symptom}|${details ?? ''}`, context: `${info.label} ${s.check} ${details ?? ''}`, mode: persuasion_mode })) {
|
|
49
|
+
out.push(techniqueLine(p.t, { category: cat, kind: p.kind === 'established' ? 'established' : 'less expected', example: false }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const top = info.suspects.find((s) => s.role !== 'COHERENCE');
|
|
54
|
+
out.push('');
|
|
55
|
+
out.push('## Confirm it with a test');
|
|
56
|
+
out.push(top
|
|
57
|
+
? `Keep everything else the same and test two versions that answer "${ROLES[top.role].question}" in different ways, for example the established move above against the less expected one. ` +
|
|
58
|
+
'If this was the gap, the version that answers it better will move the number. Size the test with plan_ab_test.'
|
|
59
|
+
: 'Fix the mismatch on one side only and test it against the current version. Size the test with plan_ab_test.');
|
|
60
|
+
out.push('If you have the copy itself, label each line and run diagnose_marketing_copy for a line-by-line read.');
|
|
61
|
+
return text(out.join('\n'));
|
|
62
|
+
});
|
|
63
|
+
}
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-English rendering for everything the scorers report.
|
|
3
|
+
*
|
|
4
|
+
* The scorers in src/scorers/ are copied from Coppica and stay in sync with it, so their own
|
|
5
|
+
* messages are left alone. Nothing they say reaches a model directly: it goes through here first,
|
|
6
|
+
* and comes out as full sentences in the voice of a working copywriter. Every tool output is a
|
|
7
|
+
* sample of the writing we want people to produce, so it has to read like good writing itself.
|
|
8
|
+
*/
|
|
9
|
+
import type { FlowScore } from './scorers/flow-framework.js';
|
|
10
|
+
type FlowGateResult = FlowScore['gates'][number];
|
|
11
|
+
export declare const cap: (s: string) => string;
|
|
12
|
+
/** "a", "a and b", "a, b and c" */
|
|
13
|
+
export declare function joinList(items: string[], conj?: string): string;
|
|
14
|
+
export declare const plural: (n: number, one: string, many?: string) => string;
|
|
15
|
+
/** Add a period unless the sentence already ends in one, including inside a closing quote ('..."Is this for me?"'). */
|
|
16
|
+
export declare const endSentence: (s: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Shorten text to about n characters at the end of a sentence where possible, otherwise at a word,
|
|
19
|
+
* never mid-word. Text the catalog export already cut (ending in …) is pulled back the same way.
|
|
20
|
+
*/
|
|
21
|
+
export declare function clipText(s: string, n: number): string;
|
|
22
|
+
/** Catalog text quoted inside our own double quotes: turn its inner double quotes into single ones. */
|
|
23
|
+
export declare const innerQuotes: (s: string) => string;
|
|
24
|
+
/** detectAISmell() violations -> stock words found, and one sentence per machine-written pattern. */
|
|
25
|
+
export declare function readSmell(violations: string[]): {
|
|
26
|
+
words: string[];
|
|
27
|
+
patterns: string[];
|
|
28
|
+
};
|
|
29
|
+
export declare function smellSentences(violations: string[]): string[];
|
|
30
|
+
/** One failing or warning flow gate -> what a good editor would tell the writer. */
|
|
31
|
+
export declare function flowGateSentence(g: FlowGateResult): string | null;
|
|
32
|
+
export declare function claimGateSentence(name: string, claim: string, type: 'cta' | 'body_claim'): string | null;
|
|
33
|
+
export {};
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
|
2
|
+
/** "a", "a and b", "a, b and c" */
|
|
3
|
+
export function joinList(items, conj = 'and') {
|
|
4
|
+
if (items.length <= 1)
|
|
5
|
+
return items[0] ?? '';
|
|
6
|
+
return `${items.slice(0, -1).join(', ')} ${conj} ${items[items.length - 1]}`;
|
|
7
|
+
}
|
|
8
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
9
|
+
/** Add a period unless the sentence already ends in one, including inside a closing quote ('..."Is this for me?"'). */
|
|
10
|
+
export const endSentence = (s) => (/[.!?…]["')\]]?$/.test(s.trim()) ? s.trim() : `${s.trim()}.`);
|
|
11
|
+
/**
|
|
12
|
+
* Shorten text to about n characters at the end of a sentence where possible, otherwise at a word,
|
|
13
|
+
* never mid-word. Text the catalog export already cut (ending in …) is pulled back the same way.
|
|
14
|
+
*/
|
|
15
|
+
export function clipText(s, n) {
|
|
16
|
+
const t = s.trim();
|
|
17
|
+
const cps = Array.from(t);
|
|
18
|
+
const cutByExport = t.endsWith('…');
|
|
19
|
+
if (cps.length <= n && !cutByExport)
|
|
20
|
+
return t;
|
|
21
|
+
const cut = cps.length > n ? cps.slice(0, n).join('') : t.slice(0, -1);
|
|
22
|
+
const ends = [...cut.matchAll(/[.!?]["')\]]?(?=\s)/g)];
|
|
23
|
+
const last = ends[ends.length - 1];
|
|
24
|
+
if (last && last.index + last[0].length >= cut.length * 0.4)
|
|
25
|
+
return cut.slice(0, last.index + last[0].length);
|
|
26
|
+
const space = cut.lastIndexOf(' ');
|
|
27
|
+
return `${(space > 0 ? cut.slice(0, space) : cut).replace(/[\s,;:(\[]+$/, '')}…`;
|
|
28
|
+
}
|
|
29
|
+
/** Catalog text quoted inside our own double quotes: turn its inner double quotes into single ones. */
|
|
30
|
+
export const innerQuotes = (s) => s.replace(/["“”]/g, "'");
|
|
31
|
+
// ── machine-written phrasing ─────────────────────────────────────────────────
|
|
32
|
+
const PATTERN_TEXT = {
|
|
33
|
+
em_dash_reversal: 'The "X, then a dash, then it\'s Y" reversal is one of the most recognizable machine-written moves. Use a period and make the point straight.',
|
|
34
|
+
not_just_but: '"It\'s not just X, it\'s Y" is one of the most recognizable machine-written lines. Say the one thing you mean and let it stand.',
|
|
35
|
+
more_than_just: '"More than just" is filler. Cut it and say what the thing actually is.',
|
|
36
|
+
in_the_world_of: '"In the world of..." is throat-clearing. Skip the setup and make the claim.',
|
|
37
|
+
tripled_adjectives: 'Three adjectives stacked on one noun, like "fast, simple, and powerful platform", is corporate boilerplate. Keep the one claim you can prove.',
|
|
38
|
+
hope_this_helps: '"Hope this helps" is chatbot filler. Cut it.',
|
|
39
|
+
absolutely_great_question: 'Words like "absolutely" and "great question" are chatbot filler. Get to the point.',
|
|
40
|
+
};
|
|
41
|
+
/** detectAISmell() violations -> stock words found, and one sentence per machine-written pattern. */
|
|
42
|
+
export function readSmell(violations) {
|
|
43
|
+
const words = [];
|
|
44
|
+
const patterns = [];
|
|
45
|
+
for (const v of violations) {
|
|
46
|
+
const w = v.match(/^Banned word\/phrase: "(.+)"$/);
|
|
47
|
+
if (w) {
|
|
48
|
+
words.push(w[1]);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const name = v.split(':')[0];
|
|
52
|
+
patterns.push(PATTERN_TEXT[name] ?? v.slice(name.length + 1).trim());
|
|
53
|
+
}
|
|
54
|
+
return { words, patterns };
|
|
55
|
+
}
|
|
56
|
+
export function smellSentences(violations) {
|
|
57
|
+
const { words, patterns } = readSmell(violations);
|
|
58
|
+
const out = [];
|
|
59
|
+
if (words.length) {
|
|
60
|
+
const quoted = joinList(words.slice(0, 5).map((w) => `"${w}"`));
|
|
61
|
+
out.push(`Readers now link ${words.length === 1 ? `the word ${quoted}` : `words like ${quoted}`} with machine-written copy, and they skim past ${words.length === 1 ? 'it' : 'them'}. ` +
|
|
62
|
+
`Use the plain word you would say out loud.`);
|
|
63
|
+
}
|
|
64
|
+
return [...out, ...patterns];
|
|
65
|
+
}
|
|
66
|
+
// ── flow (craft and rhythm) ──────────────────────────────────────────────────
|
|
67
|
+
const BAND_TEXT = [
|
|
68
|
+
[/no short punches/, 'There isn\'t one short, punchy sentence in it.'],
|
|
69
|
+
[/long sentences/, 'Almost nothing runs long enough to build momentum.'],
|
|
70
|
+
[/medium length/, 'Nearly every sentence is medium length, and that is what makes it feel monotonous.'],
|
|
71
|
+
];
|
|
72
|
+
/** One failing or warning flow gate -> what a good editor would tell the writer. */
|
|
73
|
+
export function flowGateSentence(g) {
|
|
74
|
+
const fail = g.status === 'fail';
|
|
75
|
+
switch (g.name) {
|
|
76
|
+
case 'ai_smell_lexicon':
|
|
77
|
+
return smellSentences((g.detail ?? '').split('; ').filter(Boolean)).join(' ');
|
|
78
|
+
case 'em_dash_density':
|
|
79
|
+
return 'There are more em dashes than a person would use in this much copy. Most of them want to be periods.';
|
|
80
|
+
case 'negative_parallelism_cap':
|
|
81
|
+
return 'The copy leans on "it\'s not X, it\'s Y" more than once, and readers now hear that as machine-written. Keep the stronger half and say it plainly.';
|
|
82
|
+
case 'triad_density':
|
|
83
|
+
return 'Several lines stack three adjectives on an abstract noun. People rarely talk that way, so keep the one that is true and specific.';
|
|
84
|
+
case 'burstiness':
|
|
85
|
+
return fail
|
|
86
|
+
? 'The sentences all run about the same length, and that makes the copy drone. Put a three-word line next to a long one that builds.'
|
|
87
|
+
: 'The sentence lengths are still fairly even. Vary them more.';
|
|
88
|
+
case 'paragraph_variance':
|
|
89
|
+
return 'The paragraphs are all about the same size. Let a one-line paragraph land between the longer ones.';
|
|
90
|
+
case 'rhythmic_contrast':
|
|
91
|
+
return 'In places, several sentences in a row land at the same length. Break the run with a short punch or a longer build.';
|
|
92
|
+
case 'band_distribution': {
|
|
93
|
+
const found = BAND_TEXT.filter(([re]) => re.test(g.detail ?? '')).map(([, t]) => t);
|
|
94
|
+
return found.length ? `${found.join(' ')} Mix in a few of each.` : 'The mix of short and long sentences is off. Vary it.';
|
|
95
|
+
}
|
|
96
|
+
case 'consecutive_band':
|
|
97
|
+
return 'Three or more sentences in a row land at the same length, and that is what makes copy sound flat. Break the run.';
|
|
98
|
+
case 'opening_uniformity':
|
|
99
|
+
return 'Most of the paragraphs start the same way. Vary how they open.';
|
|
100
|
+
case 'bucket_brigade_density':
|
|
101
|
+
return 'Long copy needs small phrases that pull the reader on to the next line, like "Here\'s the thing" or "But there\'s a catch." This has too few of them.';
|
|
102
|
+
case 'threading_ratio':
|
|
103
|
+
return fail
|
|
104
|
+
? 'The sentences read like a list. Each one should pick up where the last one left off, with a pronoun, a word like "so" or "but", or a key word carried over.'
|
|
105
|
+
: 'A few sentences stand alone when they should pull the reader forward.';
|
|
106
|
+
case 'closing_recap':
|
|
107
|
+
return 'The last paragraph mostly repeats what came before. Cut the recap and end on your strongest line.';
|
|
108
|
+
default:
|
|
109
|
+
return g.detail ? g.detail.replace(/\s+—\s+/g, '. ') : null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// ── claims and buttons ───────────────────────────────────────────────────────
|
|
113
|
+
export function claimGateSentence(name, claim, type) {
|
|
114
|
+
const text = claim.trim();
|
|
115
|
+
const first = text.split(/\s+/)[0] ?? '';
|
|
116
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
117
|
+
switch (name) {
|
|
118
|
+
case 'generic_button_text':
|
|
119
|
+
return `"${text}" is the button everyone uses. Say what they get instead, like "Save my seat", "Book my audit" or "Send me the guide".`;
|
|
120
|
+
case 'first_person_or_outcome':
|
|
121
|
+
return 'A button works harder in the reader\'s own voice, like "Get my plan", or when it names the thing they get, like a seat, an audit or a guide.';
|
|
122
|
+
case 'strong_action_verb':
|
|
123
|
+
return `It opens with "${first}". A button should open with the verb of the action, like "Get", "Book" or "Start".`;
|
|
124
|
+
case 'low_friction':
|
|
125
|
+
return words ? `At ${words} words it reads like a sentence. Keep a button to a few words.` : 'The button is empty.';
|
|
126
|
+
case 'specificity':
|
|
127
|
+
return 'There is no number, name or timeframe in it, so it is easy to forget. Anchor it with one.';
|
|
128
|
+
case 'mechanism_named':
|
|
129
|
+
return 'It doesn\'t say how the result happens. Readers who have heard the promise before believe it more when you name the mechanism, with a "because" or a "by".';
|
|
130
|
+
case 'proof_welded':
|
|
131
|
+
return type === 'body_claim' ? 'There is nothing in it a skeptic could check. Put a real number or a named source right next to the claim.' : null;
|
|
132
|
+
case 'empty':
|
|
133
|
+
return 'The claim is empty.';
|
|
134
|
+
case 'too_long':
|
|
135
|
+
return 'That is too long to check as a single claim. For a whole block of copy, use diagnose_marketing_copy.';
|
|
136
|
+
default:
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coppica/persuasion-mcp",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "A free MCP server that helps any AI write and check marketing copy: the nine questions every reader asks, 766 persuasion techniques from real ads, copy diagnostics and A/B test math. No API key, no AI calls.",
|
|
5
|
+
"mcpName": "com.coppica/persuasion-taxonomy",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"persuasion-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/server.d.ts",
|
|
13
|
+
"default": "./dist/server.js"
|
|
14
|
+
},
|
|
15
|
+
"./http": {
|
|
16
|
+
"types": "./dist/http.d.ts",
|
|
17
|
+
"default": "./dist/http.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"data",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"start": "node dist/index.js",
|
|
29
|
+
"export-data": "node --env-file=../persuasion-taxonomy/.env.local scripts/export-catalog.mjs",
|
|
30
|
+
"smoke": "node scripts/smoke.mjs",
|
|
31
|
+
"voice": "node scripts/voice-check.mjs",
|
|
32
|
+
"prepublishOnly": "npm run build && npm run smoke && npm run smoke -- --http && npm run voice"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT AND CC-BY-4.0",
|
|
35
|
+
"author": "Coppica LLC (https://coppica.com)",
|
|
36
|
+
"homepage": "https://taxonomy.coppica.com",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/Otha-Labs/persuasion-mcp.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/Otha-Labs/persuasion-mcp/issues"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"mcp",
|
|
46
|
+
"mcp-server",
|
|
47
|
+
"model-context-protocol",
|
|
48
|
+
"marketing",
|
|
49
|
+
"copywriting",
|
|
50
|
+
"persuasion",
|
|
51
|
+
"conversion",
|
|
52
|
+
"landing-page",
|
|
53
|
+
"headlines",
|
|
54
|
+
"ab-testing",
|
|
55
|
+
"claude",
|
|
56
|
+
"chatgpt"
|
|
57
|
+
],
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
63
|
+
"zod": "^3.23.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@supabase/supabase-js": "^2.45.0",
|
|
67
|
+
"@types/node": "^20.0.0",
|
|
68
|
+
"typescript": "^5.4.0"
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
}
|
|
73
|
+
}
|