diff --git a/.env.example b/.env.example index 677d96841..0a9f41dd0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a5eed9471..472ddd73b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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; /** diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts index 3536cfa4b..9dd854859 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -37,7 +37,6 @@ import { batchEmailRequestSchema, batchRemoveContactsRequestSchema, batchRemoveSuppressionsRequestSchema, - createApiKeyRequestSchema, createBroadcastRequestSchema, createContactPropertyRequestSchema, createContactRequestSchema, @@ -49,7 +48,6 @@ import { listContactsQuerySchema, listQuerySchema, listSuppressionsQuerySchema, - rotateWebhookSecretRequestSchema, sendBroadcastRequestSchema, sendEmailRequestSchema, updateBroadcastRequestSchema, @@ -289,6 +287,7 @@ async function callApi( path: string, body?: unknown, headers: Record = {}, + shape?: (json: Record) => void, ): Promise { const req = new Request(`http://mcp.internal${path}`, { method, @@ -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); 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) => { + 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. */ @@ -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); @@ -346,7 +359,8 @@ function buildServer( path: string, body?: unknown, headers?: Record, - ) => callApi(app, callTeam?.getStore() ?? auth, method, path, body, headers); + shape?: (json: Record) => void, + ) => callApi(app, callTeam?.getStore() ?? auth, method, path, body, headers, shape); const teamIdArg = z .uuid() .optional() @@ -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; }, @@ -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 @@ -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( @@ -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 | null | undefined; + if (period) delete period.overage_usd_per_1k; + }), ); tool( "list_contacts", @@ -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", @@ -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)}`), ); @@ -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), ); @@ -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), ); @@ -779,6 +808,7 @@ function buildServer( }), destructive: true, idempotent: true, + openWorld: true, }, ({ id, ...body }) => api("PATCH", `/emails/${enc(id)}`, body), ); @@ -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), ); @@ -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), ); @@ -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", @@ -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", @@ -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", @@ -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), ); @@ -1296,6 +1305,7 @@ function buildServer( }), destructive: true, idempotent: true, + openWorld: true, }, ({ id, ...body }) => api("PATCH", `/domains/${enc(id)}`, body), ); @@ -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`), ); @@ -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)}`), ); @@ -1366,6 +1378,8 @@ export function registerMcp(app: OpenAPIHono, 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. diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 0e328e0fe..e40bc6a10 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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, diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index a53482a1e..6c8e9b0e7 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import { type ServerType, serve } from "@hono/node-server"; -import { DAY_MS, EnvKeyring, MCP_SCOPES, mcpResourceUrl } from "@millionsend/core"; +import { DAY_MS, EnvKeyring, generateApiKey, MCP_SCOPES, mcpResourceUrl } from "@millionsend/core"; import type { Db } from "@millionsend/db"; import { schema } from "@millionsend/db"; import { createTeam, createTestDb } from "@millionsend/test-utils"; @@ -170,6 +170,21 @@ describe("auth middleware", () => { } }); + it("serves the OpenAI domain-verification token only when one is configured", async () => { + expect((await app.request("/.well-known/openai-apps-challenge")).status).toBe(404); + const withToken = createApi({ + db, + keyring: EnvKeyring.fromBase64(randomBytes(32).toString("base64")), + isCloud: false, + appBaseUrl, + openaiAppsChallengeToken: "challenge-abc", + enqueueEmailSend: async () => {}, + }); + const res = await withToken.request("/.well-known/openai-apps-challenge"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("challenge-abc"); + }); + it("401s a token minted for another resource (aud mismatch)", async () => { const token = await mintToken({ aud: "https://elsewhere.example/mcp" }); const res = await app.request("/mcp", { @@ -246,7 +261,7 @@ describe("tool listing", () => { await client.close(); }); - it("every tool carries a title in both places and an explicit write-safety hint", async () => { + it("every tool carries a title in both places and all four hints as booleans", async () => { const client = await connect(await mintToken()); const tools = (await client.listTools()).tools; expect(client.getServerVersion()?.icons?.[0]?.src).toBe( @@ -255,11 +270,16 @@ describe("tool listing", () => { for (const t of tools) { expect(t.title, t.name).toBeTruthy(); expect(t.annotations?.title, t.name).toBe(t.title); - expect(typeof t.annotations?.readOnlyHint, t.name).toBe("boolean"); - if (t.annotations?.readOnlyHint) continue; - expect(typeof t.annotations?.destructiveHint, t.name).toBe("boolean"); - expect(typeof t.annotations?.idempotentHint, t.name).toBe("boolean"); + for (const key of [ + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + ] as const) { + expect(typeof t.annotations?.[key], `${t.name}.${key}`).toBe("boolean"); + } } + expect(client.getInstructions()).toContain("purchased, rented and scraped lists"); const hint = (name: string) => tools.find((t) => t.name === name)?.annotations; for (const name of ["send_email", "create_broadcast", "update_contact", "cancel_email"]) { expect(hint(name)?.destructiveHint, name).toBe(true); @@ -267,6 +287,10 @@ describe("tool listing", () => { expect(hint("create_contact")?.destructiveHint).toBe(false); expect(hint("delete_contact")?.idempotentHint).toBe(true); expect(hint("send_email")?.idempotentHint).toBe(false); + expect(hint("send_email")?.openWorldHint).toBe(true); + expect(hint("create_webhook")?.openWorldHint).toBe(true); + expect(hint("create_contact")?.openWorldHint).toBe(false); + expect(hint("list_emails")).toMatchObject({ readOnlyHint: true, destructiveHint: false }); await client.close(); }); @@ -369,9 +393,7 @@ describe("tool listing", () => { "delete_template", "create_webhook", "update_webhook", - "rotate_webhook_secret", "delete_webhook", - "create_api_key", "revoke_api_key", "create_domain", "update_domain", @@ -434,12 +456,9 @@ describe("tool listing", () => { expect(names).toContain("list_api_keys"); expect(names).toContain("send_email"); for (const hidden of [ - "get_webhook", "create_webhook", "update_webhook", - "rotate_webhook_secret", "delete_webhook", - "create_api_key", "revoke_api_key", "create_domain", "update_domain", @@ -759,16 +778,20 @@ describe('all-teams tokens (team_id claim "*")', () => { }); }); -it("create_webhook surfaces the signing secret verbatim and lists back without it", async () => { +it("webhook tools never carry the signing secret, in or out", async () => { const client = await connect(await mintToken({ scope: "webhooks:write" })); - expect((await client.listTools()).tools.map((t) => t.name)).toEqual([ + const tools = (await client.listTools()).tools; + expect(tools.map((t) => t.name)).toEqual([ "list_webhooks", "get_webhook", "create_webhook", "update_webhook", - "rotate_webhook_secret", "delete_webhook", ]); + const createSchema = tools.find((t) => t.name === "create_webhook")?.inputSchema as { + properties?: Record; + }; + expect(createSchema.properties).not.toHaveProperty("signing_secret"); const created = await client.callTool({ name: "create_webhook", arguments: { @@ -777,14 +800,14 @@ it("create_webhook surfaces the signing secret verbatim and lists back without i }, }); expect(created.isError).toBeFalsy(); - const { id, signing_secret } = resultJson(created) as { id: string; signing_secret: string }; - expect(signing_secret).toMatch(/^whsec_/); + const createdJson = resultJson(created); + const { id } = createdJson as { id: string }; + expect(JSON.stringify(createdJson)).not.toContain("whsec_"); const listed = resultJson(await client.callTool({ name: "list_webhooks", arguments: {} })); - const listedText = JSON.stringify(listed); - expect(listedText).toContain(id); - expect(listedText).not.toContain(signing_secret); + expect(JSON.stringify(listed)).toContain(id); const got = resultJson(await client.callTool({ name: "get_webhook", arguments: { id } })); - expect((got as { signing_secret?: string }).signing_secret).toBe(signing_secret); + expect(got).toMatchObject({ id }); + expect(JSON.stringify(got)).not.toContain("whsec_"); await client.close(); }); @@ -934,44 +957,41 @@ describe("REST parity tools", () => { ends_at: end.toISOString(), }, }); + const period = ( + resultJson(await client.callTool({ name: "get_usage", arguments: {} })) as { + period: Record; + } + ).period; + expect(period).not.toHaveProperty("overage_usd_per_1k"); await client.close(); }); - it("api keys: create returns the token once, list never does, revoke cuts it off; members only list", async () => { + it("api keys: tools list and revoke but never mint a credential; members only list", async () => { + const key = generateApiKey(); + const [row] = await db + .insert(schema.apiKeys) + .values({ + teamId, + name: "dashboard key", + tokenPrefix: key.tokenPrefix, + keyHash: key.keyHash, + last4: key.last4, + }) + .returning({ id: schema.apiKeys.id }); + const id = row?.id ?? ""; const client = await connect(await mintToken({ scope: "api-keys:write" })); expect((await client.listTools()).tools.map((t) => t.name)).toEqual([ "list_api_keys", - "create_api_key", "revoke_api_key", ]); - const created = await client.callTool({ - name: "create_api_key", - arguments: { name: "minted over mcp" }, - }); - expect(created.isError).toBeFalsy(); - const { id, token } = resultJson(created) as { id: string; token: string }; - expect(token).toMatch(/^ms_/); - // The audit row names the person behind the token, as a dashboard action would. - const audits = await db - .select({ actorId: schema.auditLog.actorId }) - .from(schema.auditLog) - .where( - and(eq(schema.auditLog.teamId, teamId), eq(schema.auditLog.action, "api_key.created")), - ); - expect(audits).toEqual([{ actorId: `user:${userId}` }]); const listed = JSON.stringify( resultJson(await client.callTool({ name: "list_api_keys", arguments: {} })), ); expect(listed).toContain(id); - expect(listed).not.toContain(token); - // The minted key is a real REST credential for the same team. - const asKey = (path: string) => - app.request(path, { headers: { authorization: `Bearer ${token}` } }); - expect((await asKey("/usage")).status).toBe(200); + expect(listed).not.toContain(key.token); expect( resultJson(await client.callTool({ name: "revoke_api_key", arguments: { id } })), ).toMatchObject({ id, deleted: true }); - expect((await asKey("/usage")).status).toBe(401); await client.close(); await db.insert(schema.user).values({ id: "member-2", name: "Member", email: "m2@acme.dev" }); diff --git a/apps/docs/content/docs/mcp.mdx b/apps/docs/content/docs/mcp.mdx index 60274893b..0a337b67e 100644 --- a/apps/docs/content/docs/mcp.mdx +++ b/apps/docs/content/docs/mcp.mdx @@ -119,13 +119,14 @@ client's config. - A member removed from the team loses MCP access immediately, even before their token expires. - Your team role applies: tools that manage domains, webhooks and API keys - (and `get_webhook`, which returns the signing secret) are only offered to - owners and admins, matching the dashboard. A **member** with those + are only offered to owners and admins, matching the dashboard. A **member** with those permissions granted still gets the read-only domain, webhook and API key listings. On an **All teams** grant the tools appear when you are an admin in any team, and calls into a team where you are a member are refused. - MCP calls share the API's per-minute rate limit. +- No tool returns a credential: API keys are created, and webhook signing + secrets read or rotated, only in the dashboard or through the REST API. - The MCP server is available on every plan, including Free. Questions or problems connecting a client: [support@millionsend.com](mailto:support@millionsend.com). @@ -157,7 +158,7 @@ and admins. | `list_templates` | `templates:read` | List email templates. | | `get_template` | `templates:read` | Get one template by id or alias, with its subject, html and text. | | `list_webhooks` | `webhooks:write` | List webhook endpoints (list rows never carry signing secrets). | -| `get_webhook` | `webhooks:write` | **admin** Get one webhook, including its signing secret. | +| `get_webhook` | `webhooks:write` | Get one webhook: URL, events and status. | | `list_api_keys` | `api-keys:write` | List active API keys (never their tokens). | | `list_domains` | `domains:read` | List sending domains with verification status. | | `get_domain` | `domains:read` | Get one domain with its DNS records. | @@ -194,11 +195,9 @@ and admins. | `create_template` | `templates:write` | Create an email template (live immediately). | | `update_template` | `templates:write` | Change a template's name, subject, html, text or alias. | | `delete_template` | `templates:write` | Delete a template; broadcasts keep their own copy. | -| `create_webhook` | `webhooks:write` | **admin** Create a webhook endpoint; the response includes the signing secret. | +| `create_webhook` | `webhooks:write` | **admin** Create a webhook endpoint. | | `update_webhook` | `webhooks:write` | **admin** Update a webhook's URL, events or status. | -| `rotate_webhook_secret` | `webhooks:write` | **admin** Rotate a webhook's signing secret with an overlap window. | | `delete_webhook` | `webhooks:write` | **admin** Delete a webhook endpoint. | -| `create_api_key` | `api-keys:write` | **admin** Create an API key; the token is returned only in this response. | | `revoke_api_key` | `api-keys:write` | **admin** Revoke an API key. | | `create_domain` | `domains:write` | **admin** Add a sending domain and get its DNS records; optional tracking settings apply at creation. | | `update_domain` | `domains:write` | **admin** Change a domain's tracking settings; `tracking_subdomain` is what yields the Tracking CNAME (required on Cloud). | diff --git a/apps/docs/content/docs/mcp.pt-BR.mdx b/apps/docs/content/docs/mcp.pt-BR.mdx index 9448c5b77..3901d115c 100644 --- a/apps/docs/content/docs/mcp.pt-BR.mdx +++ b/apps/docs/content/docs/mcp.pt-BR.mdx @@ -121,13 +121,15 @@ segredo fica na configuração do cliente. - Um membro removido do time perde o acesso MCP imediatamente, mesmo antes de o token expirar. - Seu papel no time vale aqui também: as ferramentas que gerenciam domínios, - webhooks e chaves de API (e `get_webhook`, que retorna o segredo de - assinatura) só são oferecidas a owners e admins, como no dashboard. Um + webhooks e chaves de API só são oferecidas a owners e admins, como no dashboard. Um **membro** com essas permissões concedidas ainda recebe as listagens somente leitura de domínios, webhooks e chaves de API. Em uma concessão **Todos os times** as ferramentas aparecem se você for admin em algum time, e chamadas em um time onde você é membro são recusadas. - Chamadas MCP compartilham o limite por minuto da API. +- Nenhuma ferramenta retorna uma credencial: chaves de API são criadas, e + segredos de assinatura de webhook lidos ou rotacionados, só no dashboard ou + pela API REST. - O servidor MCP está disponível em todos os planos, inclusive o Free. Dúvidas ou problemas ao conectar um cliente: [support@millionsend.com](mailto:support@millionsend.com). @@ -159,7 +161,7 @@ cobre as duas ferramentas de `broadcasts:read`. Ferramentas marcadas com | `list_templates` | `templates:read` | Lista templates de e-mail. | | `get_template` | `templates:read` | Busca um template por id ou alias, com assunto, html e texto. | | `list_webhooks` | `webhooks:write` | Lista os webhooks (linhas de lista nunca trazem o segredo de assinatura). | -| `get_webhook` | `webhooks:write` | **admin** Busca um webhook, incluindo o segredo de assinatura. | +| `get_webhook` | `webhooks:write` | Busca um webhook: URL, eventos e status. | | `list_api_keys` | `api-keys:write` | Lista as chaves de API ativas (nunca os tokens). | | `list_domains` | `domains:read` | Lista domínios de envio com o status de verificação. | | `get_domain` | `domains:read` | Busca um domínio com os registros DNS. | @@ -196,11 +198,9 @@ cobre as duas ferramentas de `broadcasts:read`. Ferramentas marcadas com | `create_template` | `templates:write` | Cria um template de e-mail (ativo na hora). | | `update_template` | `templates:write` | Altera nome, assunto, html, texto ou alias de um template. | | `delete_template` | `templates:write` | Exclui um template; os broadcasts mantêm a própria cópia. | -| `create_webhook` | `webhooks:write` | **admin** Cria um webhook; a resposta inclui o segredo de assinatura. | +| `create_webhook` | `webhooks:write` | **admin** Cria um webhook. | | `update_webhook` | `webhooks:write` | **admin** Atualiza URL, eventos ou status de um webhook. | -| `rotate_webhook_secret` | `webhooks:write` | **admin** Rotaciona o segredo de assinatura de um webhook com janela de sobreposição. | | `delete_webhook` | `webhooks:write` | **admin** Exclui um webhook. | -| `create_api_key` | `api-keys:write` | **admin** Cria uma chave de API; o token só vem nesta resposta. | | `revoke_api_key` | `api-keys:write` | **admin** Revoga uma chave de API. | | `create_domain` | `domains:write` | **admin** Adiciona um domínio de envio e retorna os registros DNS; configurações de rastreamento opcionais valem já na criação. | | `update_domain` | `domains:write` | **admin** Muda as configurações de rastreamento de um domínio; o `tracking_subdomain` é o que gera o CNAME de rastreamento (obrigatório no Cloud). | diff --git a/apps/web/messages/en/auth.json b/apps/web/messages/en/auth.json index 26066c012..4023ccf30 100644 --- a/apps/web/messages/en/auth.json +++ b/apps/web/messages/en/auth.json @@ -109,8 +109,8 @@ "domains_write": "Add, verify and remove sending domains", "templates_read": "Read email templates", "templates_write": "Create, edit and delete email templates", - "webhooks_write": "Manage webhooks and read their signing secrets", - "api-keys_write": "Create and revoke API keys" + "webhooks_write": "Manage webhooks", + "api-keys_write": "List and revoke API keys" }, "allow": "Allow", "deny": "Deny", diff --git a/apps/web/messages/pt-BR/auth.json b/apps/web/messages/pt-BR/auth.json index 2a6053d89..20f6f1066 100644 --- a/apps/web/messages/pt-BR/auth.json +++ b/apps/web/messages/pt-BR/auth.json @@ -109,8 +109,8 @@ "domains_write": "Adicionar, verificar e remover domínios de envio", "templates_read": "Ler templates de e-mail", "templates_write": "Criar, editar e excluir templates de e-mail", - "webhooks_write": "Gerenciar webhooks e ler seus segredos de assinatura", - "api-keys_write": "Criar e revogar chaves de API" + "webhooks_write": "Gerenciar webhooks", + "api-keys_write": "Listar e revogar chaves de API" }, "allow": "Permitir", "deny": "Negar", diff --git a/apps/web/src/app/(dashboard)/settings/mcp/mcp-tools.ts b/apps/web/src/app/(dashboard)/settings/mcp/mcp-tools.ts index 1bb653333..5a6fb00bd 100644 --- a/apps/web/src/app/(dashboard)/settings/mcp/mcp-tools.ts +++ b/apps/web/src/app/(dashboard)/settings/mcp/mcp-tools.ts @@ -62,9 +62,7 @@ export const MCP_TOOLS: { name: string; scope: McpScope; readOnly?: boolean }[] { name: "delete_template", scope: "templates:write" }, { name: "create_webhook", scope: "webhooks:write" }, { name: "update_webhook", scope: "webhooks:write" }, - { name: "rotate_webhook_secret", scope: "webhooks:write" }, { name: "delete_webhook", scope: "webhooks:write" }, - { name: "create_api_key", scope: "api-keys:write" }, { name: "revoke_api_key", scope: "api-keys:write" }, { name: "create_domain", scope: "domains:write" }, { name: "update_domain", scope: "domains:write" }, diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts index b6263018b..9a2a4de94 100644 --- a/packages/config/src/env.ts +++ b/packages/config/src/env.ts @@ -306,6 +306,10 @@ export const env = createEnv({ // "Send email" button and the snippet asks for the team's own domain. ONBOARDING_EMAIL_FROM: z.string().optional(), + // OpenAI's plugin portal verifies ownership of the MCP host by fetching + // this token from /.well-known/openai-apps-challenge. Unset → 404. + OPENAI_APPS_CHALLENGE_TOKEN: z.string().optional(), + // Cloudflare Turnstile, both keys or neither: sign-in, sign-up, password // reset and the onboarding send verify a token when set. Unset, every // form works as before with no challenge. diff --git a/packages/core/src/oauth-scopes.ts b/packages/core/src/oauth-scopes.ts index 5a8c6507d..48390793d 100644 --- a/packages/core/src/oauth-scopes.ts +++ b/packages/core/src/oauth-scopes.ts @@ -14,12 +14,11 @@ export const MCP_SCOPES = [ "domains:write", "templates:read", "templates:write", - // One scope for the whole webhook surface: even reads return signing - // secrets (the SDK wire retrieves them by design), so a read/write split - // would imply a safety boundary that does not exist. + // One scope each for the webhook and API key surfaces. MCP tools never + // return a credential (signing secrets and new keys stay in the dashboard + // and REST API); the names predate that and are kept so existing grants + // stay valid. "webhooks:write", - // One scope for API keys as well: listing never returns a token, but the - // surface exists to mint credentials, so it is granted as a whole. "api-keys:write", ] as const; diff --git a/packages/ses/src/setup-constants.ts b/packages/ses/src/setup-constants.ts index 3fac2dd31..447d2c7cf 100644 --- a/packages/ses/src/setup-constants.ts +++ b/packages/ses/src/setup-constants.ts @@ -302,6 +302,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