@mailpace/mcp 0.0.0-stage → 2.0.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/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright © 2025 OhMySMTP Ltd
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,179 @@
1
- # Temporary Holding Version
1
+ # MailPace MCP (Model Context Protocol) Server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MailPace MCP Server exposes the [MailPace](https://mailpace.com) transactional email, domain management, and email status APIs to MCP clients.
4
+
5
+ ## Features
6
+
7
+ - Send transactional email with the [Send API](https://docs.mailpace.com/reference/send)
8
+ - List, create, update, and verify domains with the [Domains API](https://docs.mailpace.com/reference/domains)
9
+ - Manage domain API tokens
10
+ - Check delivery, bounce, and deferral status with the [Email Status API](https://docs.mailpace.com/reference/email-status)
11
+ - Optional recipient allowlist and idempotent sends
12
+ - Tools expose structured output schemas and MCP behavior annotations
13
+
14
+ ## Requirements
15
+
16
+ - Node.js 20 or newer
17
+ - A MailPace organization and domain
18
+ - A **domain API token** to send email and check email status for that domain
19
+ - An **organization API token** to manage domains and domain API tokens
20
+
21
+ ## Configuration
22
+
23
+ Credentials are read from environment variables only. They are never accepted as command-line arguments, which would expose them in process listings.
24
+
25
+ | Variable | Required | Description |
26
+ | --- | --- | --- |
27
+ | `MAILPACE_API_TOKEN` | For sending and domain-scoped status | Domain API token |
28
+ | `MAILPACE_ORGANIZATION_TOKEN` | For domain management | Organization API token |
29
+ | `MAILPACE_ALLOWED_RECIPIENTS` | No | Comma-separated allowlist of exact addresses or `@domain` rules. When set, `mailpace_send_email` refuses other recipients. |
30
+ | `MAILPACE_API_URL` | No | Override the API base URL. Must be HTTPS except for localhost development. |
31
+ | `MAILPACE_REQUEST_TIMEOUT_MS` | No | Per-request timeout in milliseconds. Default `30000`. |
32
+ | `MAILPACE_MAX_RETRIES` | No | Retries for idempotent requests and 429 responses. Default `2`. |
33
+
34
+ At least one of `MAILPACE_API_TOKEN` or `MAILPACE_ORGANIZATION_TOKEN` is required. Tools are registered according to the available credentials.
35
+
36
+ ## Tools
37
+
38
+ | Tool | Credential | Description |
39
+ | --- | --- | --- |
40
+ | `mailpace_send_email` | Domain token | Send an email. Supports attachments, tags, and `Idempotency-Key`. |
41
+ | `mailpace_get_email_status` | Domain or organization token | Get delivery, bounce, or deferral status by domain and email ID. |
42
+ | `mailpace_list_domains` | Organization token | List domains with pagination and sorting. |
43
+ | `mailpace_get_domain` | Organization token | Get a domain by ID. |
44
+ | `mailpace_create_domain` | Organization token | Create a sending domain. |
45
+ | `mailpace_update_domain` | Organization token | Update a domain's name or URL. |
46
+ | `mailpace_verify_domain` | Organization token | Check DKIM and advanced verification DNS records. |
47
+ | `mailpace_list_domain_api_tokens` | Organization token | List a domain's API tokens. |
48
+ | `mailpace_get_domain_api_token` | Organization token | Get one domain API token. |
49
+ | `mailpace_create_domain_api_token` | Organization token | Create a domain API token. Store the returned token securely. |
50
+ | `mailpace_update_domain_api_token` | Organization token | Rename a domain API token. |
51
+ | `mailpace_revoke_domain_api_token` | Organization token | Revoke a domain API token. This cannot be undone. |
52
+
53
+ ### Sending email safely
54
+
55
+ `mailpace_send_email` is an external side effect. Clients and hosts should confirm recipients before invoking it. For automation, set `MAILPACE_ALLOWED_RECIPIENTS` to reduce the impact of mistakes or prompt injection, for example:
56
+
57
+ ```bash
58
+ MAILPACE_ALLOWED_RECIPIENTS="hello@example.com,@yourcompany.com"
59
+ ```
60
+
61
+ Use an `idempotency_key` when retrying a send to prevent duplicate email delivery.
62
+
63
+ ## Client configuration
64
+
65
+ After building locally, point your MCP client at the built entry point:
66
+
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "mailpace": {
71
+ "command": "node",
72
+ "args": ["/absolute/path/to/mailpace-mcp/build/index.js"],
73
+ "env": {
74
+ "MAILPACE_API_TOKEN": "your-domain-api-token",
75
+ "MAILPACE_ORGANIZATION_TOKEN": "your-organization-api-token"
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ Once published, this can also be run with `npx -y @mailpace/mcp`.
83
+
84
+ ## Build and run
85
+
86
+ ```bash
87
+ npm ci
88
+ npm run build
89
+ npm start
90
+ ```
91
+
92
+ Development uses `tsx` and TypeScript source directly:
93
+
94
+ ```bash
95
+ npm run dev
96
+ ```
97
+
98
+ ## Testing
99
+
100
+ ```bash
101
+ npm test
102
+ ```
103
+
104
+ The integration tests start the built server, connect over stdio, and exercise tool registration, request shapes, validation, error mapping, retries, and timeouts against a local mock MailPace API.
105
+
106
+ ## Docker
107
+
108
+ ```bash
109
+ docker build -t mailpace-mcp .
110
+ docker run --rm -i \
111
+ -e MAILPACE_API_TOKEN=your-domain-api-token \
112
+ -e MAILPACE_ORGANIZATION_TOKEN=your-organization-api-token \
113
+ mailpace-mcp
114
+ ```
115
+
116
+ The image runs as the non-root `node` user and installs only production dependencies.
117
+
118
+ ## Publishing
119
+
120
+ The package is published as `@mailpace/mcp` on npm and registered with the [Official MCP Registry](https://registry.modelcontextprotocol.io) as:
121
+
122
+ ```text
123
+ com.mailpace/mailpace-mcp
124
+ ```
125
+
126
+ `package.json` contains the matching `mcpName` field, and `server.json` contains the registry metadata.
127
+
128
+ ### One-time setup
129
+
130
+ 1. Create an npm automation token with publish access and add it as the `NPM_TOKEN` repository secret.
131
+ 2. Generate an Ed25519 key pair for DNS authentication:
132
+
133
+ ```bash
134
+ openssl genpkey -algorithm Ed25519 -out key.pem
135
+ PUBLIC_KEY="$(openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64)"
136
+ echo "mailpace.com. IN TXT \"v=MCPv1; k=ed25519; p=${PUBLIC_KEY}\""
137
+ ```
138
+
139
+ 3. Add the printed TXT record to `mailpace.com` DNS.
140
+ 4. Add the private key as the `MCP_PRIVATE_KEY` repository secret:
141
+
142
+ ```bash
143
+ openssl pkey -in key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n'
144
+ ```
145
+
146
+ Do not commit `key.pem`; it is gitignored.
147
+
148
+ ### Releasing
149
+
150
+ Update `package.json` and `server.json` to the new version, commit, then push a matching tag:
151
+
152
+ ```bash
153
+ git tag v2.0.0
154
+ git push origin v2.0.0
155
+ ```
156
+
157
+ The `Release` workflow runs typechecking and tests, verifies the tag matches `package.json`, publishes to npm, waits for the package to appear, and publishes the version metadata to the Official MCP Registry.
158
+
159
+ ## Security
160
+
161
+ - Tokens are read from the environment only.
162
+ - Use a domain token for sending and an organization token only when domain management is needed.
163
+ - Keep `MAILPACE_ALLOWED_RECIPIENTS` set in unattended deployments.
164
+ - Revoke unused domain API tokens from the MailPace dashboard.
165
+
166
+ ## Contributing
167
+
168
+ 1. Fork the repository.
169
+ 2. Create a new branch for your feature or bugfix.
170
+ 3. Commit your changes and push the branch to your fork.
171
+ 4. Create a pull request with a detailed description of your changes.
172
+
173
+ ## License
174
+
175
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE.md) file for details.
176
+
177
+ ## Contact
178
+
179
+ For any questions or support, please contact us at support@mailpace.com.
package/build/index.js ADDED
@@ -0,0 +1,800 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/server";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
4
+ import { createRequire } from "node:module";
5
+ import * as z from "zod/v4";
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require("../package.json");
8
+ const apiToken = process.env.MAILPACE_API_TOKEN;
9
+ const organizationToken = process.env.MAILPACE_ORGANIZATION_TOKEN;
10
+ if (!apiToken && !organizationToken) {
11
+ console.error("No API token provided. Set MAILPACE_API_TOKEN (to send email) and/or " +
12
+ "MAILPACE_ORGANIZATION_TOKEN (to manage domains).");
13
+ process.exit(1);
14
+ }
15
+ function normalizeApiBaseUrl(value) {
16
+ let url;
17
+ try {
18
+ url = new URL(value);
19
+ }
20
+ catch {
21
+ throw new Error(`Invalid MAILPACE_API_URL: ${value}`);
22
+ }
23
+ const isLocalhost = url.hostname === "localhost" ||
24
+ url.hostname === "127.0.0.1" ||
25
+ url.hostname === "::1" ||
26
+ url.hostname === "[::1]";
27
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalhost)) {
28
+ throw new Error("MAILPACE_API_URL must use HTTPS, except for localhost development URLs");
29
+ }
30
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
31
+ }
32
+ let API_BASE_URL;
33
+ try {
34
+ API_BASE_URL = normalizeApiBaseUrl(process.env.MAILPACE_API_URL || "https://app.mailpace.com/api/v1");
35
+ }
36
+ catch (error) {
37
+ console.error(error.message);
38
+ process.exit(1);
39
+ }
40
+ function readIntegerEnv(name, fallback, minimum) {
41
+ const raw = process.env[name];
42
+ if (raw === undefined || raw === "") {
43
+ return fallback;
44
+ }
45
+ const value = Number(raw);
46
+ if (!Number.isInteger(value) || value < minimum) {
47
+ console.error(`${name} must be an integer greater than or equal to ${minimum}`);
48
+ process.exit(1);
49
+ }
50
+ return value;
51
+ }
52
+ const REQUEST_TIMEOUT_MS = readIntegerEnv("MAILPACE_REQUEST_TIMEOUT_MS", 30_000, 1);
53
+ const MAX_RETRIES = readIntegerEnv("MAILPACE_MAX_RETRIES", 2, 0);
54
+ const server = new McpServer({
55
+ name: "mailpace",
56
+ version,
57
+ }, {
58
+ instructions: "MailPace provides transactional email sending and domain management. " +
59
+ "MAILPACE_API_TOKEN enables mailpace_send_email and mailpace_get_email_status for one domain. " +
60
+ "MAILPACE_ORGANIZATION_TOKEN enables domain and domain API token management, plus mailpace_get_email_status for any domain. " +
61
+ "Sending email is an external side effect; confirm recipients before calling mailpace_send_email.",
62
+ maxToolInputElements: 10_000,
63
+ });
64
+ const domainSchema = z
65
+ .object({
66
+ id: z.number().int(),
67
+ name: z.string().nullable().optional(),
68
+ url: z.string(),
69
+ dkim_selector: z.string().nullable().optional(),
70
+ dkim_string: z.string().nullable().optional(),
71
+ dkim_verified: z.boolean(),
72
+ advanced_verified: z.boolean(),
73
+ webhook_public_key: z.string().nullable().optional(),
74
+ created_at: z.string(),
75
+ modified_at: z.string(),
76
+ })
77
+ .passthrough();
78
+ const domainListSchema = z.object({
79
+ domains: z.array(domainSchema),
80
+ });
81
+ const verifyDomainSchema = z
82
+ .object({
83
+ dkim_verified: z.boolean(),
84
+ advance_verified: z.boolean(),
85
+ errors: z.record(z.string(), z.unknown()).optional(),
86
+ })
87
+ .passthrough();
88
+ const apiTokenSchema = z
89
+ .object({
90
+ id: z.number().int().optional(),
91
+ domain_id: z.number().int().optional(),
92
+ name: z.string().nullable().optional(),
93
+ token: z.string().optional(),
94
+ created_at: z.string().optional(),
95
+ modified_at: z.string().optional(),
96
+ })
97
+ .passthrough();
98
+ const apiTokenListSchema = z.object({
99
+ api_tokens: z.array(apiTokenSchema),
100
+ });
101
+ const messageSchema = z
102
+ .object({
103
+ message: z.string().optional(),
104
+ })
105
+ .passthrough();
106
+ const emailStatusSchema = z
107
+ .object({
108
+ id: z.number().int(),
109
+ domain_id: z.number().int(),
110
+ status: z.string(),
111
+ message_id: z.string().nullable().optional(),
112
+ from: z.string().optional(),
113
+ to: z.string().optional(),
114
+ cc: z.string().nullable().optional(),
115
+ bcc: z.string().nullable().optional(),
116
+ subject: z.string().nullable().optional(),
117
+ replyto: z.string().nullable().optional(),
118
+ inreplyto: z.string().nullable().optional(),
119
+ references: z.string().nullable().optional(),
120
+ list_unsubscribe: z.string().nullable().optional(),
121
+ tags: z.array(z.string()).optional(),
122
+ bounce_reason: z.string().nullable().optional(),
123
+ bounce: z.record(z.string(), z.unknown()).nullable().optional(),
124
+ deferred: z.record(z.string(), z.unknown()).nullable().optional(),
125
+ created_at: z.string().optional(),
126
+ modified_at: z.string().optional(),
127
+ })
128
+ .passthrough();
129
+ const sendEmailSchema = z.object({
130
+ id: z.number().int(),
131
+ status: z.string(),
132
+ });
133
+ const EMAIL_ADDRESS_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
134
+ const ATTACHMENT_EXTENSIONS = new Set([
135
+ "jpeg",
136
+ "jpg",
137
+ "png",
138
+ "gif",
139
+ "txt",
140
+ "pdf",
141
+ "docx",
142
+ "xlsx",
143
+ "pptx",
144
+ "csv",
145
+ "att",
146
+ "ics",
147
+ "ical",
148
+ "html",
149
+ "zip",
150
+ "auf",
151
+ "xml",
152
+ "dwg",
153
+ ]);
154
+ const MAX_RECIPIENTS = 50;
155
+ const MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
156
+ const ALLOWED_RECIPIENTS = (process.env.MAILPACE_ALLOWED_RECIPIENTS || "")
157
+ .split(",")
158
+ .map((entry) => entry.trim().toLowerCase())
159
+ .filter(Boolean);
160
+ function bareAddress(value) {
161
+ const trimmed = value.trim();
162
+ const angleMatch = trimmed.match(/^.*<([^<>]+)>$/);
163
+ return (angleMatch ? angleMatch[1] : trimmed).trim();
164
+ }
165
+ function isRecipientAllowed(value) {
166
+ if (ALLOWED_RECIPIENTS.length === 0) {
167
+ return true;
168
+ }
169
+ const address = bareAddress(value).toLowerCase();
170
+ return ALLOWED_RECIPIENTS.some((rule) => rule.startsWith("@") ? address.endsWith(rule) : address === rule);
171
+ }
172
+ function splitAddressList(value) {
173
+ const parts = [];
174
+ let current = "";
175
+ let inQuotes = false;
176
+ let angleDepth = 0;
177
+ for (const char of value) {
178
+ if (char === '"') {
179
+ inQuotes = !inQuotes;
180
+ }
181
+ else if (char === "<" && !inQuotes) {
182
+ angleDepth += 1;
183
+ }
184
+ else if (char === ">" && !inQuotes) {
185
+ angleDepth = Math.max(0, angleDepth - 1);
186
+ }
187
+ if (char === "," && !inQuotes && angleDepth === 0) {
188
+ parts.push(current);
189
+ current = "";
190
+ }
191
+ else {
192
+ current += char;
193
+ }
194
+ }
195
+ parts.push(current);
196
+ return parts.map((part) => part.trim()).filter(Boolean);
197
+ }
198
+ function isValidAddress(value) {
199
+ const trimmed = value.trim();
200
+ const angleMatch = trimmed.match(/^.*<([^<>]+)>$/);
201
+ const address = (angleMatch ? angleMatch[1] : trimmed).trim();
202
+ return EMAIL_ADDRESS_RE.test(address);
203
+ }
204
+ const emailAddressSchema = z
205
+ .string()
206
+ .trim()
207
+ .min(1)
208
+ .refine(isValidAddress, {
209
+ message: "Must be an email address or an RFC 822 name & addr-spec, for example 'Name <user@example.com>'",
210
+ });
211
+ const emailListSchema = z
212
+ .string()
213
+ .trim()
214
+ .min(1)
215
+ .refine((value) => {
216
+ const addresses = splitAddressList(value);
217
+ return addresses.length > 0 && addresses.every(isValidAddress);
218
+ }, {
219
+ message: "Must be a comma-separated list of email addresses or RFC 822 name & addr-spec entries",
220
+ });
221
+ const attachmentSchema = z.object({
222
+ name: z
223
+ .string()
224
+ .min(1)
225
+ .refine((name) => {
226
+ const parts = name.split(".");
227
+ return (parts.length === 2 &&
228
+ ATTACHMENT_EXTENSIONS.has(parts[1].toLowerCase()));
229
+ }, {
230
+ message: "File names must have exactly one full stop and use a supported extension",
231
+ })
232
+ .describe("File name including extension, for example invoice.pdf. Must contain exactly one full stop."),
233
+ content: z.string().min(1).describe("Base64-encoded file contents"),
234
+ content_type: z
235
+ .string()
236
+ .min(1)
237
+ .describe("MIME type, for example application/pdf"),
238
+ cid: z
239
+ .string()
240
+ .optional()
241
+ .describe("Optional content ID for inline images"),
242
+ });
243
+ const sendEmailInputSchema = z
244
+ .object({
245
+ from: emailAddressSchema.describe("Sender email address or RFC 822 name & addr-spec, for example 'Name <user@example.com>'. The domain must match the API token."),
246
+ to: emailListSchema.describe("Recipient address or comma-separated list of recipients (maximum 50 total including cc and bcc)"),
247
+ subject: z.string().optional().describe("Email subject"),
248
+ htmlbody: z
249
+ .string()
250
+ .optional()
251
+ .describe("HTML body. Required if textbody is not provided."),
252
+ textbody: z
253
+ .string()
254
+ .optional()
255
+ .describe("Plain-text body. Required if htmlbody is not provided."),
256
+ cc: emailListSchema
257
+ .optional()
258
+ .describe("Optional comma-separated CC recipients"),
259
+ bcc: emailListSchema
260
+ .optional()
261
+ .describe("Optional comma-separated BCC recipients"),
262
+ replyto: emailAddressSchema
263
+ .optional()
264
+ .describe("Optional Reply-To address"),
265
+ inreplyto: z
266
+ .string()
267
+ .optional()
268
+ .describe("Optional In-Reply-To message ID"),
269
+ references: z
270
+ .string()
271
+ .optional()
272
+ .describe("Optional space-separated References message IDs"),
273
+ list_unsubscribe: z
274
+ .string()
275
+ .optional()
276
+ .describe("Optional List-Unsubscribe header value"),
277
+ attachments: z
278
+ .array(attachmentSchema)
279
+ .optional()
280
+ .describe("Optional attachments. Total email size must not exceed 30 MB."),
281
+ tags: z
282
+ .union([z.string(), z.array(z.string())])
283
+ .optional()
284
+ .describe("Optional tag or list of tags for filtering and analytics"),
285
+ idempotency_key: z
286
+ .string()
287
+ .min(1)
288
+ .max(255)
289
+ .optional()
290
+ .describe("Optional unique key (for example a UUIDv4) that prevents sending the same email twice when retrying"),
291
+ })
292
+ .superRefine((value, ctx) => {
293
+ if (!value.textbody && !value.htmlbody) {
294
+ ctx.addIssue({
295
+ code: "custom",
296
+ message: "Either textbody or htmlbody must be provided",
297
+ path: ["textbody"],
298
+ });
299
+ }
300
+ const recipients = [value.to, value.cc, value.bcc]
301
+ .filter((address) => Boolean(address))
302
+ .flatMap(splitAddressList);
303
+ if (recipients.length > MAX_RECIPIENTS) {
304
+ ctx.addIssue({
305
+ code: "custom",
306
+ message: `A maximum of ${MAX_RECIPIENTS} recipients is allowed`,
307
+ path: ["to"],
308
+ });
309
+ }
310
+ const attachmentBytes = (value.attachments ?? []).reduce((total, attachment) => total + Math.ceil((attachment.content.length * 3) / 4), 0);
311
+ if (attachmentBytes > MAX_ATTACHMENT_BYTES) {
312
+ ctx.addIssue({
313
+ code: "custom",
314
+ message: "Attachments must not exceed 30 MB in total",
315
+ path: ["attachments"],
316
+ });
317
+ }
318
+ });
319
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
320
+ const MAX_ERROR_DETAIL_LENGTH = 1000;
321
+ function jsonResult(data) {
322
+ const isObject = data !== null && typeof data === "object" && !Array.isArray(data);
323
+ return {
324
+ content: [{ type: "text", text: JSON.stringify(data) }],
325
+ ...(isObject
326
+ ? { structuredContent: data }
327
+ : {}),
328
+ };
329
+ }
330
+ function errorResult(message) {
331
+ return {
332
+ content: [{ type: "text", text: message }],
333
+ isError: true,
334
+ };
335
+ }
336
+ function truncate(text, maxLength = MAX_ERROR_DETAIL_LENGTH) {
337
+ if (text.length <= maxLength) {
338
+ return text;
339
+ }
340
+ return `${text.slice(0, maxLength)}…`;
341
+ }
342
+ function describeRequestFailure(error, options) {
343
+ if (options.signal?.aborted) {
344
+ return "MailPace API request cancelled";
345
+ }
346
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
347
+ return `MailPace API request timed out after ${REQUEST_TIMEOUT_MS}ms`;
348
+ }
349
+ return null;
350
+ }
351
+ function formatApiError(status, statusText, detail, tokenHeader) {
352
+ const safeDetail = truncate(detail.trim() || "Unknown error");
353
+ const tokenName = tokenHeader === "MailPace-Organization-Token"
354
+ ? "organization API token"
355
+ : "domain server token";
356
+ switch (status) {
357
+ case 401:
358
+ return `MailPace authentication failed (401). Check that the ${tokenName} is valid and has not been revoked. ${safeDetail}`;
359
+ case 403:
360
+ return `MailPace denied the request (403). The ${tokenName} may not have access to this resource. ${safeDetail}`;
361
+ case 404:
362
+ return `MailPace resource not found (404). Check the domain, email, or token ID. ${safeDetail}`;
363
+ case 422:
364
+ return `MailPace rejected the request (422): ${safeDetail}`;
365
+ case 429:
366
+ return `MailPace rate limit exceeded (429). Retry later. ${safeDetail}`;
367
+ default:
368
+ return `MailPace API request failed (${status} ${statusText}): ${safeDetail}`;
369
+ }
370
+ }
371
+ function sleep(ms, signal) {
372
+ return new Promise((resolve, reject) => {
373
+ if (signal?.aborted) {
374
+ reject(new Error("MailPace API request cancelled"));
375
+ return;
376
+ }
377
+ const timer = setTimeout(resolve, ms);
378
+ signal?.addEventListener("abort", () => {
379
+ clearTimeout(timer);
380
+ reject(new Error("MailPace API request cancelled"));
381
+ }, { once: true });
382
+ });
383
+ }
384
+ function retryDelayMs(attempt, response) {
385
+ const retryAfter = response?.headers.get("retry-after");
386
+ if (retryAfter) {
387
+ const seconds = Number(retryAfter);
388
+ if (Number.isFinite(seconds)) {
389
+ return Math.max(0, seconds * 1000);
390
+ }
391
+ const date = Date.parse(retryAfter);
392
+ if (!Number.isNaN(date)) {
393
+ return Math.max(0, date - Date.now());
394
+ }
395
+ }
396
+ return Math.min(1000 * 2 ** attempt, 10_000);
397
+ }
398
+ async function apiRequest(path, options) {
399
+ const url = `${API_BASE_URL}${path}`;
400
+ const method = options.method || "GET";
401
+ const body = options.body === undefined ? undefined : JSON.stringify(options.body);
402
+ const isIdempotent = ["GET", "HEAD", "PATCH", "DELETE"].includes(method) ||
403
+ Boolean(options.headers?.["Idempotency-Key"]);
404
+ for (let attempt = 0;; attempt += 1) {
405
+ const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
406
+ const signal = options.signal
407
+ ? AbortSignal.any([options.signal, timeoutSignal])
408
+ : timeoutSignal;
409
+ let response;
410
+ try {
411
+ response = await fetch(url, {
412
+ method,
413
+ headers: {
414
+ Accept: "application/json",
415
+ "Content-Type": "application/json",
416
+ [options.tokenHeader]: options.token,
417
+ ...options.headers,
418
+ },
419
+ body,
420
+ signal,
421
+ });
422
+ }
423
+ catch (error) {
424
+ const failure = describeRequestFailure(error, options);
425
+ if (failure) {
426
+ throw new Error(failure);
427
+ }
428
+ const cause = error?.cause?.message ? `: ${error.cause.message}` : "";
429
+ const message = `${error?.message || "Network request failed"}${cause}`;
430
+ if (isIdempotent && attempt < MAX_RETRIES) {
431
+ await sleep(retryDelayMs(attempt), options.signal);
432
+ continue;
433
+ }
434
+ throw new Error(`MailPace API request failed: ${message}`);
435
+ }
436
+ const contentLength = Number(response.headers.get("content-length"));
437
+ if (Number.isFinite(contentLength) &&
438
+ contentLength > MAX_RESPONSE_BYTES) {
439
+ throw new Error(`MailPace API response exceeded the ${MAX_RESPONSE_BYTES} byte limit`);
440
+ }
441
+ let responseText;
442
+ try {
443
+ responseText = await response.text();
444
+ }
445
+ catch (error) {
446
+ const failure = describeRequestFailure(error, options);
447
+ if (failure) {
448
+ throw new Error(failure);
449
+ }
450
+ throw new Error(`MailPace API response could not be read: ${error?.message || error}`);
451
+ }
452
+ if (responseText.length > MAX_RESPONSE_BYTES) {
453
+ throw new Error(`MailPace API response exceeded the ${MAX_RESPONSE_BYTES} byte limit`);
454
+ }
455
+ let data = null;
456
+ if (responseText) {
457
+ try {
458
+ data = JSON.parse(responseText);
459
+ }
460
+ catch {
461
+ data = responseText;
462
+ }
463
+ }
464
+ if (response.ok) {
465
+ return data;
466
+ }
467
+ if ((response.status === 429 || response.status >= 500) &&
468
+ attempt < MAX_RETRIES &&
469
+ (isIdempotent || response.status === 429)) {
470
+ await sleep(retryDelayMs(attempt, response), options.signal);
471
+ continue;
472
+ }
473
+ let detail = responseText;
474
+ if (data && typeof data === "object") {
475
+ const errorData = data;
476
+ detail = String(errorData.error || errorData.message || JSON.stringify(data));
477
+ }
478
+ throw new Error(formatApiError(response.status, response.statusText, detail, options.tokenHeader));
479
+ }
480
+ }
481
+ function organizationRequest(path, options = {}) {
482
+ return apiRequest(path, {
483
+ ...options,
484
+ token: organizationToken,
485
+ tokenHeader: "MailPace-Organization-Token",
486
+ });
487
+ }
488
+ function serverRequest(path, options = {}) {
489
+ return apiRequest(path, {
490
+ ...options,
491
+ token: apiToken,
492
+ tokenHeader: "MailPace-Server-Token",
493
+ });
494
+ }
495
+ async function runApiTool(operation, label = "MailPace API") {
496
+ try {
497
+ return jsonResult(await operation());
498
+ }
499
+ catch (error) {
500
+ console.error(`${label} error: ${error.message}`);
501
+ return errorResult(error.message || String(error));
502
+ }
503
+ }
504
+ function runOrganizationTool(operation) {
505
+ return runApiTool(operation, "MailPace Domains API");
506
+ }
507
+ // Define the email tool
508
+ if (apiToken) {
509
+ server.registerTool("mailpace_send_email", {
510
+ title: "Send Email",
511
+ description: "Send a transactional email through MailPace. Requires a domain API token.",
512
+ inputSchema: sendEmailInputSchema,
513
+ outputSchema: sendEmailSchema,
514
+ annotations: {
515
+ readOnlyHint: false,
516
+ destructiveHint: false,
517
+ idempotentHint: false,
518
+ openWorldHint: true,
519
+ },
520
+ }, async ({ idempotency_key, ...email }, ctx) => {
521
+ try {
522
+ const disallowedRecipient = [email.to, email.cc, email.bcc]
523
+ .filter((address) => Boolean(address))
524
+ .flatMap(splitAddressList)
525
+ .find((address) => !isRecipientAllowed(address));
526
+ if (disallowedRecipient) {
527
+ return errorResult(`Recipient ${bareAddress(disallowedRecipient)} is not permitted by MAILPACE_ALLOWED_RECIPIENTS`);
528
+ }
529
+ const result = (await serverRequest("/send", {
530
+ method: "POST",
531
+ body: email,
532
+ headers: idempotency_key
533
+ ? { "Idempotency-Key": idempotency_key }
534
+ : undefined,
535
+ signal: ctx.mcpReq.signal,
536
+ }));
537
+ return jsonResult({ id: result.id, status: result.status });
538
+ }
539
+ catch (error) {
540
+ console.error(`Error sending email: ${error.message}`);
541
+ return errorResult(error.message || String(error));
542
+ }
543
+ });
544
+ }
545
+ // Email status can be read with either an organization token or the
546
+ // domain's server token.
547
+ if (apiToken || organizationToken) {
548
+ server.registerTool("mailpace_get_email_status", {
549
+ title: "Get Email Status",
550
+ description: "Get the delivery status of an email, including bounce or deferral details.",
551
+ inputSchema: z.object({
552
+ domain_id: z
553
+ .number()
554
+ .int()
555
+ .describe("ID of the domain the email was sent from"),
556
+ id: z
557
+ .number()
558
+ .int()
559
+ .describe("Email ID returned by mailpace_send_email"),
560
+ }),
561
+ outputSchema: emailStatusSchema,
562
+ annotations: {
563
+ readOnlyHint: true,
564
+ destructiveHint: false,
565
+ idempotentHint: true,
566
+ openWorldHint: true,
567
+ },
568
+ }, async ({ domain_id, id }, ctx) => runApiTool(() => organizationToken
569
+ ? organizationRequest(`/domains/${domain_id}/emails/${id}`, {
570
+ signal: ctx.mcpReq.signal,
571
+ })
572
+ : serverRequest(`/domains/${domain_id}/emails/${id}`, {
573
+ signal: ctx.mcpReq.signal,
574
+ }), "MailPace Email Status API"));
575
+ }
576
+ // Domains API tools. These require an Organization API token.
577
+ if (organizationToken) {
578
+ server.registerTool("mailpace_list_domains", {
579
+ title: "List Domains",
580
+ description: "List the domains belonging to your MailPace organization.",
581
+ inputSchema: z.object({
582
+ limit: z.number().int().min(1).max(200).optional(),
583
+ offset: z.number().int().min(0).optional(),
584
+ sort: z.enum(["created_at", "id", "name"]).optional(),
585
+ direction: z.enum(["asc", "desc"]).optional(),
586
+ }),
587
+ outputSchema: domainListSchema,
588
+ annotations: {
589
+ readOnlyHint: true,
590
+ destructiveHint: false,
591
+ idempotentHint: true,
592
+ openWorldHint: true,
593
+ },
594
+ }, async ({ limit, offset, sort, direction }, ctx) => {
595
+ const params = new URLSearchParams();
596
+ if (limit !== undefined)
597
+ params.set("limit", String(limit));
598
+ if (offset !== undefined)
599
+ params.set("offset", String(offset));
600
+ if (sort !== undefined)
601
+ params.set("sort", sort);
602
+ if (direction !== undefined)
603
+ params.set("direction", direction);
604
+ const query = params.toString();
605
+ return runOrganizationTool(async () => ({
606
+ domains: await organizationRequest(`/domains${query ? `?${query}` : ""}`, { signal: ctx.mcpReq.signal }),
607
+ }));
608
+ });
609
+ server.registerTool("mailpace_get_domain", {
610
+ title: "Get Domain",
611
+ description: "Get a single MailPace domain by ID.",
612
+ inputSchema: z.object({
613
+ id: z.number().int().describe("The domain ID"),
614
+ }),
615
+ outputSchema: domainSchema,
616
+ annotations: {
617
+ readOnlyHint: true,
618
+ destructiveHint: false,
619
+ idempotentHint: true,
620
+ openWorldHint: true,
621
+ },
622
+ }, async ({ id }, ctx) => runOrganizationTool(() => organizationRequest(`/domains/${id}`, { signal: ctx.mcpReq.signal })));
623
+ server.registerTool("mailpace_create_domain", {
624
+ title: "Create Domain",
625
+ description: "Create a new sending domain in your MailPace organization.",
626
+ inputSchema: z.object({
627
+ url: z
628
+ .string()
629
+ .describe("Lower-case domain with TLD, for example example.com"),
630
+ name: z.string().optional().describe("Friendly name for your reference"),
631
+ }),
632
+ outputSchema: domainSchema,
633
+ annotations: {
634
+ readOnlyHint: false,
635
+ destructiveHint: false,
636
+ idempotentHint: false,
637
+ openWorldHint: true,
638
+ },
639
+ }, async ({ url, name }, ctx) => runOrganizationTool(() => organizationRequest("/domains", {
640
+ method: "POST",
641
+ body: { domain: { url, ...(name !== undefined ? { name } : {}) } },
642
+ signal: ctx.mcpReq.signal,
643
+ })));
644
+ server.registerTool("mailpace_update_domain", {
645
+ title: "Update Domain",
646
+ description: "Update the name or URL of a MailPace domain. Changing the URL may require DNS verification again.",
647
+ inputSchema: z.object({
648
+ id: z.number().int().describe("The domain ID"),
649
+ name: z.string().optional().describe("Friendly name for your reference"),
650
+ url: z
651
+ .string()
652
+ .optional()
653
+ .describe("Lower-case domain with TLD, for example example.com"),
654
+ }),
655
+ outputSchema: domainSchema,
656
+ annotations: {
657
+ readOnlyHint: false,
658
+ destructiveHint: false,
659
+ idempotentHint: true,
660
+ openWorldHint: true,
661
+ },
662
+ }, async ({ id, name, url }, ctx) => {
663
+ if (name === undefined && url === undefined) {
664
+ return errorResult("Provide at least one of name or url to update");
665
+ }
666
+ return runOrganizationTool(() => organizationRequest(`/domains/${id}`, {
667
+ method: "PATCH",
668
+ body: {
669
+ domain: {
670
+ ...(name !== undefined ? { name } : {}),
671
+ ...(url !== undefined ? { url } : {}),
672
+ },
673
+ },
674
+ signal: ctx.mcpReq.signal,
675
+ }));
676
+ });
677
+ server.registerTool("mailpace_verify_domain", {
678
+ title: "Verify Domain",
679
+ description: "Check the DKIM and advanced verification DNS records for a MailPace domain.",
680
+ inputSchema: z.object({
681
+ id: z.number().int().describe("The domain ID"),
682
+ }),
683
+ outputSchema: verifyDomainSchema,
684
+ annotations: {
685
+ readOnlyHint: true,
686
+ destructiveHint: false,
687
+ idempotentHint: true,
688
+ openWorldHint: true,
689
+ },
690
+ }, async ({ id }, ctx) => runOrganizationTool(() => organizationRequest(`/domains/${id}/verify`, {
691
+ method: "POST",
692
+ signal: ctx.mcpReq.signal,
693
+ })));
694
+ server.registerTool("mailpace_list_domain_api_tokens", {
695
+ title: "List Domain API Tokens",
696
+ description: "List the API tokens for a MailPace domain.",
697
+ inputSchema: z.object({
698
+ domain_id: z.number().int().describe("The domain ID"),
699
+ }),
700
+ outputSchema: apiTokenListSchema,
701
+ annotations: {
702
+ readOnlyHint: true,
703
+ destructiveHint: false,
704
+ idempotentHint: true,
705
+ openWorldHint: true,
706
+ },
707
+ }, async ({ domain_id }, ctx) => runOrganizationTool(async () => ({
708
+ api_tokens: await organizationRequest(`/domains/${domain_id}/api_tokens`, { signal: ctx.mcpReq.signal }),
709
+ })));
710
+ server.registerTool("mailpace_get_domain_api_token", {
711
+ title: "Get Domain API Token",
712
+ description: "Get a single API token for a MailPace domain.",
713
+ inputSchema: z.object({
714
+ domain_id: z.number().int().describe("The domain ID"),
715
+ id: z.number().int().describe("The API token ID"),
716
+ }),
717
+ outputSchema: apiTokenSchema,
718
+ annotations: {
719
+ readOnlyHint: true,
720
+ destructiveHint: false,
721
+ idempotentHint: true,
722
+ openWorldHint: true,
723
+ },
724
+ }, async ({ domain_id, id }, ctx) => runOrganizationTool(() => organizationRequest(`/domains/${domain_id}/api_tokens/${id}`, {
725
+ signal: ctx.mcpReq.signal,
726
+ })));
727
+ server.registerTool("mailpace_create_domain_api_token", {
728
+ title: "Create Domain API Token",
729
+ description: "Create a new API token for a MailPace domain. Store the returned token securely; it may not be shown again.",
730
+ inputSchema: z.object({
731
+ domain_id: z.number().int().describe("The domain ID"),
732
+ name: z.string().optional().describe("Friendly token label"),
733
+ }),
734
+ outputSchema: apiTokenSchema,
735
+ annotations: {
736
+ readOnlyHint: false,
737
+ destructiveHint: false,
738
+ idempotentHint: false,
739
+ openWorldHint: true,
740
+ },
741
+ }, async ({ domain_id, name }, ctx) => runOrganizationTool(() => organizationRequest(`/domains/${domain_id}/api_tokens`, {
742
+ method: "POST",
743
+ body: { api_token: { ...(name !== undefined ? { name } : {}) } },
744
+ signal: ctx.mcpReq.signal,
745
+ })));
746
+ server.registerTool("mailpace_update_domain_api_token", {
747
+ title: "Update Domain API Token",
748
+ description: "Update the name of an API token for a MailPace domain.",
749
+ inputSchema: z.object({
750
+ domain_id: z.number().int().describe("The domain ID"),
751
+ id: z.number().int().describe("The API token ID"),
752
+ name: z.string().optional().describe("Friendly token label"),
753
+ }),
754
+ outputSchema: apiTokenSchema,
755
+ annotations: {
756
+ readOnlyHint: false,
757
+ destructiveHint: false,
758
+ idempotentHint: true,
759
+ openWorldHint: true,
760
+ },
761
+ }, async ({ domain_id, id, name }, ctx) => {
762
+ if (name === undefined) {
763
+ return errorResult("Provide a name to update the API token");
764
+ }
765
+ return runOrganizationTool(() => organizationRequest(`/domains/${domain_id}/api_tokens/${id}`, {
766
+ method: "PATCH",
767
+ body: { api_token: { name } },
768
+ signal: ctx.mcpReq.signal,
769
+ }));
770
+ });
771
+ server.registerTool("mailpace_revoke_domain_api_token", {
772
+ title: "Revoke Domain API Token",
773
+ description: "Revoke an API token for a MailPace domain. This cannot be undone.",
774
+ inputSchema: z.object({
775
+ domain_id: z.number().int().describe("The domain ID"),
776
+ id: z.number().int().describe("The API token ID"),
777
+ }),
778
+ outputSchema: messageSchema,
779
+ annotations: {
780
+ readOnlyHint: false,
781
+ destructiveHint: true,
782
+ idempotentHint: true,
783
+ openWorldHint: true,
784
+ },
785
+ }, async ({ domain_id, id }, ctx) => runOrganizationTool(() => organizationRequest(`/domains/${domain_id}/api_tokens/${id}`, {
786
+ method: "DELETE",
787
+ signal: ctx.mcpReq.signal,
788
+ })));
789
+ }
790
+ async function main() {
791
+ const transport = new StdioServerTransport();
792
+ await server.connect(transport);
793
+ console.error(`MailPace MCP Server running on stdio (email sending: ${apiToken ? "enabled" : "disabled"}, domain management: ${organizationToken ? "enabled" : "disabled"}${ALLOWED_RECIPIENTS.length > 0
794
+ ? `, recipient allowlist: ${ALLOWED_RECIPIENTS.length} rule(s)`
795
+ : ""})`);
796
+ }
797
+ main().catch((error) => {
798
+ console.error("Fatal error in main():", error);
799
+ process.exit(1);
800
+ });
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@mailpace/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "mcpName": "com.mailpace/mailpace-mcp",
5
+ "description": "MCP server for the MailPace Send API, Domains API, and Email Status API",
6
+ "main": "build/index.js",
7
+ "type": "module",
8
+ "bin": {
9
+ "mailpace-mcp": "build/index.js"
10
+ },
11
+ "files": [
12
+ "build",
13
+ "README.md",
14
+ "LICENSE.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "clean": "rm -rf build",
22
+ "dev": "tsx index.ts",
23
+ "start": "node build/index.js",
24
+ "pretest": "npm run build",
25
+ "test": "node --test",
26
+ "typecheck": "tsc --noEmit",
27
+ "prepublishOnly": "npm run clean && npm run build && npm test"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/mailpace/mailpace-mcp.git"
32
+ },
33
+ "homepage": "https://github.com/mailpace/mailpace-mcp#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/mailpace/mailpace-mcp/issues"
36
+ },
37
+ "keywords": [
38
+ "mcp",
39
+ "model-context-protocol",
40
+ "mailpace",
41
+ "email",
42
+ "transactional-email"
43
+ ],
44
+ "author": "MailPace",
45
+ "license": "MIT",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@modelcontextprotocol/server": "^2.4.0",
51
+ "zod": "^4.6.7"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.17.27",
55
+ "tsx": "^4.20.6",
56
+ "typescript": "^5.8.2"
57
+ }
58
+ }