lettr-mcp 1.1.1 → 1.2.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/README.md CHANGED
@@ -19,6 +19,7 @@ The official [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) se
19
19
  - **Domains** — List, create, get, delete, and [verify sending domains](https://docs.lettr.com/learn/domains/sending-domains). View DNS records required for SPF, DKIM, and DMARC authentication.
20
20
  - **Webhooks** — List, create, get, update, and delete [webhook configurations](https://docs.lettr.com/learn/webhooks/introduction) for real-time email event notifications.
21
21
  - **Projects** — List the projects available to your team so you can target template and email tools at a specific project.
22
+ - **Audience** — Manage contacts, lists, subscription topics, custom properties, and segments. Create and update contacts (with double opt-in), attach contacts to lists and topics, bulk import, and build segments from match conditions.
22
23
  - **System** — Health check and API key validation for client setup and diagnostics.
23
24
 
24
25
  ## Setup
@@ -139,6 +140,44 @@ Environment variables:
139
140
  |------|-------------|
140
141
  | `list-projects` | List projects owned by the team — useful for discovering project IDs |
141
142
 
143
+ ### Audience
144
+
145
+ | Tool | Description |
146
+ |------|-------------|
147
+ | `list-audience-lists` | List audience (contact) lists with pagination |
148
+ | `create-audience-list` | Create a new audience list |
149
+ | `get-audience-list` | Get a single list and its contact count |
150
+ | `update-audience-list` | Rename an audience list |
151
+ | `delete-audience-list` | Delete an audience list |
152
+ | `bulk-delete-audience-lists` | Delete up to 50 lists in one call |
153
+ | `list-audience-contacts` | List contacts with search, status, list, and segment filters |
154
+ | `get-audience-contact` | Get a contact with its properties, lists, and topics |
155
+ | `create-audience-contact` | Create a contact, optionally with double opt-in |
156
+ | `bulk-create-audience-contacts` | Create many contacts from a list of emails |
157
+ | `update-audience-contact` | Update a contact's email, status, or properties |
158
+ | `delete-audience-contact` | Delete a contact |
159
+ | `attach-contact-to-list` | Add a contact to a list |
160
+ | `detach-contact-from-list` | Remove a contact from a list |
161
+ | `subscribe-contact-to-topic` | Subscribe a contact to a topic |
162
+ | `unsubscribe-contact-from-topic` | Unsubscribe a contact from a topic |
163
+ | `bulk-attach-contacts-to-lists` | Attach many contacts to many lists at once |
164
+ | `bulk-detach-contacts-from-lists` | Detach many contacts from many lists at once |
165
+ | `list-audience-topics` | List subscription topics with pagination |
166
+ | `create-audience-topic` | Create a subscription topic |
167
+ | `get-audience-topic` | Get a single topic |
168
+ | `update-audience-topic` | Update a topic's name, description, or visibility |
169
+ | `delete-audience-topic` | Delete a subscription topic |
170
+ | `list-audience-properties` | List custom contact properties |
171
+ | `create-audience-property` | Define a new custom property |
172
+ | `get-audience-property` | Get a single property |
173
+ | `update-audience-property` | Update a property's fallback value |
174
+ | `delete-audience-property` | Delete a custom property |
175
+ | `list-audience-segments` | List segments, optionally filtered by list |
176
+ | `create-audience-segment` | Create a segment from match conditions |
177
+ | `get-audience-segment` | Get a single segment and its conditions |
178
+ | `update-audience-segment` | Update a segment's name, list, or conditions |
179
+ | `delete-audience-segment` | Delete a segment |
180
+
142
181
  ### System
143
182
 
144
183
  | Tool | Description |
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 { addDomainTools, addEmailTools, addProjectTools, addSystemTools, addTemplateTools, addWebhookTools, } from './tools/index.js';
7
+ import { addAudienceContactTools, addAudienceListTools, addAudienceMembershipTools, addAudiencePropertyTools, addAudienceSegmentTools, addAudienceTopicTools, 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;
@@ -25,6 +25,12 @@ addTemplateTools(server, lettr);
25
25
  addDomainTools(server, lettr);
26
26
  addWebhookTools(server, lettr);
27
27
  addProjectTools(server, lettr);
28
+ addAudienceListTools(server, lettr);
29
+ addAudienceContactTools(server, lettr);
30
+ addAudienceMembershipTools(server, lettr);
31
+ addAudienceTopicTools(server, lettr);
32
+ addAudiencePropertyTools(server, lettr);
33
+ addAudienceSegmentTools(server, lettr);
28
34
  addSystemTools(server, lettr);
29
35
  async function main() {
30
36
  const transport = new StdioServerTransport();
package/dist/lettr.js CHANGED
@@ -18,7 +18,11 @@ export class LettrClient {
18
18
  Accept: 'application/json',
19
19
  };
20
20
  const options = { method, headers };
21
- if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
21
+ if (body &&
22
+ (method === 'POST' ||
23
+ method === 'PUT' ||
24
+ method === 'PATCH' ||
25
+ method === 'DELETE')) {
22
26
  headers['Content-Type'] = 'application/json';
23
27
  options.body = JSON.stringify(body);
24
28
  }
@@ -45,7 +49,10 @@ export class LettrClient {
45
49
  async put(path, body, query) {
46
50
  return this.request('PUT', path, body, query);
47
51
  }
48
- async delete(path, query) {
49
- return this.request('DELETE', path, undefined, query);
52
+ async patch(path, body, query) {
53
+ return this.request('PATCH', path, body, query);
54
+ }
55
+ async delete(path, body, query) {
56
+ return this.request('DELETE', path, body, query);
50
57
  }
51
58
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lettr-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for the Lettr email API — send transactional emails, manage templates, domains, and webhooks from any AI assistant",
5
5
  "keywords": [
6
6
  "lettr",
@@ -0,0 +1,256 @@
1
+ import { z } from 'zod';
2
+ // The API stores every custom property value as a string (max 1000 chars).
3
+ // On update, a null value removes the property from the contact.
4
+ const createPropertyValue = z.string().max(1000);
5
+ const updatePropertyValue = z.string().max(1000).nullable();
6
+ function formatContact(c) {
7
+ const props = Object.entries(c.properties ?? {});
8
+ const lines = [
9
+ `ID: ${c.id}`,
10
+ `Email: ${c.email}`,
11
+ `Status: ${c.status}`,
12
+ `Created: ${c.created_at}`,
13
+ `Lists: ${c.lists.length > 0 ? c.lists.map((l) => `${l.name} (${l.id})`).join(', ') : 'none'}`,
14
+ `Topics: ${c.topics.length > 0 ? c.topics.map((t) => `${t.name} (${t.id})`).join(', ') : 'none'}`,
15
+ ];
16
+ if (props.length > 0) {
17
+ lines.push(`Properties:\n${props.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join('\n')}`);
18
+ }
19
+ return lines.join('\n');
20
+ }
21
+ export function addAudienceContactTools(server, lettr) {
22
+ server.registerTool('list-audience-contacts', {
23
+ title: 'List Audience Contacts',
24
+ description: 'List audience contacts with pagination and optional filters. Filter by free-text search (email or name), status, a specific list, or a specific segment.',
25
+ inputSchema: {
26
+ per_page: z
27
+ .number()
28
+ .int()
29
+ .min(1)
30
+ .max(100)
31
+ .optional()
32
+ .describe('Results per page (1-100, default 20)'),
33
+ page: z
34
+ .number()
35
+ .int()
36
+ .min(1)
37
+ .optional()
38
+ .describe('Page number (default 1)'),
39
+ search: z
40
+ .string()
41
+ .max(255)
42
+ .optional()
43
+ .describe('Search by email address or contact name'),
44
+ status: z
45
+ .enum([
46
+ 'subscribed',
47
+ 'unsubscribed',
48
+ 'bounced',
49
+ 'complained',
50
+ 'unverified',
51
+ ])
52
+ .optional()
53
+ .describe('Filter by contact status'),
54
+ list_id: z
55
+ .string()
56
+ .optional()
57
+ .describe('Filter to contacts in a specific list (must belong to your team)'),
58
+ segment_id: z
59
+ .string()
60
+ .optional()
61
+ .describe('Filter to contacts matching a specific segment (must belong to your team)'),
62
+ },
63
+ }, async ({ per_page, page, search, status, list_id, segment_id }) => {
64
+ const query = {};
65
+ if (per_page)
66
+ query.per_page = per_page;
67
+ if (page)
68
+ query.page = page;
69
+ if (search)
70
+ query.search = search;
71
+ if (status)
72
+ query.status = status;
73
+ if (list_id)
74
+ query.list_id = list_id;
75
+ if (segment_id)
76
+ query.segment_id = segment_id;
77
+ const response = await lettr.get('/audience/contacts', query);
78
+ const { contacts, pagination } = response.data;
79
+ if (contacts.length === 0) {
80
+ return { content: [{ type: 'text', text: 'No contacts found.' }] };
81
+ }
82
+ const lines = contacts
83
+ .map((c) => `- ${c.email} (id: ${c.id}, status: ${c.status})`)
84
+ .join('\n');
85
+ return {
86
+ content: [
87
+ {
88
+ type: 'text',
89
+ text: `Found ${pagination.total} contact(s) — page ${pagination.current_page}/${pagination.last_page}:\n\n${lines}`,
90
+ },
91
+ ],
92
+ };
93
+ });
94
+ server.registerTool('get-audience-contact', {
95
+ title: 'Get Audience Contact',
96
+ description: 'Retrieve a single contact by ID, including status, custom properties, and the lists and topics it belongs to.',
97
+ inputSchema: {
98
+ contactId: z.string().nonempty().describe('The contact ID'),
99
+ },
100
+ }, async ({ contactId }) => {
101
+ const response = await lettr.get(`/audience/contacts/${encodeURIComponent(contactId)}`);
102
+ return {
103
+ content: [
104
+ { type: 'text', text: 'Contact details:' },
105
+ { type: 'text', text: formatContact(response.data) },
106
+ ],
107
+ };
108
+ });
109
+ server.registerTool('create-audience-contact', {
110
+ title: 'Create Audience Contact',
111
+ description: `Create a single audience contact.
112
+
113
+ - \`properties\` keys must match properties already defined for the team (use list-audience-properties).
114
+ - When \`double_opt_in\` is provided, the contact is created in \`unverified\` status and receives a confirmation email; all four of its fields (from, subject, template_slug, redirect_url) are required.`,
115
+ inputSchema: {
116
+ email: z.email().max(255).describe('Contact email address'),
117
+ list_id: z
118
+ .string()
119
+ .optional()
120
+ .describe('Optional list ID to add the contact to'),
121
+ properties: z
122
+ .record(z.string(), createPropertyValue)
123
+ .optional()
124
+ .describe('Custom property values, each as a string (max 1000 chars). Each key must match a property defined for the team.'),
125
+ double_opt_in: z
126
+ .object({
127
+ from: z
128
+ .email()
129
+ .max(255)
130
+ .describe('Sender email for the confirmation email'),
131
+ from_name: z
132
+ .string()
133
+ .max(255)
134
+ .optional()
135
+ .describe('Sender display name'),
136
+ subject: z
137
+ .string()
138
+ .max(998)
139
+ .describe('Subject line of the confirmation email'),
140
+ template_slug: z
141
+ .string()
142
+ .max(255)
143
+ .describe('Template slug used for the confirmation email'),
144
+ redirect_url: z
145
+ .url()
146
+ .max(2048)
147
+ .describe('URL the contact is sent to after confirming'),
148
+ })
149
+ .optional()
150
+ .describe('Double opt-in configuration. When set, the contact is created unverified until they click the confirmation link.'),
151
+ },
152
+ }, async ({ email, list_id, properties, double_opt_in }) => {
153
+ const body = { email };
154
+ if (list_id)
155
+ body.list_id = list_id;
156
+ if (properties)
157
+ body.properties = properties;
158
+ if (double_opt_in)
159
+ body.double_opt_in = double_opt_in;
160
+ const response = await lettr.post('/audience/contacts', body);
161
+ return {
162
+ content: [
163
+ { type: 'text', text: 'Contact created successfully.' },
164
+ { type: 'text', text: formatContact(response.data) },
165
+ ],
166
+ };
167
+ });
168
+ server.registerTool('bulk-create-audience-contacts', {
169
+ title: 'Bulk Create Audience Contacts',
170
+ description: 'Create many contacts at once from a list of email addresses. Optionally add all of them to a list and/or apply the same custom properties to every contact in the batch. Already-existing emails are skipped (reported separately).',
171
+ inputSchema: {
172
+ emails: z
173
+ .array(z.email().max(255))
174
+ .min(1)
175
+ .max(1000)
176
+ .describe('Email addresses to create contacts for (max 1000)'),
177
+ list_id: z
178
+ .string()
179
+ .optional()
180
+ .describe('Optional list ID to add all contacts to'),
181
+ properties: z
182
+ .record(z.string(), createPropertyValue)
183
+ .optional()
184
+ .describe('Custom property values applied to every contact created in this batch, each as a string (max 1000 chars). Each key must match a property defined for the team.'),
185
+ },
186
+ }, async ({ emails, list_id, properties }) => {
187
+ const body = { emails };
188
+ if (list_id)
189
+ body.list_id = list_id;
190
+ if (properties)
191
+ body.properties = properties;
192
+ const response = await lettr.post('/audience/contacts/bulk', body);
193
+ const { created, already_existed } = response.data;
194
+ return {
195
+ content: [
196
+ {
197
+ type: 'text',
198
+ text: `Created ${created} contact(s); ${already_existed} already existed.`,
199
+ },
200
+ ],
201
+ };
202
+ });
203
+ server.registerTool('update-audience-contact', {
204
+ title: 'Update Audience Contact',
205
+ description: `Update a contact. All fields are optional — only provided fields change.
206
+
207
+ - \`status\` may only be set to \`subscribed\` or \`unsubscribed\`.
208
+ - \`properties\` is a partial update: include only the keys you want to change. Setting a property to \`null\` removes it from the contact.`,
209
+ inputSchema: {
210
+ contactId: z.string().nonempty().describe('The contact ID to update'),
211
+ email: z
212
+ .email()
213
+ .max(255)
214
+ .optional()
215
+ .describe('New email address (must remain unique within the team)'),
216
+ status: z
217
+ .enum(['subscribed', 'unsubscribed'])
218
+ .optional()
219
+ .describe('New subscription status'),
220
+ properties: z
221
+ .record(z.string(), updatePropertyValue)
222
+ .optional()
223
+ .describe('Partial property update; each value is a string (max 1000 chars). A property set to null is removed from the contact.'),
224
+ },
225
+ }, async ({ contactId, ...rest }) => {
226
+ const body = {};
227
+ for (const [key, value] of Object.entries(rest)) {
228
+ if (value !== undefined)
229
+ body[key] = value;
230
+ }
231
+ const response = await lettr.patch(`/audience/contacts/${encodeURIComponent(contactId)}`, body);
232
+ return {
233
+ content: [
234
+ { type: 'text', text: 'Contact updated successfully.' },
235
+ { type: 'text', text: formatContact(response.data) },
236
+ ],
237
+ };
238
+ });
239
+ server.registerTool('delete-audience-contact', {
240
+ title: 'Delete Audience Contact',
241
+ description: 'Delete a contact. Before using this tool, you MUST double-check with the user that they want to delete this contact. Warn them that this action is irreversible and removes the contact from all lists and topics.',
242
+ inputSchema: {
243
+ contactId: z.string().nonempty().describe('The contact ID to delete'),
244
+ },
245
+ }, async ({ contactId }) => {
246
+ await lettr.delete(`/audience/contacts/${encodeURIComponent(contactId)}`);
247
+ return {
248
+ content: [
249
+ {
250
+ type: 'text',
251
+ text: `Contact "${contactId}" deleted successfully.`,
252
+ },
253
+ ],
254
+ };
255
+ });
256
+ }
@@ -0,0 +1,141 @@
1
+ import { z } from 'zod';
2
+ function formatList(l) {
3
+ return `ID: ${l.id}\nName: ${l.name}\nContacts: ${l.contacts_count}`;
4
+ }
5
+ export function addAudienceListTools(server, lettr) {
6
+ server.registerTool('list-audience-lists', {
7
+ title: 'List Audience Lists',
8
+ description: 'List the audience (contact) lists for your team, with pagination. Use this to discover list IDs to pass to contact and segment tools.',
9
+ inputSchema: {
10
+ per_page: z
11
+ .number()
12
+ .int()
13
+ .min(1)
14
+ .max(100)
15
+ .optional()
16
+ .describe('Results per page (1-100, default 20)'),
17
+ page: z
18
+ .number()
19
+ .int()
20
+ .min(1)
21
+ .optional()
22
+ .describe('Page number (default 1)'),
23
+ },
24
+ }, async ({ per_page, page }) => {
25
+ const query = {};
26
+ if (per_page)
27
+ query.per_page = per_page;
28
+ if (page)
29
+ query.page = page;
30
+ const response = await lettr.get('/audience/lists', query);
31
+ const { lists, pagination } = response.data;
32
+ if (lists.length === 0) {
33
+ return {
34
+ content: [{ type: 'text', text: 'No audience lists found.' }],
35
+ };
36
+ }
37
+ const lines = lists
38
+ .map((l) => `- ${l.name} (id: ${l.id}, contacts: ${l.contacts_count})`)
39
+ .join('\n');
40
+ return {
41
+ content: [
42
+ {
43
+ type: 'text',
44
+ text: `Found ${pagination.total} list(s) — page ${pagination.current_page}/${pagination.last_page}:\n\n${lines}`,
45
+ },
46
+ ],
47
+ };
48
+ });
49
+ server.registerTool('create-audience-list', {
50
+ title: 'Create Audience List',
51
+ description: 'Create a new audience list. The name must be unique within the team.',
52
+ inputSchema: {
53
+ name: z
54
+ .string()
55
+ .nonempty()
56
+ .max(255)
57
+ .describe('List name (must be unique within the team)'),
58
+ },
59
+ }, async ({ name }) => {
60
+ const response = await lettr.post('/audience/lists', { name });
61
+ return {
62
+ content: [
63
+ { type: 'text', text: 'Audience list created successfully.' },
64
+ { type: 'text', text: formatList(response.data) },
65
+ ],
66
+ };
67
+ });
68
+ server.registerTool('get-audience-list', {
69
+ title: 'Get Audience List',
70
+ description: 'Retrieve a single audience list by its ID, including its current contact count.',
71
+ inputSchema: {
72
+ listId: z.string().nonempty().describe('The list ID'),
73
+ },
74
+ }, async ({ listId }) => {
75
+ const response = await lettr.get(`/audience/lists/${encodeURIComponent(listId)}`);
76
+ return {
77
+ content: [
78
+ { type: 'text', text: 'Audience list details:' },
79
+ { type: 'text', text: formatList(response.data) },
80
+ ],
81
+ };
82
+ });
83
+ server.registerTool('update-audience-list', {
84
+ title: 'Update Audience List',
85
+ description: 'Rename an audience list. The new name must remain unique within the team.',
86
+ inputSchema: {
87
+ listId: z.string().nonempty().describe('The list ID to update'),
88
+ name: z
89
+ .string()
90
+ .nonempty()
91
+ .max(255)
92
+ .describe('New list name (must remain unique within the team)'),
93
+ },
94
+ }, async ({ listId, name }) => {
95
+ const response = await lettr.patch(`/audience/lists/${encodeURIComponent(listId)}`, { name });
96
+ return {
97
+ content: [
98
+ { type: 'text', text: 'Audience list updated successfully.' },
99
+ { type: 'text', text: formatList(response.data) },
100
+ ],
101
+ };
102
+ });
103
+ server.registerTool('delete-audience-list', {
104
+ title: 'Delete Audience List',
105
+ description: 'Delete an audience list. Before using this tool, you MUST double-check with the user that they want to delete this list. Warn them that this action is irreversible — the list is removed and its contacts are detached from it.',
106
+ inputSchema: {
107
+ listId: z.string().nonempty().describe('The list ID to delete'),
108
+ },
109
+ }, async ({ listId }) => {
110
+ await lettr.delete(`/audience/lists/${encodeURIComponent(listId)}`);
111
+ return {
112
+ content: [
113
+ {
114
+ type: 'text',
115
+ text: `Audience list "${listId}" deleted successfully.`,
116
+ },
117
+ ],
118
+ };
119
+ });
120
+ server.registerTool('bulk-delete-audience-lists', {
121
+ title: 'Bulk Delete Audience Lists',
122
+ description: 'Delete between 1 and 50 audience lists in a single call. Before using this tool, you MUST double-check with the user. Warn them that this action is irreversible — every listed list is removed and its contacts detached. All IDs must belong to your team.',
123
+ inputSchema: {
124
+ list_ids: z
125
+ .array(z.string().nonempty())
126
+ .min(1)
127
+ .max(50)
128
+ .describe('1–50 list IDs to delete (all must belong to your team)'),
129
+ },
130
+ }, async ({ list_ids }) => {
131
+ const response = await lettr.delete('/audience/lists/bulk', { list_ids });
132
+ return {
133
+ content: [
134
+ {
135
+ type: 'text',
136
+ text: `Deleted ${response.data.deleted} list(s).`,
137
+ },
138
+ ],
139
+ };
140
+ });
141
+ }
@@ -0,0 +1,129 @@
1
+ import { z } from 'zod';
2
+ export function addAudienceMembershipTools(server, lettr) {
3
+ server.registerTool('attach-contact-to-list', {
4
+ title: 'Attach Contact to List',
5
+ description: 'Add a single contact to a single list. Both the contact and the list must belong to your team.',
6
+ inputSchema: {
7
+ contactId: z.string().nonempty().describe('The contact ID'),
8
+ listId: z.string().nonempty().describe('The list ID'),
9
+ },
10
+ }, async ({ contactId, listId }) => {
11
+ await lettr.post(`/audience/contacts/${encodeURIComponent(contactId)}/lists/${encodeURIComponent(listId)}`);
12
+ return {
13
+ content: [
14
+ {
15
+ type: 'text',
16
+ text: `Contact "${contactId}" attached to list "${listId}".`,
17
+ },
18
+ ],
19
+ };
20
+ });
21
+ server.registerTool('detach-contact-from-list', {
22
+ title: 'Detach Contact from List',
23
+ description: 'Remove a single contact from a single list.',
24
+ inputSchema: {
25
+ contactId: z.string().nonempty().describe('The contact ID'),
26
+ listId: z.string().nonempty().describe('The list ID'),
27
+ },
28
+ }, async ({ contactId, listId }) => {
29
+ await lettr.delete(`/audience/contacts/${encodeURIComponent(contactId)}/lists/${encodeURIComponent(listId)}`);
30
+ return {
31
+ content: [
32
+ {
33
+ type: 'text',
34
+ text: `Contact "${contactId}" detached from list "${listId}".`,
35
+ },
36
+ ],
37
+ };
38
+ });
39
+ server.registerTool('subscribe-contact-to-topic', {
40
+ title: 'Subscribe Contact to Topic',
41
+ description: 'Subscribe a single contact to a single topic. Both must belong to your team.',
42
+ inputSchema: {
43
+ contactId: z.string().nonempty().describe('The contact ID'),
44
+ topicId: z.string().nonempty().describe('The topic ID'),
45
+ },
46
+ }, async ({ contactId, topicId }) => {
47
+ await lettr.post(`/audience/contacts/${encodeURIComponent(contactId)}/topics/${encodeURIComponent(topicId)}`);
48
+ return {
49
+ content: [
50
+ {
51
+ type: 'text',
52
+ text: `Contact "${contactId}" subscribed to topic "${topicId}".`,
53
+ },
54
+ ],
55
+ };
56
+ });
57
+ server.registerTool('unsubscribe-contact-from-topic', {
58
+ title: 'Unsubscribe Contact from Topic',
59
+ description: 'Unsubscribe a single contact from a single topic.',
60
+ inputSchema: {
61
+ contactId: z.string().nonempty().describe('The contact ID'),
62
+ topicId: z.string().nonempty().describe('The topic ID'),
63
+ },
64
+ }, async ({ contactId, topicId }) => {
65
+ await lettr.delete(`/audience/contacts/${encodeURIComponent(contactId)}/topics/${encodeURIComponent(topicId)}`);
66
+ return {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: `Contact "${contactId}" unsubscribed from topic "${topicId}".`,
71
+ },
72
+ ],
73
+ };
74
+ });
75
+ server.registerTool('bulk-attach-contacts-to-lists', {
76
+ title: 'Bulk Attach Contacts to Lists',
77
+ description: 'Attach multiple contacts to multiple lists at once. Every contact is attached to every list (a cartesian product of contact_ids × list_ids). All IDs must belong to your team.',
78
+ inputSchema: {
79
+ contact_ids: z
80
+ .array(z.string().nonempty())
81
+ .min(1)
82
+ .max(1000)
83
+ .describe('Contact IDs to attach (max 1000)'),
84
+ list_ids: z
85
+ .array(z.string().nonempty())
86
+ .min(1)
87
+ .max(50)
88
+ .describe('List IDs to attach the contacts to (max 50)'),
89
+ },
90
+ }, async ({ contact_ids, list_ids }) => {
91
+ const response = await lettr.post('/audience/contacts/lists/bulk', { contact_ids, list_ids });
92
+ const { attached, already_attached, total_pairs } = response.data;
93
+ return {
94
+ content: [
95
+ {
96
+ type: 'text',
97
+ text: `Attached ${attached} of ${total_pairs} contact-list pair(s); ${already_attached} were already attached.`,
98
+ },
99
+ ],
100
+ };
101
+ });
102
+ server.registerTool('bulk-detach-contacts-from-lists', {
103
+ title: 'Bulk Detach Contacts from Lists',
104
+ description: 'Detach multiple contacts from multiple lists at once (a cartesian product of contact_ids × list_ids). Before using this tool, you MUST double-check with the user, as it removes many memberships at once.',
105
+ inputSchema: {
106
+ contact_ids: z
107
+ .array(z.string().nonempty())
108
+ .min(1)
109
+ .max(1000)
110
+ .describe('Contact IDs to detach (max 1000)'),
111
+ list_ids: z
112
+ .array(z.string().nonempty())
113
+ .min(1)
114
+ .max(50)
115
+ .describe('List IDs to detach the contacts from (max 50)'),
116
+ },
117
+ }, async ({ contact_ids, list_ids }) => {
118
+ const response = await lettr.delete('/audience/contacts/lists/bulk', { contact_ids, list_ids });
119
+ const { detached, not_present, total_pairs } = response.data;
120
+ return {
121
+ content: [
122
+ {
123
+ type: 'text',
124
+ text: `Detached ${detached} of ${total_pairs} contact-list pair(s); ${not_present} were not attached.`,
125
+ },
126
+ ],
127
+ };
128
+ });
129
+ }
@@ -0,0 +1,140 @@
1
+ import { z } from 'zod';
2
+ function formatProperty(p) {
3
+ return [
4
+ `ID: ${p.id}`,
5
+ `Name: ${p.name}`,
6
+ `Type: ${p.type}`,
7
+ `Fallback: ${p.fallback_value ?? '(none)'}`,
8
+ ].join('\n');
9
+ }
10
+ export function addAudiencePropertyTools(server, lettr) {
11
+ server.registerTool('list-audience-properties', {
12
+ title: 'List Audience Properties',
13
+ description: 'List the custom contact properties defined for your team, with pagination. Use this to discover which property keys are valid when creating or updating contacts.',
14
+ inputSchema: {
15
+ per_page: z
16
+ .number()
17
+ .int()
18
+ .min(1)
19
+ .max(100)
20
+ .optional()
21
+ .describe('Results per page (1-100, default 20)'),
22
+ page: z
23
+ .number()
24
+ .int()
25
+ .min(1)
26
+ .optional()
27
+ .describe('Page number (default 1)'),
28
+ },
29
+ }, async ({ per_page, page }) => {
30
+ const query = {};
31
+ if (per_page)
32
+ query.per_page = per_page;
33
+ if (page)
34
+ query.page = page;
35
+ const response = await lettr.get('/audience/properties', query);
36
+ const { properties, pagination } = response.data;
37
+ if (properties.length === 0) {
38
+ return { content: [{ type: 'text', text: 'No properties found.' }] };
39
+ }
40
+ const lines = properties
41
+ .map((p) => `- ${p.name} (id: ${p.id}, type: ${p.type}${p.fallback_value !== null ? `, fallback: ${p.fallback_value}` : ''})`)
42
+ .join('\n');
43
+ return {
44
+ content: [
45
+ {
46
+ type: 'text',
47
+ text: `Found ${pagination.total} property(ies) — page ${pagination.current_page}/${pagination.last_page}:\n\n${lines}`,
48
+ },
49
+ ],
50
+ };
51
+ });
52
+ server.registerTool('create-audience-property', {
53
+ title: 'Create Audience Property',
54
+ description: 'Define a new custom contact property. The name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores. The type fixes how values are stored and cannot be changed later.',
55
+ inputSchema: {
56
+ name: z
57
+ .string()
58
+ .nonempty()
59
+ .max(255)
60
+ .regex(/^[a-z][a-z0-9_]*$/, 'Must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.')
61
+ .describe('Property name. Must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.'),
62
+ type: z
63
+ .enum(['string', 'number', 'boolean', 'date', 'json'])
64
+ .describe('Value type for the property'),
65
+ fallback_value: z
66
+ .string()
67
+ .max(255)
68
+ .nullable()
69
+ .optional()
70
+ .describe('Optional default value used when a contact has no value set'),
71
+ },
72
+ }, async ({ name, type, fallback_value }) => {
73
+ const body = { name, type };
74
+ if (fallback_value !== undefined)
75
+ body.fallback_value = fallback_value;
76
+ const response = await lettr.post('/audience/properties', body);
77
+ return {
78
+ content: [
79
+ { type: 'text', text: 'Property created successfully.' },
80
+ { type: 'text', text: formatProperty(response.data) },
81
+ ],
82
+ };
83
+ });
84
+ server.registerTool('get-audience-property', {
85
+ title: 'Get Audience Property',
86
+ description: 'Retrieve a single custom contact property by its ID.',
87
+ inputSchema: {
88
+ propertyId: z.string().nonempty().describe('The property ID'),
89
+ },
90
+ }, async ({ propertyId }) => {
91
+ const response = await lettr.get(`/audience/properties/${encodeURIComponent(propertyId)}`);
92
+ return {
93
+ content: [
94
+ { type: 'text', text: 'Property details:' },
95
+ { type: 'text', text: formatProperty(response.data) },
96
+ ],
97
+ };
98
+ });
99
+ server.registerTool('update-audience-property', {
100
+ title: 'Update Audience Property',
101
+ description: "Update a property's fallback value. The name and type are fixed at creation and cannot be changed. Set fallback_value to null to clear it.",
102
+ inputSchema: {
103
+ propertyId: z.string().nonempty().describe('The property ID to update'),
104
+ fallback_value: z
105
+ .string()
106
+ .max(255)
107
+ .nullable()
108
+ .optional()
109
+ .describe('New fallback value. Set to null to clear it; omit to leave it unchanged.'),
110
+ },
111
+ }, async ({ propertyId, fallback_value }) => {
112
+ const body = {};
113
+ if (fallback_value !== undefined)
114
+ body.fallback_value = fallback_value;
115
+ const response = await lettr.patch(`/audience/properties/${encodeURIComponent(propertyId)}`, body);
116
+ return {
117
+ content: [
118
+ { type: 'text', text: 'Property updated successfully.' },
119
+ { type: 'text', text: formatProperty(response.data) },
120
+ ],
121
+ };
122
+ });
123
+ server.registerTool('delete-audience-property', {
124
+ title: 'Delete Audience Property',
125
+ description: 'Delete a custom contact property. Before using this tool, you MUST double-check with the user that they want to delete this property. Warn them that this action is irreversible and removes the property value from every contact.',
126
+ inputSchema: {
127
+ propertyId: z.string().nonempty().describe('The property ID to delete'),
128
+ },
129
+ }, async ({ propertyId }) => {
130
+ await lettr.delete(`/audience/properties/${encodeURIComponent(propertyId)}`);
131
+ return {
132
+ content: [
133
+ {
134
+ type: 'text',
135
+ text: `Property "${propertyId}" deleted successfully.`,
136
+ },
137
+ ],
138
+ };
139
+ });
140
+ }
@@ -0,0 +1,222 @@
1
+ import { z } from 'zod';
2
+ const SEGMENT_OPERATORS = [
3
+ 'contains',
4
+ 'not_contains',
5
+ 'equals',
6
+ 'not_equals',
7
+ 'starts_with',
8
+ 'not_starts_with',
9
+ 'ends_with',
10
+ 'not_ends_with',
11
+ 'is_true',
12
+ 'is_false',
13
+ 'greater_than',
14
+ 'greater_than_or_equal',
15
+ 'less_than',
16
+ 'less_than_or_equal',
17
+ 'before',
18
+ 'after',
19
+ ];
20
+ // Request shape: groups are nested under `conditions.groups`. Each group's
21
+ // `conditions` are ORed; groups are ANDed. `value` is omitted for the
22
+ // is_true / is_false operators.
23
+ const conditionsSchema = z
24
+ .object({
25
+ groups: z
26
+ .array(z.object({
27
+ conditions: z
28
+ .array(z.object({
29
+ field: z
30
+ .string()
31
+ .max(255)
32
+ .describe('Field to match against. Built-in fields: "email", "status", "created_at". For a custom property, use the "properties." prefix, e.g. "properties.age" (a bare property name will NOT work). You can also match "topics" or "lists" by name (equals/not_equals only). An unrecognized field is silently ignored, which makes the segment match every contact — double-check the field name.'),
33
+ operator: z
34
+ .enum(SEGMENT_OPERATORS)
35
+ .describe('Comparison operator'),
36
+ value: z
37
+ .string()
38
+ .max(1000)
39
+ .nullable()
40
+ .optional()
41
+ .describe('Value to compare against, as a string. Required for all operators except is_true and is_false, which take no value.'),
42
+ }))
43
+ .min(1)
44
+ .describe('Conditions within a group are ORed together'),
45
+ }))
46
+ .min(1)
47
+ .describe('Groups are ANDed together'),
48
+ })
49
+ .describe('Segment match rules. `groups` are ANDed; conditions within a group are ORed.');
50
+ // Mirror the API contract: every operator except is_true/is_false needs a value.
51
+ // Surface a clear error before the request rather than a generic 422.
52
+ function assertConditionValues(conditions) {
53
+ for (const group of conditions.groups) {
54
+ for (const c of group.conditions) {
55
+ const valueless = c.operator === 'is_true' || c.operator === 'is_false';
56
+ if (!valueless &&
57
+ (c.value === undefined || c.value === null || c.value === '')) {
58
+ throw new Error(`Operator "${c.operator}" on field "${c.field}" requires a value.`);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ function formatSegment(s) {
64
+ const groups = s.condition_groups
65
+ .map((g, i) => {
66
+ const conds = g.conditions
67
+ .map((c) => ` ${c.field} ${c.operator}${c.value != null ? ` ${JSON.stringify(c.value)}` : ''}`)
68
+ .join('\n');
69
+ return ` Group ${i + 1} (any of):\n${conds}`;
70
+ })
71
+ .join('\n');
72
+ return [
73
+ `ID: ${s.id}`,
74
+ `Name: ${s.name}`,
75
+ `List: ${s.list_name ? `${s.list_name} (${s.list_id})` : 'all contacts'}`,
76
+ `Cached contacts: ${s.cached_contacts_count ?? 'not computed'}`,
77
+ s.condition_groups.length > 0
78
+ ? `Conditions:\n${groups}`
79
+ : 'Conditions: none',
80
+ ].join('\n');
81
+ }
82
+ export function addAudienceSegmentTools(server, lettr) {
83
+ server.registerTool('list-audience-segments', {
84
+ title: 'List Audience Segments',
85
+ description: 'List audience segments for your team, with pagination. Optionally filter to segments restricted to a specific list.',
86
+ inputSchema: {
87
+ per_page: z
88
+ .number()
89
+ .int()
90
+ .min(1)
91
+ .max(100)
92
+ .optional()
93
+ .describe('Results per page (1-100, default 20)'),
94
+ page: z
95
+ .number()
96
+ .int()
97
+ .min(1)
98
+ .optional()
99
+ .describe('Page number (default 1)'),
100
+ list_id: z
101
+ .string()
102
+ .optional()
103
+ .describe('Filter to segments restricted to a specific list (must belong to your team)'),
104
+ },
105
+ }, async ({ per_page, page, list_id }) => {
106
+ const query = {};
107
+ if (per_page)
108
+ query.per_page = per_page;
109
+ if (page)
110
+ query.page = page;
111
+ if (list_id)
112
+ query.list_id = list_id;
113
+ const response = await lettr.get('/audience/segments', query);
114
+ const { segments, pagination } = response.data;
115
+ if (segments.length === 0) {
116
+ return { content: [{ type: 'text', text: 'No segments found.' }] };
117
+ }
118
+ const lines = segments
119
+ .map((s) => `- ${s.name} (id: ${s.id}${s.list_name ? `, list: ${s.list_name}` : ''}, contacts: ${s.cached_contacts_count ?? 'n/a'})`)
120
+ .join('\n');
121
+ return {
122
+ content: [
123
+ {
124
+ type: 'text',
125
+ text: `Found ${pagination.total} segment(s) — page ${pagination.current_page}/${pagination.last_page}:\n\n${lines}`,
126
+ },
127
+ ],
128
+ };
129
+ });
130
+ server.registerTool('create-audience-segment', {
131
+ title: 'Create Audience Segment',
132
+ description: `Create an audience segment defined by match conditions.
133
+
134
+ Conditions are grouped: conditions within a group are ORed together, and groups are ANDed together. The is_true and is_false operators take no value; all other operators require a value. Optionally restrict the segment to a single list with list_id.`,
135
+ inputSchema: {
136
+ name: z.string().nonempty().max(255).describe('Segment name'),
137
+ list_id: z
138
+ .string()
139
+ .optional()
140
+ .describe('Restrict the segment to a single list (must belong to your team)'),
141
+ conditions: conditionsSchema,
142
+ },
143
+ }, async ({ name, list_id, conditions }) => {
144
+ assertConditionValues(conditions);
145
+ const body = { name, conditions };
146
+ if (list_id)
147
+ body.list_id = list_id;
148
+ const response = await lettr.post('/audience/segments', body);
149
+ return {
150
+ content: [
151
+ { type: 'text', text: 'Segment created successfully.' },
152
+ { type: 'text', text: formatSegment(response.data) },
153
+ ],
154
+ };
155
+ });
156
+ server.registerTool('get-audience-segment', {
157
+ title: 'Get Audience Segment',
158
+ description: 'Retrieve a single audience segment by its ID, including its condition groups and cached contact count.',
159
+ inputSchema: {
160
+ segmentId: z.string().nonempty().describe('The segment ID'),
161
+ },
162
+ }, async ({ segmentId }) => {
163
+ const response = await lettr.get(`/audience/segments/${encodeURIComponent(segmentId)}`);
164
+ return {
165
+ content: [
166
+ { type: 'text', text: 'Segment details:' },
167
+ { type: 'text', text: formatSegment(response.data) },
168
+ ],
169
+ };
170
+ });
171
+ server.registerTool('update-audience-segment', {
172
+ title: 'Update Audience Segment',
173
+ description: 'Update an audience segment. All fields are optional — only provided fields change. Providing conditions replaces the entire condition set.',
174
+ inputSchema: {
175
+ segmentId: z.string().nonempty().describe('The segment ID to update'),
176
+ name: z
177
+ .string()
178
+ .nonempty()
179
+ .max(255)
180
+ .optional()
181
+ .describe('New segment name'),
182
+ list_id: z
183
+ .string()
184
+ .nullable()
185
+ .optional()
186
+ .describe('New list restriction (null removes the restriction)'),
187
+ conditions: conditionsSchema.optional(),
188
+ },
189
+ }, async ({ segmentId, ...rest }) => {
190
+ if (rest.conditions)
191
+ assertConditionValues(rest.conditions);
192
+ const body = {};
193
+ for (const [key, value] of Object.entries(rest)) {
194
+ if (value !== undefined)
195
+ body[key] = value;
196
+ }
197
+ const response = await lettr.patch(`/audience/segments/${encodeURIComponent(segmentId)}`, body);
198
+ return {
199
+ content: [
200
+ { type: 'text', text: 'Segment updated successfully.' },
201
+ { type: 'text', text: formatSegment(response.data) },
202
+ ],
203
+ };
204
+ });
205
+ server.registerTool('delete-audience-segment', {
206
+ title: 'Delete Audience Segment',
207
+ description: 'Delete an audience segment. Before using this tool, you MUST double-check with the user that they want to delete this segment. Warn them that this action is irreversible. (Contacts themselves are not deleted.)',
208
+ inputSchema: {
209
+ segmentId: z.string().nonempty().describe('The segment ID to delete'),
210
+ },
211
+ }, async ({ segmentId }) => {
212
+ await lettr.delete(`/audience/segments/${encodeURIComponent(segmentId)}`);
213
+ return {
214
+ content: [
215
+ {
216
+ type: 'text',
217
+ text: `Segment "${segmentId}" deleted successfully.`,
218
+ },
219
+ ],
220
+ };
221
+ });
222
+ }
@@ -0,0 +1,154 @@
1
+ import { z } from 'zod';
2
+ function formatTopic(t) {
3
+ return [
4
+ `ID: ${t.id}`,
5
+ `Name: ${t.name}`,
6
+ `Description: ${t.description || '(none)'}`,
7
+ `Default subscription: ${t.default_subscription}`,
8
+ `Visibility: ${t.visibility}`,
9
+ `Contacts: ${t.contacts_count}`,
10
+ ].join('\n');
11
+ }
12
+ export function addAudienceTopicTools(server, lettr) {
13
+ server.registerTool('list-audience-topics', {
14
+ title: 'List Audience Topics',
15
+ description: 'List subscription topics for your team, with pagination. Use this to discover topic IDs to subscribe contacts to.',
16
+ inputSchema: {
17
+ per_page: z
18
+ .number()
19
+ .int()
20
+ .min(1)
21
+ .max(100)
22
+ .optional()
23
+ .describe('Results per page (1-100, default 20)'),
24
+ page: z
25
+ .number()
26
+ .int()
27
+ .min(1)
28
+ .optional()
29
+ .describe('Page number (default 1)'),
30
+ },
31
+ }, async ({ per_page, page }) => {
32
+ const query = {};
33
+ if (per_page)
34
+ query.per_page = per_page;
35
+ if (page)
36
+ query.page = page;
37
+ const response = await lettr.get('/audience/topics', query);
38
+ const { topics, pagination } = response.data;
39
+ if (topics.length === 0) {
40
+ return { content: [{ type: 'text', text: 'No topics found.' }] };
41
+ }
42
+ const lines = topics
43
+ .map((t) => `- ${t.name} (id: ${t.id}, ${t.visibility}, default ${t.default_subscription}, contacts: ${t.contacts_count})`)
44
+ .join('\n');
45
+ return {
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: `Found ${pagination.total} topic(s) — page ${pagination.current_page}/${pagination.last_page}:\n\n${lines}`,
50
+ },
51
+ ],
52
+ };
53
+ });
54
+ server.registerTool('create-audience-topic', {
55
+ title: 'Create Audience Topic',
56
+ description: 'Create a subscription topic. default_subscription controls whether contacts are opted in or out by default (defaults to opt_in); visibility controls whether the topic is shown on public preference pages (defaults to private).',
57
+ inputSchema: {
58
+ name: z.string().nonempty().max(255).describe('Topic name'),
59
+ description: z
60
+ .string()
61
+ .max(1000)
62
+ .optional()
63
+ .describe('Optional description of the topic'),
64
+ default_subscription: z
65
+ .enum(['opt_in', 'opt_out'])
66
+ .optional()
67
+ .describe('Default subscription state for contacts (default opt_in)'),
68
+ visibility: z
69
+ .enum(['private', 'public'])
70
+ .optional()
71
+ .describe('Topic visibility (default private)'),
72
+ },
73
+ }, async ({ name, description, default_subscription, visibility }) => {
74
+ const body = { name };
75
+ if (description !== undefined)
76
+ body.description = description;
77
+ if (default_subscription)
78
+ body.default_subscription = default_subscription;
79
+ if (visibility)
80
+ body.visibility = visibility;
81
+ const response = await lettr.post('/audience/topics', body);
82
+ return {
83
+ content: [
84
+ { type: 'text', text: 'Topic created successfully.' },
85
+ { type: 'text', text: formatTopic(response.data) },
86
+ ],
87
+ };
88
+ });
89
+ server.registerTool('get-audience-topic', {
90
+ title: 'Get Audience Topic',
91
+ description: 'Retrieve a single subscription topic by its ID.',
92
+ inputSchema: {
93
+ topicId: z.string().nonempty().describe('The topic ID'),
94
+ },
95
+ }, async ({ topicId }) => {
96
+ const response = await lettr.get(`/audience/topics/${encodeURIComponent(topicId)}`);
97
+ return {
98
+ content: [
99
+ { type: 'text', text: 'Topic details:' },
100
+ { type: 'text', text: formatTopic(response.data) },
101
+ ],
102
+ };
103
+ });
104
+ server.registerTool('update-audience-topic', {
105
+ title: 'Update Audience Topic',
106
+ description: 'Update a subscription topic. All fields are optional — only provided fields change. Note that default_subscription cannot be changed after creation.',
107
+ inputSchema: {
108
+ topicId: z.string().nonempty().describe('The topic ID to update'),
109
+ name: z
110
+ .string()
111
+ .nonempty()
112
+ .max(255)
113
+ .optional()
114
+ .describe('New topic name'),
115
+ description: z
116
+ .string()
117
+ .max(1000)
118
+ .nullable()
119
+ .optional()
120
+ .describe('New description (null clears it)'),
121
+ visibility: z
122
+ .enum(['private', 'public'])
123
+ .optional()
124
+ .describe('New visibility'),
125
+ },
126
+ }, async ({ topicId, ...rest }) => {
127
+ const body = {};
128
+ for (const [key, value] of Object.entries(rest)) {
129
+ if (value !== undefined)
130
+ body[key] = value;
131
+ }
132
+ const response = await lettr.patch(`/audience/topics/${encodeURIComponent(topicId)}`, body);
133
+ return {
134
+ content: [
135
+ { type: 'text', text: 'Topic updated successfully.' },
136
+ { type: 'text', text: formatTopic(response.data) },
137
+ ],
138
+ };
139
+ });
140
+ server.registerTool('delete-audience-topic', {
141
+ title: 'Delete Audience Topic',
142
+ description: 'Delete a subscription topic. Before using this tool, you MUST double-check with the user that they want to delete this topic. Warn them that this action is irreversible and removes all contact subscriptions to it.',
143
+ inputSchema: {
144
+ topicId: z.string().nonempty().describe('The topic ID to delete'),
145
+ },
146
+ }, async ({ topicId }) => {
147
+ await lettr.delete(`/audience/topics/${encodeURIComponent(topicId)}`);
148
+ return {
149
+ content: [
150
+ { type: 'text', text: `Topic "${topicId}" deleted successfully.` },
151
+ ],
152
+ };
153
+ });
154
+ }
@@ -1,3 +1,9 @@
1
+ export * from './audience-contacts.js';
2
+ export * from './audience-lists.js';
3
+ export * from './audience-memberships.js';
4
+ export * from './audience-properties.js';
5
+ export * from './audience-segments.js';
6
+ export * from './audience-topics.js';
1
7
  export * from './domains.js';
2
8
  export * from './emails.js';
3
9
  export * from './projects.js';
@@ -217,7 +217,7 @@ export function addTemplateTools(server, lettr) {
217
217
  const query = {};
218
218
  if (project_id)
219
219
  query.project_id = project_id;
220
- await lettr.delete(`/templates/${encodeURIComponent(slug)}`, query);
220
+ await lettr.delete(`/templates/${encodeURIComponent(slug)}`, undefined, query);
221
221
  return {
222
222
  content: [
223
223
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lettr-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for the Lettr email API — send transactional emails, manage templates, domains, and webhooks from any AI assistant",
5
5
  "keywords": [
6
6
  "lettr",