index.js•24.9 kB
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fetch from "node-fetch";
const server = new McpServer({
name: "sidemail",
version: "1.0.0",
capabilities: {
resources: {},
tools: {},
prompts: {},
},
});
const SIDEMAIL_API_KEY = process.env.SIDEMAIL_API_KEY;
const API_BASE = process.env.SIDEMAIL_API_BASE || "https://api.sidemail.io/v1";
console.log(`-> Sidemail MCP Server starting...`);
console.log(`-> Using Sidemail API base: ${API_BASE}`);
if (!SIDEMAIL_API_KEY) {
console.error("🔴 Missing SIDEMAIL_API_KEY environment variable. Exiting...");
process.exit(1);
}
// Helper for Sidemail API requests
async function sidemailApiRequest(endpoint, { method = "GET", body } = {}) {
if (!SIDEMAIL_API_KEY) {
throw new Error("Missing SIDEMAIL_API_KEY environment variable.");
}
const options = {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${SIDEMAIL_API_KEY}`,
},
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(API_BASE + endpoint, options);
if (!response.ok) {
let errorMsg = `HTTP error! status: ${response.status}`;
try {
const errorBody = await response.json();
if (errorBody?.developerMessage) {
errorMsg += ` - ${errorBody.developerMessage}`;
}
} catch {
// Ignore JSON parse errors
}
throw new Error(errorMsg);
}
return await response.json();
}
server.tool(
"list-domains",
"List all sending domains (Sidemail API)",
{},
async () => {
try {
const resp = await sidemailApiRequest("/domains");
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to list domains: ${error.message}` },
],
};
}
}
);
server.tool(
"create-domain",
"Create a new sending domain (Sidemail API)",
{
domain: z.string().min(1).describe("Domain name to add (e.g. example.com)"),
},
async ({ domain }) => {
try {
const d = await sidemailApiRequest("/domains", {
method: "POST",
body: { domain },
});
return {
content: [{ type: "text", text: JSON.stringify(d, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to create domain: ${error.message}` },
],
};
}
}
);
server.tool(
"delete-domain",
"Delete a sending domain (Sidemail API)",
{
id: z.string().min(1).describe("ID of the domain to delete"),
},
async ({ id }) => {
try {
await sidemailApiRequest(`/domains/${id}`, { method: "DELETE" });
return {
content: [
{
type: "text",
text: JSON.stringify({ deleted: true, id }, null, 2),
},
],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to delete domain: ${error.message}` },
],
};
}
}
);
// List Messenger drafts
server.tool(
"list-messenger-drafts",
"List Messenger drafts (Sidemail Messenger API)",
{
offset: z
.number()
.int()
.min(0)
.optional()
.describe("Number of drafts to skip (pagination). Default: 0"),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Number of drafts per page. Default: 20"),
},
async ({ offset = 0, limit = 20 }) => {
try {
const params = new URLSearchParams();
if (offset) params.append("offset", offset.toString());
if (limit) params.append("limit", limit.toString());
const query = params.toString() ? `?${params.toString()}` : "";
const resp = await sidemailApiRequest(`/messenger${query}`);
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to list Messenger drafts: ${error.message}`,
},
],
};
}
}
);
// Get Messenger draft by ID
server.tool(
"get-messenger-draft",
"Get a Messenger draft by ID (Sidemail Messenger API)",
{
id: z.string().min(1).describe("Messenger draft ID"),
},
async ({ id }) => {
try {
const resp = await sidemailApiRequest(`/messenger/${id}`);
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to get Messenger draft: ${error.message}`,
},
],
};
}
}
);
// Create Messenger draft
server.tool(
"create-messenger-draft",
"Create a Messenger draft (Sidemail Messenger API). Messenger is a tool for sending newsletters / broadcasts to contacts inside Sidemail.",
{
create: z.object({
subject: z.string().optional(),
fromName: z.string().optional(),
fromAddress: z.string(),
markdown: z.string().nullable().optional(),
templateId: z.string().nullable().optional(),
templateProps: z.record(z.any()).optional().nullable(),
recipients: z.object({
contactId: z.array(z.string()).optional().nullable(),
groupId: z.array(z.string()).optional().nullable(),
filter: z
.object({
match: z.enum(["all", "any"]).optional(),
rules: z
.array(
z.object({
field: z
.string()
.describe(
"To filter by a property which is not one of (identifier, emailAddress, timezone, id, groups, note, createdAt, subscribedIp), prefix with 'customProps.', e.g., 'customProps.subscription.id' or 'customProps.name'"
), // required
operator: z
.enum([
"equals",
"notEquals",
"includes",
"notIncludes",
"gt",
"gte",
"lt",
"lte",
"in",
"notIn",
"anyValue",
"noValue",
])
.optional(), // optional, default: equals
value: z.union([z.string(), z.number(), z.null()]), // string or number or null
})
)
.optional(),
})
.optional(),
match: z.enum(["all", "any"]).optional(),
isSubscribed: z.boolean().optional(),
}),
scheduledAt: z.string().nullable().optional(),
isTimezoneScheduled: z.boolean().optional(),
contentType: z.enum(["with-layout", "no-layout", "template"]).optional(),
status: z
.enum(["draft"])
.optional()
.describe(
"to send the draft, draft must be updated with status: queued"
),
}),
},
async ({ create }) => {
try {
const resp = await sidemailApiRequest("/messenger", {
method: "POST",
body: create,
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to create Messenger draft: ${error.message}`,
},
],
};
}
}
);
// Update Messenger draft (partial)
server.tool(
"update-messenger-draft",
"Update a Messenger draft (Sidemail Messenger API)",
{
id: z.string().min(1).describe("Messenger draft ID"),
update: z
.object({
subject: z.string().optional(),
fromName: z.string().optional(),
fromAddress: z.string().optional(),
markdown: z.string().nullable().optional(),
templateId: z.string().nullable().optional(),
templateProps: z.record(z.any()).optional().nullable(),
recipients: z
.object({
contactId: z.array(z.string()).optional().nullable(),
groupId: z.array(z.string()).optional().nullable(),
filter: z
.object({
match: z.enum(["all", "any"]).optional(),
rules: z
.array(
z.object({
field: z
.string()
.describe(
"To filter by a custom property, prefix with 'customProps.', e.g., 'customProps.subscription.id'"
),
operator: z
.enum([
"equals",
"notEquals",
"includes",
"notIncludes",
"gt",
"gte",
"lt",
"lte",
"in",
"notIn",
"anyValue",
"noValue",
])
.optional(),
value: z.union([z.string(), z.number(), z.null()]),
})
)
.optional(),
})
.optional(),
match: z.enum(["all", "any"]).optional(),
isSubscribed: z.boolean().optional(),
})
.optional(),
scheduledAt: z.string().nullable().optional(),
isTimezoneScheduled: z.boolean().optional(),
contentType: z
.enum(["with-layout", "no-layout", "template"])
.optional(),
status: z
.enum(["draft", "queued"])
.optional()
.describe(
"queued means that the draft will be sent out immediately, no more changes are possible after this, always confirm with user before sending out!"
), // required,
})
.partial()
.describe("Partial Messenger draft object to update"),
},
async ({ id, update }) => {
try {
await sidemailApiRequest(`/messenger/${id}`, {
method: "PATCH",
body: update,
});
return {
content: [{ type: "text", text: JSON.stringify({}, null, 2) }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to update Messenger draft: ${error.message}`,
},
],
};
}
}
);
// Delete Messenger draft
server.tool(
"delete-messenger-draft",
"Delete a Messenger draft (Sidemail Messenger API)",
{
id: z.string().min(1).describe("Messenger draft ID"),
},
async ({ id }) => {
try {
const resp = await sidemailApiRequest(`/messenger/${id}`, {
method: "DELETE",
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to delete Messenger draft: ${error.message}`,
},
],
};
}
}
);
server.tool(
"list-groups",
"List all contact groups (Sidemail API)",
{},
async () => {
try {
const resp = await sidemailApiRequest("/groups");
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to list groups: ${error.message}` },
],
};
}
}
);
server.tool(
"create-group",
"List all contact groups (Sidemail API)",
{
create: z.object({
name: z.string().min(1).describe("Name of the group to create"),
}),
},
async ({ create }) => {
try {
const resp = await sidemailApiRequest("/groups", {
method: "POST",
body: create,
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to create group: ${error.message}` },
],
};
}
}
);
server.tool(
"update-group",
"Update a Group (Sidemail Messenger API)",
{
id: z.string().min(1).describe("Group ID"),
update: z
.object({
name: z.string().optional(),
})
.partial()
.describe("Partial Group object to update"),
},
async ({ id, update }) => {
try {
await sidemailApiRequest(`/groups/${id}`, {
method: "PATCH",
body: update,
});
return {
content: [{ type: "text", text: JSON.stringify({}, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to update Group: ${error.message}` },
],
};
}
}
);
// CONTACTS API TOOLS START
// Create or update a contact
server.tool(
"create-or-update-contact",
"Create or update a contact (Sidemail API). It's partial update, so you can provide only the fields you want to change. If the contact does not exist, it will be created.",
{
emailAddress: z.string().email().describe("Email address of the contact."),
identifier: z
.string()
.optional()
.describe("Your unique identifier for the contact."),
isSubscribed: z
.boolean()
.optional()
.describe(
"Specifies if Sidemail should set contact's status to subscribed or unsubscribed."
),
timezone: z
.string()
.optional()
.describe('Timezone of the contact (e.g., "Europe/Prague").'),
customProps: z
.record(z.any())
.optional()
.describe(
"Object of custom properties. Use exactly the same property key name as you used when creating the property."
),
groups: z
.array(z.string())
.optional()
.describe("An array where each item must be group ID."),
},
async (params) => {
try {
const resp = await sidemailApiRequest("/contacts", {
method: "POST",
body: params,
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to create or update contact: ${error.message}`,
},
],
};
}
}
);
// Query contacts
server.tool(
"query-contacts",
"Query contacts with advanced filtering, searching, and pagination (Sidemail API). If the query parameter is empty or not provided, it lists all contacts. The contact data are returned sorted by creation date, with the most recent contact appearing first.",
{
contactId: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
"Filter by contact ID(s). Supports a single ID or array of IDs."
),
groupId: z
.union([z.string(), z.array(z.string())])
.optional()
.describe("Filter by group ID(s). Supports a single ID or array of IDs."),
search: z
.string()
.optional()
.describe("Partial email address search, case-insensitive."),
isSubscribed: z
.boolean()
.optional()
.describe("Filter by subscription status."),
match: z
.enum(["all", "any"])
.optional()
.describe(
'Combine top-level filters with AND (all) or OR (any). Default is "all".'
),
filter: z
.object({
match: z
.enum(["all", "any"])
.optional()
.describe(
'Whether all rules must match or any rule can match. Default: "all".'
),
rules: z
.array(
z.object({
field: z
.string()
.describe(
"The field to filter on (any contact property, e.g., 'customProps.keyName', 'emailAddress', 'createdAt')."
),
operator: z
.enum([
"equals",
"notEquals",
"includes",
"notIncludes",
"gt",
"gte",
"lt",
"lte",
"in",
"notIn",
"anyValue",
"noValue",
])
.optional()
.describe("Operator for the rule. Default is 'equals'."),
value: z
.union([z.string(), z.number(), z.boolean(), z.null()])
.describe(
"The value to compare (can be empty string or null for some operators)."
),
})
)
.optional(),
})
.optional()
.describe("Advanced filter object."),
offset: z
.number()
.int()
.min(0)
.optional()
.describe("Number of contacts to skip (pagination). Default: 0."),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Number of contacts per page. Default: 20. Max: 100."),
},
async (params) => {
try {
const resp = await sidemailApiRequest("/contacts/query", {
method: "POST",
body: params,
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to query contacts: ${error.message}` },
],
};
}
}
);
// Find a contact
server.tool(
"find-contact",
"Find a contact by email address (Sidemail API)",
{
emailAddress: z
.string()
.email()
.describe("Email address of the contact to find."),
},
async ({ emailAddress }) => {
try {
const resp = await sidemailApiRequest(
`/contacts/${encodeURIComponent(emailAddress)}`
);
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to find contact: ${error.message}` },
],
};
}
}
);
// Delete a contact
server.tool(
"delete-contact",
"Delete a contact by email address (Sidemail API)",
{
emailAddress: z
.string()
.email()
.describe("Email address of the contact to delete."),
},
async ({ emailAddress }) => {
try {
const resp = await sidemailApiRequest(
`/contacts/${encodeURIComponent(emailAddress)}`,
{
method: "DELETE",
}
);
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to delete contact: ${error.message}` },
],
};
}
}
);
// CONTACTS API TOOLS END
// EMAIL API TOOLS START
// Send an email
server.tool(
"send-email",
"Send a transactional email (Sidemail API). This tools can only be used for sending transactional emails, not marketing emails (for that, use the Messenger tool).",
{
confirmTransactional: z
.boolean()
.default(true)
.describe(
"Confirm that this is a transactional email. If false, the email will not be sent."
),
toAddress: z
.string()
.email()
.describe("A valid email address that will receive the email."),
subject: z.string().describe("An email subject line."),
fromName: z
.string()
.min(1)
.max(100)
.optional()
.describe(
"Display name that appears before the fromAddress email address."
),
fromAddress: z
.string()
.email()
.describe("The email address from which you want to send the email."),
replyToAddress: z
.string()
.email()
.optional()
.describe(
"If you want the recipient of an email to reply to a different email address than fromAddress, specify it here."
),
replyToName: z
.string()
.min(1)
.max(100)
.optional()
.describe(
"Use in conjunction with replyToAddress to show a friendly name for the replyToAddress."
),
templateId: z
.string()
.optional()
.describe(
"A template ID of the template you want to send. Cannot be used together with templateName, html or text or markdown."
),
templateName: z
.string()
.optional()
.describe(
"A template name of the template you want to send. Cannot be used together with templateId, html or text or markdown."
),
templateProps: z
.record(z.any())
.optional()
.describe("Pass data to template props here."),
markdown: z
.string()
.max(102400)
.optional()
.describe(
"To send markdown content which Sidemail turns into branded transactional email, pass the markdown as a string. Cannot be used together with templateId or templateName or html or text."
),
html: z
.string()
.max(1024000)
.optional()
.describe(
"To send your own HTML email, pass your HTML as a string. Cannot be used together with templateId or templateName or markdown."
),
text: z
.string()
.max(102400)
.optional()
.describe(
"To send a plain-text email, pass string into the text parameter. Cannot be used together with templateId or templateName or markdown."
),
isOpenTracked: z
.boolean()
.optional()
.default(true)
.describe(
"Whether the email should be open tracked or not. Default is true."
),
scheduledAt: z
.string()
.datetime({ offset: true })
.optional()
.describe(
"Specify a delayed email delivery by providing a valid ISO 8601 date in the future."
),
headers: z
.record(z.string())
.optional()
.describe(
"Specify any custom headers of an email. Both key and value must be a string type."
),
attachments: z
.array(
z.object({
name: z
.string()
.min(1)
.max(100)
.describe(
"The name of the attached file that will show up to the recipient. The file ending must be one of the allowed file types."
),
content: z
.string()
.max(2621440)
.describe(
"The Base64 encoded file. Must be valid Base64 (RFC 4648) and less than 2500 kB."
),
})
)
.optional()
.describe(
"Attach a Base64 encoded file to an email. Combined file size limit is 2500 kB."
),
},
async (params) => {
try {
if (!params.confirmTransactional) {
return {
content: [
{
type: "text",
text: "This tool is for sending transactional emails only. Use the Messenger tool for marketing emails.",
},
],
};
}
const resp = await sidemailApiRequest("/email/send", {
method: "POST",
body: params,
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to send email: ${error.message}` },
],
};
}
}
);
// Query emails
server.tool(
"query-emails",
"Query emails with filtering and pagination (Sidemail API). If the query parameter is empty or not provided, it lists all emails. The email data are returned sorted by creation date, with the most recent emails appearing first.",
{
paginationCursorNext: z
.string()
.optional()
.describe("Cursor for fetching the next page of results."),
paginationCursorPrev: z
.string()
.optional()
.describe("Cursor for fetching the previous page of results."),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.default(20)
.describe("Number of results to return per page (1-100, default 20)."),
query: z
.object({
search: z
.string()
.optional()
.describe("A text search query to match against email fields."),
toAddress: z
.string()
.email()
.optional()
.describe("Recipient's email address."),
fromName: z.string().optional().describe("Sender's display name."),
fromAddress: z
.string()
.email()
.optional()
.describe("Sender's email address."),
subject: z.string().optional().describe("Subject of the email."),
templateName: z
.string()
.optional()
.describe("Name of the template used."),
templateId: z
.union([z.string(), z.array(z.string())])
.optional()
.describe("Identifier(s) of the template(s) used."),
automationId: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
"Identifier(s) of the automation(s) associated with the email."
),
messengerId: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
"Identifier(s) of the messenger(s) associated with the email."
),
status: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
'Status of the email (e.g., "queued", "delivered", "open").'
),
scheduledAt: z
.string()
.datetime({ offset: true })
.optional()
.describe("ISO8601 date string for scheduled delivery."),
templateProps: z
.record(z.any())
.optional()
.describe("Key-value pairs to match against template variables."),
})
.optional()
.describe("Query object for filtering emails. Omit to list all emails."),
},
async (params) => {
try {
const resp = await sidemailApiRequest("/email/search", {
method: "POST",
body: params,
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to query emails: ${error.message}` },
],
};
}
}
);
// Retrieve an email by ID
server.tool(
"get-email",
"Retrieve an email by its ID (Sidemail API)",
{
id: z.string().min(1).describe("ID of the email to retrieve."),
},
async ({ id }) => {
try {
const resp = await sidemailApiRequest(`/email/${id}`);
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to retrieve email: ${error.message}` },
],
};
}
}
);
// Delete an email by ID
server.tool(
"delete-email",
"Delete a scheduled email by ID (Sidemail API). Only emails not yet sent can be deleted.",
{
id: z.string().min(1).describe("ID of the email to delete."),
},
async ({ id }) => {
try {
const resp = await sidemailApiRequest(`/email/${id}`, {
method: "DELETE",
});
return {
content: [{ type: "text", text: JSON.stringify(resp, null, 2) }],
};
} catch (error) {
return {
content: [
{ type: "text", text: `Failed to delete email: ${error.message}` },
],
};
}
}
);
// EMAIL API TOOLS END
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Sidemail MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});