lettr-mcp 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +3 -2
- package/dist/lettr.js +4 -1
- package/dist/package.json +1 -1
- package/dist/tools/campaigns.js +270 -0
- package/dist/tools/index.js +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import minimist from 'minimist';
|
|
5
5
|
import { LettrClient } from './lettr.js';
|
|
6
6
|
import packageJson from './package.json' with { type: 'json' };
|
|
7
|
-
import { addAudienceContactTools, addAudienceListTools, addAudienceMembershipTools, addAudiencePropertyTools, addAudienceSegmentTools, addAudienceTopicTools, addDomainTools, addEmailTools, addProjectTools, addSystemTools, addTemplateTools, addWebhookTools, } from './tools/index.js';
|
|
7
|
+
import { addAudienceContactTools, addAudienceListTools, addAudienceMembershipTools, addAudiencePropertyTools, addAudienceSegmentTools, addAudienceTopicTools, addCampaignTools, addDomainTools, addEmailTools, addProjectTools, addSystemTools, addTemplateTools, addWebhookTools, } from './tools/index.js';
|
|
8
8
|
const argv = minimist(process.argv.slice(2));
|
|
9
9
|
const apiKey = argv.key || process.env.LETTR_API_KEY;
|
|
10
10
|
const senderEmailAddress = argv.sender || process.env.SENDER_EMAIL_ADDRESS;
|
|
@@ -15,7 +15,7 @@ if (!apiKey) {
|
|
|
15
15
|
console.error('No API key provided. Please set LETTR_API_KEY environment variable or use --key argument');
|
|
16
16
|
process.exit(1);
|
|
17
17
|
}
|
|
18
|
-
const lettr = new LettrClient(apiKey);
|
|
18
|
+
const lettr = new LettrClient(apiKey, packageJson.version);
|
|
19
19
|
const server = new McpServer({
|
|
20
20
|
name: 'lettr',
|
|
21
21
|
version: packageJson.version,
|
|
@@ -31,6 +31,7 @@ addAudienceMembershipTools(server, lettr);
|
|
|
31
31
|
addAudienceTopicTools(server, lettr);
|
|
32
32
|
addAudiencePropertyTools(server, lettr);
|
|
33
33
|
addAudienceSegmentTools(server, lettr);
|
|
34
|
+
addCampaignTools(server, lettr);
|
|
34
35
|
addSystemTools(server, lettr);
|
|
35
36
|
async function main() {
|
|
36
37
|
const transport = new StdioServerTransport();
|
package/dist/lettr.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const BASE_URL = 'https://app.lettr.com/api';
|
|
2
2
|
export class LettrClient {
|
|
3
3
|
apiKey;
|
|
4
|
-
|
|
4
|
+
version;
|
|
5
|
+
constructor(apiKey, version) {
|
|
5
6
|
this.apiKey = apiKey;
|
|
7
|
+
this.version = version;
|
|
6
8
|
}
|
|
7
9
|
async request(method, path, body, query) {
|
|
8
10
|
const url = new URL(`${BASE_URL}${path}`);
|
|
@@ -16,6 +18,7 @@ export class LettrClient {
|
|
|
16
18
|
const headers = {
|
|
17
19
|
Authorization: `Bearer ${this.apiKey}`,
|
|
18
20
|
Accept: 'application/json',
|
|
21
|
+
'User-Agent': `lettr-mcp/${this.version}`,
|
|
19
22
|
};
|
|
20
23
|
const options = { method, headers };
|
|
21
24
|
if (body &&
|
package/dist/package.json
CHANGED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const CAMPAIGN_STATUSES = [
|
|
3
|
+
'draft',
|
|
4
|
+
'scheduled',
|
|
5
|
+
'preparing',
|
|
6
|
+
'in_review',
|
|
7
|
+
'sending',
|
|
8
|
+
'sent',
|
|
9
|
+
'failed',
|
|
10
|
+
];
|
|
11
|
+
const EVENT_TYPES = [
|
|
12
|
+
'injection',
|
|
13
|
+
'delivery',
|
|
14
|
+
'bounce',
|
|
15
|
+
'spam_complaint',
|
|
16
|
+
'open',
|
|
17
|
+
'click',
|
|
18
|
+
'list_unsubscribe',
|
|
19
|
+
];
|
|
20
|
+
const HTML_PREVIEW_CHARS = 500;
|
|
21
|
+
function formatStats(s) {
|
|
22
|
+
return `opens ${s.opens} (unique ${s.unique_opens}), clicks ${s.clicks} (unique ${s.unique_clicks}), deliveries ${s.deliveries}, bounces ${s.bounces}, spam ${s.spam_complaints}, unsubscribes ${s.unsubscribes}`;
|
|
23
|
+
}
|
|
24
|
+
function formatCampaign(c) {
|
|
25
|
+
const sender = c.from_email
|
|
26
|
+
? c.from_name
|
|
27
|
+
? `${c.from_name} <${c.from_email}>`
|
|
28
|
+
: c.from_email
|
|
29
|
+
: 'none';
|
|
30
|
+
const lines = [
|
|
31
|
+
`ID: ${c.id}`,
|
|
32
|
+
`Name: ${c.name}`,
|
|
33
|
+
`Status: ${c.status}`,
|
|
34
|
+
`Subject: ${c.subject ?? 'none'}`,
|
|
35
|
+
`From: ${sender}`,
|
|
36
|
+
`Reply-to: ${c.reply_to ?? 'none'}`,
|
|
37
|
+
`Recipients: ${c.total_recipients ?? 'not yet resolved'}`,
|
|
38
|
+
`Sent count: ${c.sent_count}`,
|
|
39
|
+
`Scheduled at: ${c.scheduled_at ?? 'not scheduled'}`,
|
|
40
|
+
`Sent at: ${c.sent_at ?? 'not sent'}`,
|
|
41
|
+
`Created: ${c.created_at}`,
|
|
42
|
+
`Stats: ${formatStats(c.stats)}`,
|
|
43
|
+
];
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
function formatEvent(e) {
|
|
47
|
+
const extras = [];
|
|
48
|
+
if (e.reason)
|
|
49
|
+
extras.push(`reason: ${e.reason}`);
|
|
50
|
+
if (e.bounce_class)
|
|
51
|
+
extras.push(`bounce_class: ${e.bounce_class}`);
|
|
52
|
+
if (e.target_link_url)
|
|
53
|
+
extras.push(`url: ${e.target_link_url}`);
|
|
54
|
+
const suffix = extras.length > 0 ? ` — ${extras.join(', ')}` : '';
|
|
55
|
+
return `- ${e.timestamp} ${e.event_type} ${e.email} (id: ${e.event_id})${suffix}`;
|
|
56
|
+
}
|
|
57
|
+
export function addCampaignTools(server, lettr) {
|
|
58
|
+
server.registerTool('list-campaigns', {
|
|
59
|
+
title: 'List Campaigns',
|
|
60
|
+
description: 'List campaigns for your team, with pagination and an optional status filter. Each campaign includes embedded engagement stats. Use this to discover campaign IDs for the other campaign tools.',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
per_page: z
|
|
63
|
+
.number()
|
|
64
|
+
.int()
|
|
65
|
+
.min(1)
|
|
66
|
+
.max(100)
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('Results per page (1-100, default 20)'),
|
|
69
|
+
page: z
|
|
70
|
+
.number()
|
|
71
|
+
.int()
|
|
72
|
+
.min(1)
|
|
73
|
+
.max(10000)
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('Page number (default 1)'),
|
|
76
|
+
status: z
|
|
77
|
+
.enum(CAMPAIGN_STATUSES)
|
|
78
|
+
.optional()
|
|
79
|
+
.describe('Filter by campaign status'),
|
|
80
|
+
},
|
|
81
|
+
}, async ({ per_page, page, status }) => {
|
|
82
|
+
const query = {};
|
|
83
|
+
if (per_page)
|
|
84
|
+
query.per_page = per_page;
|
|
85
|
+
if (page)
|
|
86
|
+
query.page = page;
|
|
87
|
+
if (status)
|
|
88
|
+
query.status = status;
|
|
89
|
+
const response = await lettr.get('/campaigns', query);
|
|
90
|
+
const { campaigns, pagination } = response.data;
|
|
91
|
+
if (campaigns.length === 0) {
|
|
92
|
+
return { content: [{ type: 'text', text: 'No campaigns found.' }] };
|
|
93
|
+
}
|
|
94
|
+
const lines = campaigns
|
|
95
|
+
.map((c) => `- ${c.name} (id: ${c.id}, status: ${c.status}, sent: ${c.sent_count})`)
|
|
96
|
+
.join('\n');
|
|
97
|
+
return {
|
|
98
|
+
content: [
|
|
99
|
+
{
|
|
100
|
+
type: 'text',
|
|
101
|
+
text: `Found ${pagination.total} campaign(s) — page ${pagination.current_page}/${pagination.last_page}:\n\n${lines}`,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
server.registerTool('get-campaign', {
|
|
107
|
+
title: 'Get Campaign',
|
|
108
|
+
description: 'Retrieve a single campaign by its ID, including embedded engagement stats and a preview of its rendered HTML content.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
campaignId: z.string().nonempty().describe('The campaign ID'),
|
|
111
|
+
},
|
|
112
|
+
}, async ({ campaignId }) => {
|
|
113
|
+
const response = await lettr.get(`/campaigns/${encodeURIComponent(campaignId)}`);
|
|
114
|
+
const campaign = response.data;
|
|
115
|
+
const html = campaign.html_content;
|
|
116
|
+
const htmlText = html == null
|
|
117
|
+
? 'HTML content: none'
|
|
118
|
+
: `HTML content: ${html.length} chars\nPreview:\n${html.slice(0, HTML_PREVIEW_CHARS)}${html.length > HTML_PREVIEW_CHARS ? '…' : ''}`;
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{ type: 'text', text: 'Campaign details:' },
|
|
122
|
+
{ type: 'text', text: formatCampaign(campaign) },
|
|
123
|
+
{ type: 'text', text: htmlText },
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
server.registerTool('list-campaign-events', {
|
|
128
|
+
title: 'List Campaign Events',
|
|
129
|
+
description: `List engagement events (opens, clicks, bounces, etc.) for a campaign, with optional filters. Uses cursor-based pagination: this tool returns one page plus a \`next_cursor\`. Keep calling it with \`cursor\` set to the returned \`next_cursor\` until \`next_cursor\` is null. Note: when a filter is applied, a page can come back with no events but a non-null \`next_cursor\` — that means more pages remain, so keep paginating until \`next_cursor\` is null.`,
|
|
130
|
+
inputSchema: {
|
|
131
|
+
campaignId: z.string().nonempty().describe('The campaign ID'),
|
|
132
|
+
event_type: z
|
|
133
|
+
.enum(EVENT_TYPES)
|
|
134
|
+
.optional()
|
|
135
|
+
.describe('Filter by event type'),
|
|
136
|
+
email: z
|
|
137
|
+
.string()
|
|
138
|
+
.optional()
|
|
139
|
+
.describe('Filter by recipient email address'),
|
|
140
|
+
start_date: z
|
|
141
|
+
.string()
|
|
142
|
+
.optional()
|
|
143
|
+
.describe('Only events at or after this time (ISO 8601). A date-only value (e.g. 2026-05-01) is treated as start of day in UTC.'),
|
|
144
|
+
end_date: z
|
|
145
|
+
.string()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe('Only events at or before this time (ISO 8601, inclusive). A date-only value covers the whole day in UTC.'),
|
|
148
|
+
limit: z
|
|
149
|
+
.number()
|
|
150
|
+
.int()
|
|
151
|
+
.min(1)
|
|
152
|
+
.max(100)
|
|
153
|
+
.optional()
|
|
154
|
+
.describe('Max events per page (1-100, default 25)'),
|
|
155
|
+
cursor: z
|
|
156
|
+
.string()
|
|
157
|
+
.optional()
|
|
158
|
+
.describe('Pagination cursor from a previous response'),
|
|
159
|
+
},
|
|
160
|
+
}, async ({ campaignId, event_type, email, start_date, end_date, limit, cursor, }) => {
|
|
161
|
+
const query = {};
|
|
162
|
+
if (event_type)
|
|
163
|
+
query.event_type = event_type;
|
|
164
|
+
if (email)
|
|
165
|
+
query.email = email;
|
|
166
|
+
if (start_date)
|
|
167
|
+
query.start_date = start_date;
|
|
168
|
+
if (end_date)
|
|
169
|
+
query.end_date = end_date;
|
|
170
|
+
if (limit)
|
|
171
|
+
query.limit = limit;
|
|
172
|
+
if (cursor)
|
|
173
|
+
query.cursor = cursor;
|
|
174
|
+
const response = await lettr.get(`/campaigns/${encodeURIComponent(campaignId)}/events`, query);
|
|
175
|
+
const { events, next_cursor } = response.data;
|
|
176
|
+
const cursorNote = next_cursor == null
|
|
177
|
+
? 'No more pages (next_cursor is null).'
|
|
178
|
+
: `More pages remain — call again with cursor: ${next_cursor}`;
|
|
179
|
+
if (events.length === 0) {
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
{ type: 'text', text: `No events on this page. ${cursorNote}` },
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const lines = events.map(formatEvent).join('\n');
|
|
187
|
+
return {
|
|
188
|
+
content: [
|
|
189
|
+
{
|
|
190
|
+
type: 'text',
|
|
191
|
+
text: `${events.length} event(s) on this page:\n\n${lines}\n\n${cursorNote}`,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
server.registerTool('send-campaign', {
|
|
197
|
+
title: 'Send Campaign',
|
|
198
|
+
description: 'Immediately send a draft campaign to its recipients. Before using this tool, you MUST double-check with the user that they want to send now. Warn them that this is irreversible and outward-facing — it dispatches real emails to recipients that cannot be recalled. The campaign must be a draft with a subject, sender, and content; on success it transitions to "preparing".',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
campaignId: z.string().nonempty().describe('The campaign ID to send'),
|
|
201
|
+
},
|
|
202
|
+
}, async ({ campaignId }) => {
|
|
203
|
+
const response = await lettr.post(`/campaigns/${encodeURIComponent(campaignId)}/send`);
|
|
204
|
+
const status = response.data?.status;
|
|
205
|
+
return {
|
|
206
|
+
content: [
|
|
207
|
+
{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: response.message ?? 'Campaign queued for sending.',
|
|
210
|
+
},
|
|
211
|
+
...(status
|
|
212
|
+
? [{ type: 'text', text: `Status is now: ${status}` }]
|
|
213
|
+
: []),
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
server.registerTool('schedule-campaign', {
|
|
218
|
+
title: 'Schedule Campaign',
|
|
219
|
+
description: 'Schedule a draft campaign for future delivery, or reschedule an already-scheduled campaign to a new time. Before using this tool, you MUST confirm the time with the user and warn them that at the scheduled time this will dispatch real emails to recipients. The campaign must be a draft or already scheduled, with a subject, sender, and content.',
|
|
220
|
+
inputSchema: {
|
|
221
|
+
campaignId: z
|
|
222
|
+
.string()
|
|
223
|
+
.nonempty()
|
|
224
|
+
.describe('The campaign ID to schedule'),
|
|
225
|
+
scheduled_at: z
|
|
226
|
+
.string()
|
|
227
|
+
.nonempty()
|
|
228
|
+
.describe('Future delivery time (ISO 8601, e.g. 2026-06-01T09:00:00+02:00). Include a timezone offset or "Z"; a value without an offset is interpreted as UTC. Must be in the future.'),
|
|
229
|
+
},
|
|
230
|
+
}, async ({ campaignId, scheduled_at }) => {
|
|
231
|
+
const response = await lettr.post(`/campaigns/${encodeURIComponent(campaignId)}/schedule`, { scheduled_at });
|
|
232
|
+
const scheduledAt = response.data?.scheduled_at;
|
|
233
|
+
return {
|
|
234
|
+
content: [
|
|
235
|
+
{
|
|
236
|
+
type: 'text',
|
|
237
|
+
text: response.message ?? 'Campaign scheduled for delivery.',
|
|
238
|
+
},
|
|
239
|
+
...(scheduledAt
|
|
240
|
+
? [
|
|
241
|
+
{
|
|
242
|
+
type: 'text',
|
|
243
|
+
text: `Scheduled at: ${scheduledAt}`,
|
|
244
|
+
},
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
server.registerTool('unschedule-campaign', {
|
|
251
|
+
title: 'Unschedule Campaign',
|
|
252
|
+
description: 'Cancel a scheduled send, returning the campaign to draft. The campaign must currently be scheduled.',
|
|
253
|
+
inputSchema: {
|
|
254
|
+
campaignId: z
|
|
255
|
+
.string()
|
|
256
|
+
.nonempty()
|
|
257
|
+
.describe('The campaign ID to unschedule'),
|
|
258
|
+
},
|
|
259
|
+
}, async ({ campaignId }) => {
|
|
260
|
+
const response = await lettr.post(`/campaigns/${encodeURIComponent(campaignId)}/unschedule`);
|
|
261
|
+
return {
|
|
262
|
+
content: [
|
|
263
|
+
{
|
|
264
|
+
type: 'text',
|
|
265
|
+
text: response.message ?? 'Campaign unscheduled; returned to draft.',
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export * from './audience-memberships.js';
|
|
|
4
4
|
export * from './audience-properties.js';
|
|
5
5
|
export * from './audience-segments.js';
|
|
6
6
|
export * from './audience-topics.js';
|
|
7
|
+
export * from './campaigns.js';
|
|
7
8
|
export * from './domains.js';
|
|
8
9
|
export * from './emails.js';
|
|
9
10
|
export * from './projects.js';
|
package/package.json
CHANGED