Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ AUTH_EMAIL_FROM=
# domain.
ONBOARDING_EMAIL_FROM=

# Domain-verification token from OpenAI's plugin portal (platform.openai.com
# → Plugins → MCPs), served as plain text at
# /.well-known/openai-apps-challenge on the API host. Leave unset otherwise.
OPENAI_APPS_CHALLENGE_TOKEN=

# Cloudflare Turnstile (https://dash.cloudflare.com → Turnstile), both keys or
# neither. When set, sign-in, sign-up, password reset and the onboarding
# "Send email" button verify a challenge token; an invisible or managed
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ export interface ApiDeps {
isCloud: boolean;
/** ONBOARDING_EMAIL_FROM: the shared first-email sender (core verifyOnboardingSender). */
onboardingEmailFrom?: string | undefined;
/** OPENAI_APPS_CHALLENGE_TOKEN: served at /.well-known/openai-apps-challenge. */
openaiAppsChallengeToken?: string | undefined;
/** Whether the shared sender reaches only members who verified their address (the instance verifies). */
requireVerifiedMembers?: boolean | undefined;
/**
Expand Down
136 changes: 75 additions & 61 deletions apps/api/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
batchEmailRequestSchema,
batchRemoveContactsRequestSchema,
batchRemoveSuppressionsRequestSchema,
createApiKeyRequestSchema,
createBroadcastRequestSchema,
createContactPropertyRequestSchema,
createContactRequestSchema,
Expand All @@ -49,7 +48,6 @@ import {
listContactsQuerySchema,
listQuerySchema,
listSuppressionsQuerySchema,
rotateWebhookSecretRequestSchema,
sendBroadcastRequestSchema,
sendEmailRequestSchema,
updateBroadcastRequestSchema,
Expand Down Expand Up @@ -289,6 +287,7 @@ async function callApi(
path: string,
body?: unknown,
headers: Record<string, string> = {},
shape?: (json: Record<string, unknown>) => void,
): Promise<CallToolResult> {
const req = new Request(`http://mcp.internal${path}`, {
method,
Expand All @@ -300,9 +299,20 @@ async function callApi(
const json: unknown = await res
.json()
.catch(() => errorBody(res.status, "error", res.statusText));
if (res.ok && shape && json && typeof json === "object") shape(json as Record<string, unknown>);
return toolResult(json, res.ok);
}

// Policy facts for every client: chat directories (OpenAI's) refuse servers
// whose tool results carry credentials, so secrets stay out of tool results.
const SERVER_INSTRUCTIONS =
"MillionSend's terms permit email only to recipients who consented to receive it from the sending team or have an existing relationship that lawfully permits it; purchased, rented and scraped lists are prohibited. API key tokens and webhook signing secrets are never returned by these tools; they are available in the MillionSend dashboard (when a key is created or a webhook secret rotated) and through the REST API. Billing, plan changes and payments are not available through these tools.";

/** Drops a webhook's signing secret from a tool result; it is only shown in the dashboard. */
const omitSigningSecret = (json: Record<string, unknown>) => {
delete json.signing_secret;
};

const idOrEmail = z.string().min(1).describe("Contact id or email address");
const enc = encodeURIComponent;
/** How to read records[] on a domain response; shared by get_domain and verify_domain. */
Expand All @@ -321,19 +331,22 @@ function buildServer(
authInfo: AuthInfo,
appBaseUrl: string,
): McpServer {
const server = new McpServer({
name: "millionsend",
title: "MillionSend",
version: "1.0.0",
websiteUrl: "https://millionsend.com",
icons: [
{
src: `${appBaseUrl}/logo/millionsend-avatar-512.png`,
mimeType: "image/png",
sizes: ["512x512"],
},
],
});
const server = new McpServer(
{
name: "millionsend",
title: "MillionSend",
version: "1.0.0",
websiteUrl: "https://millionsend.com",
icons: [
{
src: `${appBaseUrl}/logo/millionsend-avatar-512.png`,
mimeType: "image/png",
sizes: ["512x512"],
},
],
},
{ instructions: SERVER_INSTRUCTIONS },
);
const { auth, userId, role, teams } = authInfo.extra as unknown as McpAuthExtra;
const scopes = new Set(authInfo.scopes);
const canAdmin = teams ? teams.some((t) => isAdmin(t.role)) : isAdmin(role);
Expand All @@ -346,7 +359,8 @@ function buildServer(
path: string,
body?: unknown,
headers?: Record<string, string>,
) => callApi(app, callTeam?.getStore() ?? auth, method, path, body, headers);
shape?: (json: Record<string, unknown>) => void,
) => callApi(app, callTeam?.getStore() ?? auth, method, path, body, headers, shape);
const teamIdArg = z
.uuid()
.optional()
Expand All @@ -363,6 +377,8 @@ function buildServer(
readOnly?: boolean;
destructive?: boolean;
idempotent?: boolean;
/** Reaches outside the team's MillionSend account: external recipients, URLs or DNS/SES. */
openWorld?: boolean;
/** Owner/admin only even though read-only (e.g. a read that returns a secret). */
admin?: boolean;
},
Expand All @@ -385,14 +401,14 @@ function buildServer(
? cfg.inputSchema.extend({ team_id: teamIdArg })
: cfg.inputSchema) as unknown as S,
// Directory reviews read the title from annotations, the pre-2025-06 location.
annotations: cfg.readOnly
? { title: cfg.title, readOnlyHint: true }
: {
title: cfg.title,
readOnlyHint: false,
destructiveHint: cfg.destructive === true,
idempotentHint: cfg.idempotent === true,
},
// Directories require every hint as an explicit boolean.
annotations: {
title: cfg.title,
readOnlyHint: cfg.readOnly === true,
destructiveHint: !cfg.readOnly && cfg.destructive === true,
idempotentHint: cfg.readOnly === true || cfg.idempotent === true,
openWorldHint: cfg.openWorld === true,
},
},
// The conditional ToolCallback type cannot resolve for an unbound
// generic; the cast is sound because args were validated against
Expand Down Expand Up @@ -440,7 +456,13 @@ function buildServer(
description:
"List the teams this all-teams connection can act in. Every other tool takes a team's id as team_id; the first team listed is the default when team_id is omitted.",
inputSchema: z.object({}),
annotations: { title: "List teams", readOnlyHint: true },
annotations: {
title: "List teams",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
() =>
toolResult(
Expand Down Expand Up @@ -503,11 +525,16 @@ function buildServer(
{
title: "Get usage",
description:
"Get the team's plan and quota picture: effective plan, its send limit (emails_per_day on Free and Starter, emails_per_month on Pro and Scale), domain limit and contact limit (`limits.contacts`, null when unlimited), emails accepted so far today (UTC) and when that counter resets, and on a monthly plan a `period` object with the billing period's emails_sent, included volume, whether overage is on and when the period ends. A self-hosted instance reports cloud=false with null plan, limits and period; the instance's own (system) team reports cloud=true with the same nulls.",
"Get the team's plan name (`plan`), sending limits and usage: the send limit (emails_per_day or emails_per_month), domain limit and contact limit (`limits.contacts`, null when unlimited), emails accepted so far today (UTC) and when that counter resets, and on a monthly limit a `period` object with the period's emails_sent, included volume, whether sending past it is on and when the period ends. A self-hosted instance reports cloud=false with null plan, limits and period; the instance's own (system) team reports cloud=true with the same nulls.",
inputSchema: z.object({}),
readOnly: true,
},
() => api("GET", "/usage"),
// Prices stay out of chat clients' results: directories refuse pricing in a plugin.
() =>
api("GET", "/usage", undefined, undefined, (json) => {
const period = json.period as Record<string, unknown> | null | undefined;
if (period) delete period.overage_usd_per_1k;
}),
);
tool(
"list_contacts",
Expand Down Expand Up @@ -697,12 +724,11 @@ function buildServer(
{
title: "Get webhook",
description:
"Get one webhook endpoint by id, including its Standard Webhooks signing secret (whsec_…).",
"Get one webhook endpoint by id: URL, subscribed events and status. The signing secret is not returned by these tools; it is available by rotating it in the MillionSend dashboard or from the REST API.",
inputSchema: z.object({ id: z.uuid().describe("Webhook id from list_webhooks") }),
readOnly: true,
admin: true,
},
({ id }) => api("GET", `/webhooks/${enc(id)}`),
({ id }) => api("GET", `/webhooks/${enc(id)}`, undefined, undefined, omitSigningSecret),
);
tool(
"list_api_keys",
Expand Down Expand Up @@ -737,6 +763,7 @@ function buildServer(
description: `Get one sending domain with its DNS records (DKIM, MAIL FROM, DMARC, and the Tracking CNAME once a tracking subdomain is set) and per-record status. ${RECORD_STATUS_NOTE}`,
inputSchema: z.object({ id: z.uuid().describe("Domain id from list_domains") }),
readOnly: true,
openWorld: true,
},
({ id }) => api("GET", `/domains/${enc(id)}`),
);
Expand All @@ -748,9 +775,10 @@ function buildServer(
{
title: "Send email",
description:
"Send a transactional email (or schedule it with scheduled_at). Suppressed and topic-opted-out recipients are skipped automatically. Returns the email id.",
"Send a transactional email (or schedule it with scheduled_at). Suppressed and topic-opted-out recipients are skipped automatically. Returns the email id. Each call is a new send: repeating it delivers the email again.",
inputSchema: sendEmailRequestSchema,
destructive: true,
openWorld: true,
},
(body) => api("POST", "/emails", body),
);
Expand All @@ -760,11 +788,12 @@ function buildServer(
{
title: "Send email batch",
description:
"Send up to 100 emails in one call; each entry has the same shape as send_email. Returns one id per accepted email.",
"Send up to 100 emails in one call; each entry has the same shape as send_email. Returns one id per accepted email. Each call is a new send: repeating it delivers every email again.",
inputSchema: z.object({
emails: batchEmailRequestSchema.describe("The emails to send, same shape as send_email"),
}),
destructive: true,
openWorld: true,
},
({ emails }) => api("POST", "/emails/batch", emails),
);
Expand All @@ -779,6 +808,7 @@ function buildServer(
}),
destructive: true,
idempotent: true,
openWorld: true,
},
({ id, ...body }) => api("PATCH", `/emails/${enc(id)}`, body),
);
Expand Down Expand Up @@ -1100,6 +1130,7 @@ function buildServer(
"Create a broadcast (bulk email to a segment or the whole audience). Saved as a draft unless send is true; send_broadcast sends a saved draft. A large audience is paced over days; the response's finishes_at and warning say when it finishes.",
inputSchema: createBroadcastRequestSchema,
destructive: true,
openWorld: true,
},
(body) => api("POST", "/broadcasts", body),
);
Expand Down Expand Up @@ -1128,6 +1159,7 @@ function buildServer(
id: z.uuid().describe("Broadcast id from create_broadcast"),
}),
destructive: true,
openWorld: true,
},
({ id, ...body }) => api("POST", `/broadcasts/${enc(id)}/send`, body),
);
Expand Down Expand Up @@ -1201,10 +1233,11 @@ function buildServer(
{
title: "Create webhook",
description:
"Create a webhook endpoint subscribed to email events. The response includes the Standard Webhooks signing secret (whsec_…) used to verify deliveries; get_webhook also returns it.",
inputSchema: createWebhookRequestSchema,
"Create a webhook endpoint subscribed to email events. MillionSend generates its Standard Webhooks signing secret, which these tools never return; the full secret is available by rotating it in the MillionSend dashboard or from the REST API (GET /webhooks/{id}).",
inputSchema: createWebhookRequestSchema.omit({ signing_secret: true }),
openWorld: true,
},
(body) => api("POST", "/webhooks", body),
(body) => api("POST", "/webhooks", body, undefined, omitSigningSecret),
);
tool(
"update_webhook",
Expand All @@ -1218,24 +1251,10 @@ function buildServer(
}),
destructive: true,
idempotent: true,
openWorld: true,
},
({ id, ...body }) => api("PATCH", `/webhooks/${enc(id)}`, body),
);
tool(
"rotate_webhook_secret",
"webhooks:write",
{
title: "Rotate webhook secret",
description:
"Rotate a webhook's signing secret. Returns the new whsec_ secret; the previous one keeps signing alongside it for overlap_hours (default 24, up to 72) so the receiver can switch without a gap. signing_secret optionally supplies your own.",
inputSchema: rotateWebhookSecretRequestSchema.extend({
id: z.uuid().describe("Webhook id from list_webhooks"),
}),
admin: true,
destructive: true,
},
({ id, ...body }) => api("POST", `/webhooks/${enc(id)}/rotate`, body),
);
tool(
"delete_webhook",
"webhooks:write",
Expand All @@ -1248,17 +1267,6 @@ function buildServer(
},
({ id }) => api("DELETE", `/webhooks/${enc(id)}`),
);
tool(
"create_api_key",
"api-keys:write",
{
title: "Create API key",
description:
"Create an API key for REST and SDK access. The token is a secret returned only in this response and never again. permission is full_access (default) or sending_access; domain_id restricts a key to one verified domain.",
inputSchema: createApiKeyRequestSchema,
},
(body) => api("POST", "/api-keys", body),
);
tool(
"revoke_api_key",
"api-keys:write",
Expand All @@ -1281,6 +1289,7 @@ function buildServer(
title: "Add domain",
description: `Add a sending domain. region is optional: this deployment serves ${regions.join(", ")} (default ${regions[0]}) and refuses any other. A domain has one region, fixed when it is added. Returns the DNS records to create; the domain sends once they verify. Open and click tracking start off; open_tracking/click_tracking together with a tracking_subdomain set the domain up tracked in one call — its Tracking CNAME then comes back with the other records (same rules as update_domain).`,
inputSchema: createDomainRequestSchema(regions),
openWorld: true,
},
(body) => api("POST", "/domains", body),
);
Expand All @@ -1296,6 +1305,7 @@ function buildServer(
}),
destructive: true,
idempotent: true,
openWorld: true,
},
({ id, ...body }) => api("PATCH", `/domains/${enc(id)}`, body),
);
Expand All @@ -1307,6 +1317,7 @@ function buildServer(
description: `Re-check a domain's DNS records and SES verification, returning the domain with fresh per-record status. ${RECORD_STATUS_NOTE}`,
inputSchema: z.object({ id: z.uuid().describe("Domain id from list_domains") }),
idempotent: true,
openWorld: true,
},
({ id }) => api("POST", `/domains/${enc(id)}/verify`),
);
Expand All @@ -1320,6 +1331,7 @@ function buildServer(
inputSchema: z.object({ id: z.uuid().describe("Domain id from list_domains") }),
destructive: true,
idempotent: true,
openWorld: true,
},
({ id }) => api("DELETE", `/domains/${enc(id)}`),
);
Expand Down Expand Up @@ -1366,6 +1378,8 @@ export function registerMcp(app: OpenAPIHono<Env>, deps: ApiDeps, appBaseUrl: st
};
app.get("/.well-known/oauth-protected-resource", (c) => c.json(metadata));
app.get(`/.well-known/oauth-protected-resource${MCP_RESOURCE_PATH}`, (c) => c.json(metadata));
const challenge = deps.openaiAppsChallengeToken;
if (challenge) app.get("/.well-known/openai-apps-challenge", (c) => c.text(challenge));
// Origin is not validated: auth is an explicit bearer (never cookies), so a
// DNS-rebound page holds no credential — the same reasoning behind the REST
// API's wildcard CORS.
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const app = createApi({
},
isCloud: env.IS_CLOUD,
onboardingEmailFrom: env.ONBOARDING_EMAIL_FROM,
openaiAppsChallengeToken: env.OPENAI_APPS_CHALLENGE_TOKEN,
requireVerifiedMembers: accountMailDeliverable(),
rateLimitPerMinute: env.API_RATE_LIMIT_PER_MINUTE,
revision: env.MILLIONSEND_REVISION,
Expand Down
Loading
Loading