From caabac23f1617e6bd5480d48f93012e198b3ce3e Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 15:58:40 +0530 Subject: [PATCH 1/6] feat(mcp): run MCP on the Platform kit with dedicated and generic api.* tools SendLit's own SDK 2 server registered one tool per REST operation (118). /mcp now runs on @codelitdev/mcp-server-kit, following CourseLit: 29 dedicated tools for the everyday flows (contacts, segments, templates, sequences and broadcasts, transactional email, events, delivery events, plan usage), and api.list_operations / api.describe_operation / api.read / api.write / api.delete for every other team operation in the parity manifest. Generic calls are real REST requests to the same app over a loopback listener, with the caller's credential, team, IP and request id. Authorization is per operation (policy.ts maps each MCP-reachable operation to its OAuth scope) for both kinds of tool; team keys still can't manage keys. The route keeps SendLit's auth and team resolution (now shared with REST via resolveUserTeam), x-sendlit-apikey, the OAuth discovery routes, the WWW-Authenticate challenge and per-tool rate limits. Tool errors are platform errors whose details keep SendLit's reason and message. Breaking for MCP clients: removed tools are reached through api.* and deletes need confirm: true; server version 3.0.0. See ADR 0010. Needs @codelitdev/mcp-server-kit with tool title/outputSchema/annotations (codelitdev/platform#22). --- apps/api/src/app.ts | 5 +- apps/api/src/auth/require-team.ts | 143 +- apps/api/src/contacts/tags.test.ts | 48 - apps/api/src/mcp/api-tools.test.ts | 298 +++ apps/api/src/mcp/api-tools.ts | 447 +++++ apps/api/src/mcp/auth-context.test.ts | 57 - apps/api/src/mcp/auth-context.ts | 76 - apps/api/src/mcp/context.ts | 168 ++ apps/api/src/mcp/parity.test.ts | 56 +- apps/api/src/mcp/policy.ts | 319 ++-- apps/api/src/mcp/routes.test.ts | 379 ++-- apps/api/src/mcp/routes.ts | 245 +-- apps/api/src/mcp/schema.ts | 22 - apps/api/src/mcp/server.test.ts | 301 +-- apps/api/src/mcp/server.ts | 118 +- apps/api/src/mcp/tool-registry.ts | 125 -- apps/api/src/mcp/tool.ts | 76 + apps/api/src/mcp/tools/auth.ts | 17 - apps/api/src/mcp/tools/contacts.test.ts | 208 +++ apps/api/src/mcp/tools/contacts.ts | 606 ++----- .../src/mcp/tools/delivery-feedback.test.ts | 301 +-- apps/api/src/mcp/tools/delivery-feedback.ts | 470 +---- apps/api/src/mcp/tools/esp.ts | 475 ----- apps/api/src/mcp/tools/events.test.ts | 91 + apps/api/src/mcp/tools/events.ts | 215 +-- apps/api/src/mcp/tools/forms.ts | 126 -- apps/api/src/mcp/tools/general-settings.ts | 69 - apps/api/src/mcp/tools/media.ts | 206 --- apps/api/src/mcp/tools/reputation.ts | 119 -- apps/api/src/mcp/tools/responses.ts | 85 +- apps/api/src/mcp/tools/schemas.ts | 192 +- apps/api/src/mcp/tools/segments.test.ts | 84 + apps/api/src/mcp/tools/segments.ts | 193 +- apps/api/src/mcp/tools/sequences.test.ts | 345 ++++ apps/api/src/mcp/tools/sequences.ts | 932 ++++------ apps/api/src/mcp/tools/tags.ts | 123 -- apps/api/src/mcp/tools/teams.test.ts | 72 + apps/api/src/mcp/tools/teams.ts | 385 +--- apps/api/src/mcp/tools/templates.test.ts | 163 ++ apps/api/src/mcp/tools/templates.ts | 331 +--- apps/api/src/mcp/tools/tools.test.ts | 1612 ----------------- apps/api/src/mcp/tools/tracking-domain.ts | 79 - apps/api/src/mcp/tools/transactional.test.ts | 180 ++ apps/api/src/mcp/tools/transactional.ts | 343 ++-- apps/api/src/mcp/tools/web.ts | 144 -- .../content/docs/activity/suppressions.mdx | 2 +- .../docs/developers/authentication.mdx | 2 +- apps/docs/content/docs/developers/mcp.mdx | 114 +- .../docs/email-marketing/broadcasts.mdx | 4 +- .../content/docs/email-marketing/contacts.mdx | 10 +- .../email-marketing/domain-authentication.mdx | 3 +- .../docs/email-marketing/sequences.mdx | 6 +- .../docs/email-marketing/tracking-domain.mdx | 4 +- .../content/docs/email-marketing/warm-up.mdx | 8 +- .../docs/self-hosting/amazon-ses-feedback.mdx | 2 +- ...the-platform-kit-with-generic-api-tools.md | 62 + packages/api-contract/src/index.ts | 1 + packages/api-contract/src/mcp-parity.ts | 194 +- 58 files changed, 4099 insertions(+), 7362 deletions(-) create mode 100644 apps/api/src/mcp/api-tools.test.ts create mode 100644 apps/api/src/mcp/api-tools.ts delete mode 100644 apps/api/src/mcp/auth-context.test.ts delete mode 100644 apps/api/src/mcp/auth-context.ts create mode 100644 apps/api/src/mcp/context.ts delete mode 100644 apps/api/src/mcp/schema.ts delete mode 100644 apps/api/src/mcp/tool-registry.ts create mode 100644 apps/api/src/mcp/tool.ts delete mode 100644 apps/api/src/mcp/tools/auth.ts create mode 100644 apps/api/src/mcp/tools/contacts.test.ts delete mode 100644 apps/api/src/mcp/tools/esp.ts create mode 100644 apps/api/src/mcp/tools/events.test.ts delete mode 100644 apps/api/src/mcp/tools/forms.ts delete mode 100644 apps/api/src/mcp/tools/general-settings.ts delete mode 100644 apps/api/src/mcp/tools/media.ts delete mode 100644 apps/api/src/mcp/tools/reputation.ts create mode 100644 apps/api/src/mcp/tools/segments.test.ts create mode 100644 apps/api/src/mcp/tools/sequences.test.ts delete mode 100644 apps/api/src/mcp/tools/tags.ts create mode 100644 apps/api/src/mcp/tools/teams.test.ts create mode 100644 apps/api/src/mcp/tools/templates.test.ts delete mode 100644 apps/api/src/mcp/tools/tools.test.ts delete mode 100644 apps/api/src/mcp/tools/tracking-domain.ts create mode 100644 apps/api/src/mcp/tools/transactional.test.ts delete mode 100644 apps/api/src/mcp/tools/web.ts create mode 100644 docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 460dab12..0c72266e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -26,7 +26,7 @@ import espWebhookRoutes from "./delivery-feedback/webhook-route"; import eventsRoutes from "./events/routes"; import signupFormPublicRoutes from "./forms/public-routes"; import signupFormRoutes from "./forms/routes"; -import mcpRoutes from "./mcp/routes"; +import { createMcpRoutes } from "./mcp/routes"; import mediaRoutes from "./media/routes"; import { captureError, getTeamId } from "./observability"; import organizationRoutes from "./organization/routes"; @@ -100,7 +100,8 @@ export function createApp(options: CreateAppOptions) { app.use(oauthPagesRoutes); // The MCP handler owns JSON parsing and protocol content-type errors, so it // must be mounted before the API's global Express body parser. - app.use(mcpRoutes); + // Generic api.* tools run REST operations by injecting into this app. + app.use(createMcpRoutes({ app: () => app })); // Mounted before global body parsing: this route needs the unmodified raw // request bytes for provider signature verification and has no // session/API-key concept at all — see delivery-feedback/webhook-route.ts. diff --git a/apps/api/src/auth/require-team.ts b/apps/api/src/auth/require-team.ts index 5219ee0f..bccecc76 100644 --- a/apps/api/src/auth/require-team.ts +++ b/apps/api/src/auth/require-team.ts @@ -8,18 +8,91 @@ function getHeaderValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +export type UserTeamResolution = + | { ok: true; teamId: string } + | { + ok: false; + status: number; + error: "invalid_team_id" | "not_a_team_member" | "no_team" | "team_required"; + body: Record; + }; + +/** + * The team a user-authenticated request acts on: the `X-Sendlit-Team-Id` + * header, validated against team membership on every call (so the dashboard + * can switch teams without re-authenticating), or the user's only team when + * the header is absent. Shared by REST (`requireTeam`) and MCP. + */ +export async function resolveUserTeam( + userId: string, + headerTeamIdValue: unknown, +): Promise { + const headerTeamId = getHeaderValue(headerTeamIdValue); + if (headerTeamId) { + const invalid = { + ok: false as const, + status: 400, + error: "invalid_team_id" as const, + body: { + error: "invalid_team_id", + error_description: "The provided team ID is not valid.", + }, + }; + const team = await getTeamByTeamId(headerTeamId); + if (!team) return invalid; + let membership: Awaited>; + try { + membership = await getTeamMembership(team.id, userId); + } catch { + return invalid; + } + if (!membership) { + return { + ok: false, + status: 403, + error: "not_a_team_member", + body: { + error: "not_a_team_member", + error_description: "You are not a member of this team.", + }, + }; + } + return { ok: true, teamId: team.id }; + } + + const teams = await listTeamsForUser(userId); + if (teams.length === 1) return { ok: true, teamId: teams[0].id }; + if (teams.length === 0) { + return { + ok: false, + status: 409, + error: "no_team", + body: { + error: "no_team", + error_description: "This user doesn't belong to any team yet.", + }, + }; + } + return { + ok: false, + status: 409, + error: "team_required", + body: { + error: "team_required", + error_description: + "This user belongs to multiple teams — specify one via the X-Sendlit-Team-Id header.", + teams: teams.map((t) => ({ teamId: t.teamId, name: t.name })), + }, + }; +} + /** * Resolves `req.teamId` — the one thing every resource route actually needs. - * Must run after `requireAuth`/`mcpAuth`. + * Must run after `requireAuth`. * * - API-key requests already have `req.teamId` set by `auth/middleware.ts` * (a key authenticates as exactly one, fixed team) — this is a no-op for them. - * - User-authenticated requests pick a team via the `X-Sendlit-Team-Id` - * header, validated against team membership on every call — this is what - * lets the web dashboard switch teams instantly, without re-authenticating. - * If the header is absent and the account belongs to exactly one team, that - * team is used automatically (the common case). Otherwise the caller must - * specify one explicitly. + * - User-authenticated requests resolve it with `resolveUserTeam`. */ export async function requireTeam( req: Request, @@ -33,54 +106,14 @@ export async function requireTeam( return; } - const headerTeamId = getHeaderValue(req.headers["x-sendlit-team-id"]); - if (headerTeamId) { - const team = await getTeamByTeamId(headerTeamId); - if (!team) { - res.status(400).json({ - error: "invalid_team_id", - error_description: "The provided team ID is not valid.", - }); - return; - } - let membership: Awaited>; - try { - membership = await getTeamMembership(team.id, anyReq.userId); - } catch { - res.status(400).json({ - error: "invalid_team_id", - error_description: "The provided team ID is not valid.", - }); - return; - } - if (!membership) { - res.status(403).json({ - error: "not_a_team_member", - error_description: "You are not a member of this team.", - }); - return; - } - anyReq.teamId = team.id; - return next(); - } - - const teams = await listTeamsForUser(anyReq.userId); - if (teams.length === 1) { - anyReq.teamId = teams[0].id; - return next(); - } - if (teams.length === 0) { - res.status(409).json({ - error: "no_team", - error_description: "This user doesn't belong to any team yet.", - }); + const resolved = await resolveUserTeam( + anyReq.userId, + req.headers["x-sendlit-team-id"], + ); + if (!resolved.ok) { + res.status(resolved.status).json(resolved.body); return; } - - res.status(409).json({ - error: "team_required", - error_description: - "This user belongs to multiple teams — specify one via the X-Sendlit-Team-Id header.", - teams: teams.map((t) => ({ teamId: t.teamId, name: t.name })), - }); + anyReq.teamId = resolved.teamId; + next(); } diff --git a/apps/api/src/contacts/tags.test.ts b/apps/api/src/contacts/tags.test.ts index 79af9d6c..74df3007 100644 --- a/apps/api/src/contacts/tags.test.ts +++ b/apps/api/src/contacts/tags.test.ts @@ -34,7 +34,6 @@ const schema = await import("../db/schema"); const { seedTeamAndContact, truncateAll } = await import("../test/db"); const { requestApp } = await import("../test/http"); const { default: tagRoutes } = await import("./tags-routes"); -const { registerTagTools } = await import("../mcp/tools/tags"); type TestDb = import("../test/db").TestDb; const tdb = db as unknown as TestDb; @@ -339,50 +338,3 @@ describe("tag routes", () => { expect(response.json().contactsUpdated).toBe(1); }); }); - -describe("MCP tag tools", () => { - function tools() { - const registered = new Map(); - registerTagTools({ - registerTool: (name: string, config: any, handler: any) => { - registered.set(name, { config, handler }); - }, - } as any); - return registered; - } - const authFor = (teamId: string) => ({ - http: { authInfo: { extra: { authKind: "team_key", teamId, user: null } } }, - }); - - it("requires auth", async () => { - const result = await tools().get("list_tags")!.handler({}); - expect(result.isError).toBe(true); - }); - - it("lists, inspects, renames and deletes tags with valid output", async () => { - const { team } = await seedReferences(); - const registered = tools(); - const auth = authFor(team.id); - const call = async (name: string, args?: unknown) => { - const tool = registered.get(name)!; - const result = - args === undefined ? await tool.handler(auth) : await tool.handler(args, auth); - expect(result.isError).toBeUndefined(); - expect(tool.config.outputSchema.safeParse(result.structuredContent).success).toBe( - true, - ); - return result.structuredContent; - }; - - expect((await call("list_tags")).items).toHaveLength(2); - expect((await call("get_tag_references", { tag: "vip" })).segments).toHaveLength(1); - expect((await call("rename_tag", { tag: "vip", name: "gold" })).tag).toBe("gold"); - expect((await call("delete_tag", { tag: "gold" })).contactsUpdated).toBe(2); - - const missing = await registered - .get("delete_tag")! - .handler({ tag: "nope" }, authFor(team.id)); - expect(missing.isError).toBe(true); - expect(registered.get("delete_tag")!.config.annotations.destructiveHint).toBe(true); - }); -}); diff --git a/apps/api/src/mcp/api-tools.test.ts b/apps/api/src/mcp/api-tools.test.ts new file mode 100644 index 00000000..7d81b322 --- /dev/null +++ b/apps/api/src/mcp/api-tools.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it, mock } from "bun:test"; +import express from "express"; + +mock.module("../db/client", () => ({ db: {}, pool: {} })); + +const { apiTools, MCP_API_OPERATIONS } = await import("./api-tools"); +const { MCP_SCOPES } = await import("./policy"); + +type Seen = { + method: string; + url: string; + headers: Record; + ip?: string; + body: unknown; +}; + +/** A stand-in for the SendLit app, serving a few real contract paths. */ +function fakeApp(seen: Seen[] = []) { + const app = express(); + app.set("trust proxy", 1); + app.use(express.json()); + app.use((req, _res, next) => { + seen.push({ + method: req.method, + url: req.originalUrl, + headers: req.headers, + ip: req.ip, + body: req.body, + }); + next(); + }); + app.get("/settings/esps", (_req, res) => res.json([{ espId: "esp_1" }])); + app.get("/settings/esps/:espId", (req, res) => + req.params.espId === "esp_missing" + ? res.status(404).json({ error: "not_found", error_description: "ESP not found" }) + : res.json({ espId: req.params.espId, createdAt: "2026-01-01T00:00:00.000Z" }), + ); + app.delete("/settings/esps/:espId", (_req, res) => res.status(204).end()); + app.post("/settings/esps/:espId/test", (_req, res) => + res.status(402).json({ error: "plan_limit_reached", limit: 3, nested: { x: 1 } }), + ); + app.post("/settings/esps", (_req, res) => + res.status(400).json({ + name: "ZodError", + issues: [ + { path: ["body", "name"], message: "Required" }, + { path: [], message: "Unrecognized key" }, + ], + }), + ); + app.get("/settings/general", (_req, res) => res.status(500).json({ error: "boom" })); + app.get("/contacts/export", (_req, res) => + res.type("text/csv").send("email\na@b.co\n"), + ); + return app; +} + +const teamKey = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [] as string[], + requestId: "req-1", + clientIp: "203.0.113.9", + replayHeaders: { "x-sendlit-apikey": "tak_secret" }, +}; + +const oauth = (scopes: string[]) => ({ + ...teamKey, + authKind: "oauth" as const, + scopes, + replayHeaders: { authorization: "Bearer tok" }, +}); + +function tools(seen: Seen[] = []) { + const app = fakeApp(seen); + return new Map(apiTools({ app: () => app }).map((tool) => [tool.name, tool])); +} + +async function call( + name: string, + args: Record, + context: any = teamKey, + seen: Seen[] = [], +) { + return (await tools(seen).get(name)!.handler({ + context, + args, + signal: new AbortController().signal, + })) as any; +} + +describe("generic api.* MCP tools", () => { + it("routes only manifest operations, each to the tool for its risk", () => { + expect(MCP_API_OPERATIONS.get("settings.esps.list")).toMatchObject({ + method: "GET", + path: "/settings/esps", + tool: "api.read", + }); + expect(MCP_API_OPERATIONS.get("settings.esps.remove")?.tool).toBe("api.delete"); + expect(MCP_API_OPERATIONS.get("settings.esps.test")?.tool).toBe("api.write"); + // Dedicated tools and REST-only operations are not generic operations. + expect(MCP_API_OPERATIONS.has("contacts.list")).toBe(false); + expect(MCP_API_OPERATIONS.has("billing.portal")).toBe(false); + expect(MCP_API_OPERATIONS.has("organizations.list")).toBe(false); + for (const operation of MCP_API_OPERATIONS.values()) { + expect(operation.tool).toBe( + { read: "api.read", write: "api.write", destructive: "api.delete" }[ + operation.risk + ], + ); + } + }); + + it("lists operations with their scope and whether the connection may run them", async () => { + const { items } = await call( + "api.list_operations", + { search: "esps" }, + oauth([MCP_SCOPES.espRead]), + ); + const list = items.find((item: any) => item.operationId === "settings.esps.list"); + const remove = items.find( + (item: any) => item.operationId === "settings.esps.remove", + ); + expect(list).toMatchObject({ + tool: "api.read", + requiredScope: "esp:read", + available: true, + }); + expect(remove).toMatchObject({ + tool: "api.delete", + requiredScope: "esp:write", + available: false, + }); + expect(items.every((item: any) => item.path.includes("esps"))).toBe(true); + }); + + it("describes an operation's parameters and response from the OpenAPI document", async () => { + const described = await call("api.describe_operation", { + operationId: "settings.esps.get", + }); + expect(described).toMatchObject({ method: "GET", path: "/settings/esps/:espId" }); + expect(described.pathParams).toEqual([ + expect.objectContaining({ name: "espId", in: "path" }), + ]); + expect(described.response?.status).toBe(200); + expect(await call("api.describe_operation", { operationId: "nope" })).toMatchObject( + { + error: { + code: "validation_failed", + safeDetails: { reason: "unknown_operation" }, + }, + }, + ); + }); + + it("runs the REST route with the caller's credential, request id and IP", async () => { + const seen: Seen[] = []; + const result = await call( + "api.read", + { operationId: "settings.esps.get", pathParams: { espId: "esp 1" } }, + teamKey, + seen, + ); + expect(result).toEqual({ espId: "esp 1", createdAt: "2026-01-01T00:00:00.000Z" }); + expect(seen[0]).toMatchObject({ + method: "GET", + url: "/settings/esps/esp%201", + ip: "203.0.113.9", + }); + expect(seen[0]?.headers).toMatchObject({ + "x-sendlit-apikey": "tak_secret", + "x-request-id": "req-1", + }); + }); + + it("wraps arrays, empty responses and non-JSON bodies", async () => { + expect(await call("api.read", { operationId: "settings.esps.list" })).toEqual({ + items: [{ espId: "esp_1" }], + }); + expect( + await call("api.delete", { + operationId: "settings.esps.remove", + pathParams: { espId: "esp_1" }, + confirm: true, + }), + ).toEqual({ ok: true }); + expect(await call("api.read", { operationId: "contacts.export" })).toEqual({ + contentType: expect.stringContaining("text/csv"), + body: "email\na@b.co\n", + }); + }); + + it("maps REST errors to platform codes, keeping the REST reason and primitive details", async () => { + expect( + await call("api.read", { + operationId: "settings.esps.get", + pathParams: { espId: "esp_missing" }, + }), + ).toMatchObject({ + error: { + code: "not_found", + safeDetails: { reason: "not_found", message: "ESP not found" }, + }, + }); + const limited = await call("api.write", { + operationId: "settings.esps.test", + pathParams: { espId: "esp_1" }, + }); + expect(limited.error.code).toBe("forbidden"); + expect(limited.error.safeDetails).toEqual({ + reason: "plan_limit_reached", + limit: 3, + }); + expect( + await call("api.write", { operationId: "settings.esps.create", body: {} }), + ).toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { + reason: "invalid_request", + message: "body.name: Required; Unrecognized key", + }, + }, + }); + // Server errors never leak their body. + const failed = await call("api.read", { operationId: "settings.general.get" }); + expect(failed.error).toEqual({ + code: "internal_error", + message: expect.any(String), + }); + }); + + it("refuses the wrong tool, missing or path-escaping parameters and unknown operations", async () => { + expect( + await call("api.read", { + operationId: "settings.esps.remove", + pathParams: { espId: "e" }, + }), + ).toMatchObject({ + error: { safeDetails: { reason: "wrong_tool", useTool: "api.delete" } }, + }); + expect(await call("api.read", { operationId: "settings.esps.get" })).toMatchObject({ + error: { safeDetails: { reason: "missing_path_param", param: "espId" } }, + }); + for (const espId of [".", ".."]) { + expect( + await call("api.read", { + operationId: "settings.esps.get", + pathParams: { espId }, + }), + ).toMatchObject({ error: { safeDetails: { reason: "invalid_path_param" } } }); + } + expect(await call("api.read", { operationId: "contacts.list" })).toMatchObject({ + error: { safeDetails: { reason: "unknown_operation" } }, + }); + expect(await call("api.write", { operationId: "billing.portal" })).toMatchObject({ + error: { safeDetails: { reason: "unknown_operation" } }, + }); + }); + + it("enforces the operation's OAuth scope and keeps API keys away from key management", async () => { + const seen: Seen[] = []; + expect( + await call( + "api.read", + { operationId: "settings.esps.list" }, + oauth([MCP_SCOPES.contactsRead]), + seen, + ), + ).toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "esp:read" }, + }, + }); + expect( + await call( + "api.read", + { operationId: "teams.listKeys", pathParams: { teamId: "team_1" } }, + teamKey, + seen, + ), + ).toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "user_auth_required" } }, + }); + expect(seen).toHaveLength(0); + + const allowed: Seen[] = []; + await call( + "api.read", + { operationId: "settings.esps.list" }, + oauth([MCP_SCOPES.espRead]), + allowed, + ); + expect(allowed[0]?.headers).toMatchObject({ authorization: "Bearer tok" }); + }); +}); diff --git a/apps/api/src/mcp/api-tools.ts b/apps/api/src/mcp/api-tools.ts new file mode 100644 index 00000000..81e9315c --- /dev/null +++ b/apps/api/src/mcp/api-tools.ts @@ -0,0 +1,447 @@ +import { createServer, type RequestListener } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createPlatformError, type PlatformErrorCode } from "@codelitdev/platform"; +import { contract, MCP_API_TOOLS, mcpParityManifest } from "@sendlit/api-contract"; +import { openApiDocument } from "@sendlit/api-contract/openapi"; +import { z } from "zod"; +import logger from "../services/log"; +import type { SendLitMcpContext, SendLitMcpTool } from "./context"; +import { authorizeMcpOperation, getRequiredScope } from "./policy"; +import { toolError } from "./tools/responses"; + +type ApiRisk = keyof typeof MCP_API_TOOLS; + +type ApiOperation = { + operationId: string; + method: string; + path: string; + risk: ApiRisk; + tool: string; +}; + +type ContractRoute = { method: string; path: string }; + +type OpenApiOperation = { + summary?: string; + tags?: string[]; + parameters?: Array<{ + name: string; + in: string; + required?: boolean; + schema?: unknown; + }>; + requestBody?: { content?: Record }; + responses?: Record }>; +}; + +/** Every ts-rest route, keyed by its dotted router-key operation id. */ +function contractRoutes( + router: object, + prefix: string[] = [], +): Map { + const routes = new Map(); + for (const [key, value] of Object.entries(router)) { + if (!value || typeof value !== "object") continue; + const route = value as Partial; + if (typeof route.method === "string" && typeof route.path === "string") { + routes.set([...prefix, key].join("."), { + method: route.method.toUpperCase(), + path: route.path, + }); + } else { + for (const entry of contractRoutes(value, [...prefix, key])) routes.set(...entry); + } + } + return routes; +} + +const ROUTES = contractRoutes(contract); + +const API_TOOL_RISK = new Map( + (Object.entries(MCP_API_TOOLS) as Array<[ApiRisk, string]>).map(([risk, tool]) => [ + tool, + risk, + ]), +); + +/** + * REST operations the parity manifest routes to a generic `api.*` tool. + * Operations with a dedicated tool, and REST-only operations (billing, + * organization administration, account deletion), are not here. + */ +export const MCP_API_OPERATIONS: ReadonlyMap = new Map( + mcpParityManifest.flatMap((entry) => { + if (entry.parity !== "required") return []; + const risk = API_TOOL_RISK.get(entry.mcp.tool); + const route = ROUTES.get(entry.rest.operationId); + if (!risk || !route) return []; + return [ + [ + entry.rest.operationId, + { operationId: entry.rest.operationId, ...route, risk, tool: entry.mcp.tool }, + ] as const, + ]; + }), +); + +function openApiOperation(operation: ApiOperation): OpenApiOperation | undefined { + const paths = ( + openApiDocument as { paths: Record> } + ).paths; + const openApiPath = operation.path.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); + return paths[openApiPath]?.[operation.method.toLowerCase()]; +} + +function pathPattern(path: string): RegExp { + const escaped = path + .split("/") + .map((segment) => + segment.startsWith(":") + ? "[^/]+" + : segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ) + .join("/"); + return new RegExp(`^${escaped}$`); +} + +/** Whether a concrete path reaches exactly this operation, so a parameter + * value (`export` is its own route, not a contact id) can't re-route the call + * to an operation the caller was not authorized for. A path without + * parameters is the operation's own literal route. */ +function reachesOnly(operation: ApiOperation, concretePath: string): boolean { + if (!operation.path.includes(":")) return concretePath === operation.path; + for (const [operationId, route] of ROUTES) { + if (operationId === operation.operationId || route.method !== operation.method) + continue; + if (pathPattern(route.path).test(concretePath)) return false; + } + return pathPattern(operation.path).test(concretePath); +} + +function unknownOperation(operationId: string) { + return toolError("validation_failed", { reason: "unknown_operation", operationId }); +} + +const STATUS_CODES: Record = { + 400: "validation_failed", + 401: "unauthenticated", + 402: "forbidden", + 403: "forbidden", + 404: "not_found", + 409: "conflict", + 413: "validation_failed", + 415: "validation_failed", + 422: "validation_failed", + 429: "rate_limited", +}; + +/** A REST error body (`{ error, error_description, ... }`) as a tool error: + * the REST code becomes `reason`, its description `message`, and other + * primitive fields (limits, upgrade URLs) are kept. */ +function restError(status: number, body: unknown) { + const code = + STATUS_CODES[status] ?? (status >= 500 ? "internal_error" : "validation_failed"); + const details: Record = {}; + if (body && typeof body === "object" && !Array.isArray(body)) { + for (const [key, value] of Object.entries(body)) { + if (value !== null && !["string", "number", "boolean"].includes(typeof value)) + continue; + if (key === "error") details.reason = value as string; + else if (key === "error_description") details.message = value as string; + else details[key] = value as string | number | boolean | null; + } + // ts-rest's request validation answers `{ name: "ZodError", issues }`; + // the issues are what tell the model which fields to fix. + const issues = (body as { issues?: unknown }).issues; + if (Array.isArray(issues) && issues.length > 0) { + delete details.name; + details.reason ??= "invalid_request"; + details.message ??= issues + .slice(0, 10) + .map((issue: { path?: unknown[]; message?: unknown }) => { + const path = Array.isArray(issue.path) ? issue.path.join(".") : ""; + return path ? `${path}: ${String(issue.message)}` : String(issue.message); + }) + .join("; "); + } + } + if (code === "internal_error") return { error: createPlatformError(code) }; + return toolError(code, Object.keys(details).length ? details : undefined); +} + +const queryValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), +]); + +const callInputShape = { + operationId: z.string().min(1).describe("An operationId from api.list_operations."), + pathParams: z + .record(z.string()) + .optional() + .describe("Values for the operation's path parameters, e.g. { espId: 'esp_…' }."), + query: z + .record(queryValueSchema) + .optional() + .describe("Query-string parameters, as listed by api.describe_operation."), +}; + +const bodySchema = z.object({}).passthrough(); + +type CallInput = { + operationId: string; + pathParams?: Record; + query?: Record>; + body?: unknown; +}; + +export type ApiToolDeps = { + /** The SendLit HTTP app that serves each generic call. */ + app: () => RequestListener; +}; + +const loopbackOrigins = new WeakMap>(); + +/** + * A loopback-only listener for the same app, so a generic call is a real + * REST request through every middleware and route, without leaving the host + * or going through the public load balancer. Started on first use; it does + * not keep the process alive. + */ +function loopbackOrigin(app: RequestListener): Promise { + let origin = loopbackOrigins.get(app); + if (!origin) { + origin = new Promise((resolve, reject) => { + const server = createServer(app); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.unref(); + resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`); + }); + }); + origin.catch(() => loopbackOrigins.delete(app)); + loopbackOrigins.set(app, origin); + } + return origin; +} + +async function callOperation( + deps: ApiToolDeps, + context: SendLitMcpContext, + tool: string, + input: CallInput, + signal: AbortSignal, +): Promise { + const operation = MCP_API_OPERATIONS.get(input.operationId); + if (!operation) return unknownOperation(input.operationId); + if (operation.tool !== tool) { + return toolError("validation_failed", { + reason: "wrong_tool", + useTool: operation.tool, + }); + } + const denied = authorizeMcpOperation(operation.operationId, context); + if (denied) return { error: denied }; + + let path = operation.path; + for (const name of operation.path.match(/:([A-Za-z0-9_]+)/g) ?? []) { + const value = input.pathParams?.[name.slice(1)]; + if (!value) { + return toolError("validation_failed", { + reason: "missing_path_param", + param: name.slice(1), + }); + } + // `.` and `..` survive encoding and would be resolved away as path + // segments, reaching another route. + if (value === "." || value === "..") { + return toolError("validation_failed", { reason: "invalid_path_param" }); + } + path = path.replace(name, encodeURIComponent(value)); + } + if (!reachesOnly(operation, path)) { + return toolError("validation_failed", { reason: "invalid_path_param" }); + } + + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(input.query ?? {})) { + for (const item of Array.isArray(value) ? value : [value]) { + search.append(key, String(item)); + } + } + const query = search.toString(); + const startedAt = performance.now(); + const origin = await loopbackOrigin(deps.app()); + const response = await fetch(`${origin}${path}${query ? `?${query}` : ""}`, { + method: operation.method, + headers: { + ...context.replayHeaders, + "content-type": "application/json", + ...(context.requestId ? { "x-request-id": context.requestId } : {}), + // With `trust proxy` on, REST sees (and rate limits) the MCP caller's + // IP, not this loopback hop. + ...(context.clientIp ? { "x-forwarded-for": context.clientIp } : {}), + }, + body: operation.method === "GET" ? undefined : JSON.stringify(input.body ?? {}), + redirect: "manual", + signal, + }); + const text = await response.text(); + logger.info( + { + tool, + operationId: operation.operationId, + requiredScope: getRequiredScope(operation.operationId), + authKind: context.authKind, + statusCode: response.status, + durationMs: Math.round(performance.now() - startedAt), + }, + "MCP api operation completed", + ); + + const contentType = response.headers.get("content-type") ?? ""; + const isJson = contentType.includes("application/json"); + let body: unknown; + if (isJson && text) { + try { + body = JSON.parse(text); + } catch { + body = undefined; + } + } + if (response.status >= 400) return restError(response.status, body); + if (response.status === 204 || !text) return { ok: true }; + if (!isJson) return { contentType, body: text }; + return Array.isArray(body) ? { items: body } : body; +} + +const CALL_GUIDE = + "Get the operationId from api.list_operations and its parameters and body from api.describe_operation. The call runs exactly as the REST API does, with the same validation, plan limits and errors, for the connected team."; + +function callTool( + deps: ApiToolDeps, + risk: ApiRisk, + description: string, + inputSchema: z.ZodObject, +): SendLitMcpTool { + const tool = MCP_API_TOOLS[risk]; + return { + name: tool, + description, + risk, + inputSchema, + handler: ({ context, args, signal }) => + callOperation(deps, context, tool, args as CallInput, signal), + }; +} + +function describe(operation: ApiOperation, context: SendLitMcpContext) { + const spec = openApiOperation(operation); + return { + operationId: operation.operationId, + tool: operation.tool, + method: operation.method, + path: operation.path, + summary: spec?.summary ?? null, + group: spec?.tags?.[0] ?? null, + requiredScope: getRequiredScope(operation.operationId), + available: authorizeMcpOperation(operation.operationId, context) === null, + }; +} + +/** + * Generic access to every team REST operation the parity manifest routes to + * MCP, beyond the dedicated tools. Calls run through SendLit's own REST + * routes, so validation, plan limits and errors are the REST API's. + */ +export function apiTools(deps: ApiToolDeps): SendLitMcpTool[] { + return [ + { + name: "api.list_operations", + description: + "Lists the SendLit API operations this connection can run beyond the dedicated tools: sending providers (ESPs) and their delivery feedback, suppressions, sender reputation, the tracking domain, general and web settings, signup forms, media, tags, segments, templates, sequence and contact management (including deletes), event definitions, teams and API keys. Each item gives the tool that runs it (api.read, api.write or api.delete), the OAuth scope it needs and whether this connection may run it. Filter with search (matches the operation id, path, summary or group) or group.", + risk: "read", + inputSchema: z.object({ + search: z.string().max(100).optional(), + group: z.string().max(100).optional(), + }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const { search, group } = args as { search?: string; group?: string }; + const needle = search?.trim().toLowerCase(); + const items = [...MCP_API_OPERATIONS.values()] + .map((operation) => describe(operation, context)) + .filter( + (item) => + !group || item.group?.toLowerCase() === group.trim().toLowerCase(), + ) + .filter( + (item) => + !needle || + [item.operationId, item.path, item.summary ?? "", item.group ?? ""].some( + (text) => text.toLowerCase().includes(needle), + ), + ); + return { items }; + }, + }, + { + name: "api.describe_operation", + description: + "Describes one operation from api.list_operations: its path and query parameters, the JSON Schema of its request body, and the schema of a successful response.", + risk: "read", + inputSchema: z.object({ operationId: z.string().min(1) }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const { operationId } = args as { operationId: string }; + const operation = MCP_API_OPERATIONS.get(operationId); + if (!operation) return unknownOperation(operationId); + const spec = openApiOperation(operation); + const success = Object.entries(spec?.responses ?? {}).find(([status]) => + status.startsWith("2"), + ); + return { + ...describe(operation, context), + pathParams: (spec?.parameters ?? []).filter((param) => param.in === "path"), + query: (spec?.parameters ?? []).filter((param) => param.in === "query"), + body: spec?.requestBody?.content?.["application/json"]?.schema ?? null, + response: success + ? { + status: Number(success[0]), + schema: success[1].content?.["application/json"]?.schema ?? null, + } + : null, + }; + }, + }, + callTool( + deps, + "read", + `Runs a read-only SendLit API operation (GET). ${CALL_GUIDE}`, + z.object(callInputShape), + ), + callTool( + deps, + "write", + `Runs a SendLit API operation that creates or changes data, or has an external effect such as testing an ESP. Pass the JSON request body in body. ${CALL_GUIDE} Repeating a create usually makes a duplicate or fails with conflict, so read before retrying.`, + z.object({ + ...callInputShape, + body: bodySchema.optional().describe("The JSON request body."), + }), + ), + callTool( + deps, + "destructive", + `Runs a SendLit API operation that deletes or permanently disables something (deleting contacts, templates, sequences, ESPs or API keys, retiring an ESP, disconnecting reputation sources). It can't be undone: confirm the target with the user first and pass confirm: true. ${CALL_GUIDE}`, + z.object({ + ...callInputShape, + body: bodySchema + .optional() + .describe("The JSON request body, if the operation takes one."), + confirm: z.boolean().optional(), + }), + ), + ]; +} diff --git a/apps/api/src/mcp/auth-context.test.ts b/apps/api/src/mcp/auth-context.test.ts deleted file mode 100644 index 76b57dc6..00000000 --- a/apps/api/src/mcp/auth-context.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { createSendLitMcpAuthInfo } from "./auth-context"; - -describe("SendLit MCP auth context", () => { - it("keeps tenant identity separate from OAuth client identity", () => { - const auth = createSendLitMcpAuthInfo({ - authKind: "oauth", - oauthToken: "oauth-token", - clientId: "oauth-client", - scopes: ["contacts:read"], - teamId: "team-1", - user: { id: "user-1" }, - }); - - expect(auth).toMatchObject({ - token: "oauth-token", - clientId: "oauth-client", - scopes: ["contacts:read"], - extra: { - authKind: "oauth", - teamId: "team-1", - }, - }); - expect(auth?.clientId).not.toBe(auth?.extra.teamId); - }); - - it("never places an API-key secret in AuthInfo", () => { - const auth = createSendLitMcpAuthInfo({ - authKind: "team_key", - apiKeyId: "tak_public", - apikey: "sl_live_secret", - teamId: "team-1", - }); - - expect(auth).toMatchObject({ - token: "tak_public", - clientId: "team-key:tak_public", - extra: { - authKind: "team_key", - teamId: "team-1", - }, - }); - expect(JSON.stringify(auth)).not.toContain("sl_live_secret"); - }); - - it("does not treat organization API keys as MCP credentials", () => { - expect( - createSendLitMcpAuthInfo({ - authKind: "organization_key", - organizationId: "org-internal", - organizationApiKeyId: "oak_public", - apiKeyId: "oak_public", - teamId: "team-1", - }), - ).toBeNull(); - }); -}); diff --git a/apps/api/src/mcp/auth-context.ts b/apps/api/src/mcp/auth-context.ts deleted file mode 100644 index 88fc9031..00000000 --- a/apps/api/src/mcp/auth-context.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { AuthInfo, ServerContext } from "@modelcontextprotocol/server"; -import type { User } from "../user/queries"; - -export type SendLitMcpAuthExtra = { - authKind: "oauth" | "team_key"; - teamId: string; - user: User | null; -}; - -export type SendLitMcpAuthInfo = AuthInfo & { - extra: SendLitMcpAuthExtra; -}; - -type McpAuthRequest = { - authKind?: unknown; - teamId?: unknown; - oauthToken?: unknown; - clientId?: unknown; - scopes?: unknown; - apiKeyId?: unknown; - user?: User | null; -}; - -export function createSendLitMcpAuthInfo(input: unknown): SendLitMcpAuthInfo | null { - if (!input || typeof input !== "object") return null; - const req = input as McpAuthRequest; - const teamId = typeof req.teamId === "string" ? req.teamId : null; - if (!teamId) return null; - - if (req.authKind === "oauth") { - if (typeof req.oauthToken !== "string" || typeof req.clientId !== "string") { - return null; - } - return { - token: req.oauthToken, - clientId: req.clientId, - scopes: Array.isArray(req.scopes) ? req.scopes : [], - extra: { - authKind: "oauth", - teamId, - user: req.user ?? null, - }, - }; - } - - if (req.authKind === "team_key" && typeof req.apiKeyId === "string") { - return { - // The SDK requires a token field for AuthInfo pass-through. Use the - // stable public key id, never the API-key secret. - token: req.apiKeyId, - clientId: `team-key:${req.apiKeyId}`, - scopes: [], - extra: { - authKind: "team_key", - teamId, - user: null, - }, - }; - } - - return null; -} - -export function getSendLitMcpAuthInfo(ctx: ServerContext): SendLitMcpAuthInfo | null { - const authInfo = ctx.http?.authInfo; - const extra = authInfo?.extra as SendLitMcpAuthExtra | undefined; - if ( - !authInfo || - !extra || - (extra.authKind !== "oauth" && extra.authKind !== "team_key") || - typeof extra.teamId !== "string" - ) { - return null; - } - return authInfo as SendLitMcpAuthInfo; -} diff --git a/apps/api/src/mcp/context.ts b/apps/api/src/mcp/context.ts new file mode 100644 index 00000000..011e9afb --- /dev/null +++ b/apps/api/src/mcp/context.ts @@ -0,0 +1,168 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { McpToolDefinition } from "@codelitdev/mcp-server-kit"; +import { + type AuthenticationResult, + createPlatformError, + type HeaderMap, + type PlatformError, +} from "@codelitdev/platform"; +import { CLIENT_IP_HEADER } from "../abuse/signup"; +import { resolveUserTeam } from "../auth/require-team"; +import { type AuthResult, resolveAuth } from "../auth/resolve-auth"; +import type { User } from "../user/queries"; + +/** Headers a generic `api.*` tool replays on the REST request it makes for + * the caller, so REST authenticates and picks the team exactly as it would + * for the caller's own request. */ +const REPLAYED_HEADERS = ["authorization", "x-sendlit-apikey", "x-sendlit-team-id"]; + +/** What every SendLit MCP tool runs with. */ +export type SendLitMcpContext = { + authKind: "oauth" | "team_key"; + /** Internal team id, resolved like REST's `requireTeam`. */ + teamId: string; + /** The signed-in person for OAuth; `null` for a team API key. */ + user: User | null; + /** OAuth scopes granted to the token; empty for a team API key. */ + scopes: readonly string[]; + requestId: string; + clientIp: string; + /** The caller's credential and team headers. Never logged. */ + replayHeaders: Record; +}; + +export type SendLitMcpTool = McpToolDefinition; + +/** The kit hands `resolveContext` a principal id and credential only, so the + * full auth result is kept for the request's duration here. */ +const authByRequest = new AsyncLocalStorage<{ auth?: AuthResult }>(); + +export function runMcpRequest(handle: () => Promise): Promise { + return authByRequest.run({}, handle); +} + +function headerValue(headers: HeaderMap, name: string): string | undefined { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +/** MCP accepts an OAuth bearer token or a team API key (sent as + * `x-sendlit-apikey`, or `X-API-Key`, which the route aliases). */ +export async function authenticateMcpRequest( + headers: HeaderMap, +): Promise> { + // `resolveAuth` reads `Authorization` and `X-API-Key` from the headers + // itself; passing the key again would count it twice (ambiguous). + const auth = await resolveAuth({ + headers: headers as Record, + transport: "mcp", + }); + const store = authByRequest.getStore(); + if (store) store.auth = auth; + + switch (auth.status) { + case "authenticated": + if (auth.kind === "oauth") { + return { + kind: "authenticated", + principalId: auth.userId, + credential: { + kind: "oauth", + credentialId: auth.clientId, + scopes: auth.scopes, + }, + }; + } + if (auth.kind === "team_key") { + return { + kind: "authenticated", + principalId: `team-key:${auth.apiKeyId}`, + credential: { kind: "api_key", credentialId: auth.apiKeyId }, + }; + } + // MCP is team-scoped: organization keys and dashboard sessions are + // not MCP credentials. + return { + kind: "rejected", + error: createPlatformError("unauthenticated", { + safeDetails: { reason: "credential_not_allowed" }, + }), + }; + case "missing": + return { kind: "absent" }; + case "ambiguous": + return { kind: "rejected", error: createPlatformError("credential_ambiguous") }; + case "unavailable": + return { kind: "rejected", error: createPlatformError("internal_error") }; + default: + return { kind: "rejected", error: createPlatformError("unauthenticated") }; + } +} + +const TEAM_ERRORS: Record = { + invalid_team_id: createPlatformError("validation_failed", { + safeDetails: { reason: "invalid_team_id" }, + }), + not_a_team_member: createPlatformError("tenant_forbidden"), + no_team: createPlatformError("tenant_required", { + safeDetails: { reason: "no_team" }, + }), + team_required: createPlatformError("tenant_required", { + safeDetails: { + reason: "team_required", + message: "Pick a team on the OAuth consent screen or send X-Sendlit-Team-Id.", + }, + }), +}; + +export async function resolveMcpContext({ + headers, +}: { + headers: HeaderMap; +}): Promise< + { ok: true; context: SendLitMcpContext } | { ok: false; error: PlatformError } +> { + const auth = authByRequest.getStore()?.auth; + if (auth?.status !== "authenticated") { + return { ok: false, error: createPlatformError("unauthenticated") }; + } + + let teamId: string; + if (auth.kind === "team_key") { + teamId = auth.teamId; + } else if (auth.kind === "oauth") { + if (auth.teamId) { + teamId = auth.teamId; + } else { + const resolved = await resolveUserTeam(auth.userId, headers["x-sendlit-team-id"]); + if (!resolved.ok) return { ok: false, error: TEAM_ERRORS[resolved.error] }; + teamId = resolved.teamId; + } + } else { + return { ok: false, error: createPlatformError("unauthenticated") }; + } + + const replayHeaders: Record = {}; + for (const name of REPLAYED_HEADERS) { + const value = headerValue(headers, name); + if (value) replayHeaders[name] = value; + } + // The route aliases `x-sendlit-apikey` to `X-API-Key` for the kit; REST + // reads the SendLit header. + const apiKey = headerValue(headers, "x-api-key"); + if (apiKey && !replayHeaders.authorization) + replayHeaders["x-sendlit-apikey"] = apiKey; + + return { + ok: true, + context: { + authKind: auth.kind, + teamId, + user: auth.user, + scopes: auth.kind === "oauth" ? auth.scopes : [], + requestId: headerValue(headers, "x-request-id") ?? "", + clientIp: headerValue(headers, CLIENT_IP_HEADER) ?? "", + replayHeaders, + }, + }; +} diff --git a/apps/api/src/mcp/parity.test.ts b/apps/api/src/mcp/parity.test.ts index 97d56943..ef1ab45a 100644 --- a/apps/api/src/mcp/parity.test.ts +++ b/apps/api/src/mcp/parity.test.ts @@ -1,21 +1,11 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import { validateParityManifest } from "@codelitdev/mcp-server-kit"; -import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; -import { type AuthInfo, createMcpHandler } from "@modelcontextprotocol/server"; import { contract, mcpParityManifest } from "@sendlit/api-contract"; +import express from "express"; mock.module("../db/client", () => ({ db: {}, pool: {} })); -const { buildMcpServer } = await import("./server"); - -type Risk = "read" | "write" | "destructive"; - -const teamKeyAuth: AuthInfo = { - token: "tak_test", - clientId: "team-key:tak_test", - scopes: [], - extra: { authKind: "team_key", teamId: "team-1", user: null }, -}; +const { createSendLitMcp } = await import("./server"); /** Dotted router-key paths for every ts-rest route, e.g. `settings.esps.list`. */ function collectRestOperationIds(router: object, prefix: string[] = []): string[] { @@ -30,45 +20,12 @@ function collectRestOperationIds(router: object, prefix: string[] = []): string[ }); } -function riskFromAnnotations(annotations?: { - readOnlyHint?: boolean; - destructiveHint?: boolean; -}): Risk { - if (annotations?.readOnlyHint) return "read"; - if (annotations?.destructiveHint) return "destructive"; - return "write"; -} - -async function listRegisteredTools() { - const handler = createMcpHandler(() => buildMcpServer(), { legacy: "stateless" }); - const client = new Client( - { name: "sendlit-parity-test", version: "1.0.0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, - ); - closeables.push(client, handler); - await client.connect( - new StreamableHTTPClientTransport(new URL("http://sendlit.test/mcp"), { - fetch: (input, init) => - handler.fetch(new Request(input, init), { authInfo: teamKeyAuth }), - }), - ); - return (await client.listTools()).tools; -} - -const closeables: Array<{ close(): Promise }> = []; - -afterEach(async () => { - await Promise.allSettled(closeables.splice(0).map((item) => item.close())); -}); - describe("platform conformance: MCP parity manifest", () => { - it("documents every REST operation and MCP tool with matching risk", async () => { + it("documents every REST operation and MCP tool with matching risk", () => { const restOperationIds = new Set(collectRestOperationIds(contract)); - const tools = await listRegisteredTools(); + const tools = createSendLitMcp({ app: () => express() }).listTools(); const mcpToolNames = new Set(tools.map((tool) => tool.name)); - const mcpToolRisks = new Map( - tools.map((tool) => [tool.name, riskFromAnnotations(tool.annotations)] as const), - ); + const mcpToolRisks = new Map(tools.map((tool) => [tool.name, tool.risk] as const)); expect( validateParityManifest(mcpParityManifest, { @@ -90,5 +47,6 @@ describe("platform conformance: MCP parity manifest", () => { mcpParityManifest.flatMap((entry) => (entry.mcp ? [entry.mcp.tool] : [])), ); expect([...mcpToolNames].filter((name) => !documentedTools.has(name))).toEqual([]); + expect([...documentedTools].filter((name) => !mcpToolNames.has(name))).toEqual([]); }); }); diff --git a/apps/api/src/mcp/policy.ts b/apps/api/src/mcp/policy.ts index fc9349bf..5ae7eaa5 100644 --- a/apps/api/src/mcp/policy.ts +++ b/apps/api/src/mcp/policy.ts @@ -1,5 +1,5 @@ -import type { ServerContext } from "@modelcontextprotocol/server"; -import { errorResult } from "./tools/responses"; +import { createPlatformError, type PlatformError } from "@codelitdev/platform"; +import type { SendLitMcpContext } from "./context"; export const MCP_SCOPES = { contactsRead: "contacts:read", @@ -33,186 +33,179 @@ export const MCP_SCOPES_SUPPORTED = Object.freeze( Object.values(MCP_SCOPES), ) as readonly McpScope[]; -const toolPolicies = { - list_contacts: MCP_SCOPES.contactsRead, - get_contact: MCP_SCOPES.contactsRead, - get_contact_deliveries: MCP_SCOPES.contactsRead, - get_contact_events: MCP_SCOPES.contactsRead, - track_event: MCP_SCOPES.contactsWrite, - list_event_definitions: MCP_SCOPES.contactsRead, - get_event_definition: MCP_SCOPES.contactsRead, - create_event_definition: MCP_SCOPES.contactsWrite, - update_event_definition: MCP_SCOPES.contactsWrite, - delete_event_definition: MCP_SCOPES.contactsWrite, - create_contact: MCP_SCOPES.contactsWrite, - update_contact: MCP_SCOPES.contactsWrite, - delete_contact: MCP_SCOPES.contactsWrite, - delete_contacts: MCP_SCOPES.contactsWrite, - preview_delete_contacts: MCP_SCOPES.contactsRead, - get_contact_deletion: MCP_SCOPES.contactsRead, - add_contact_tag: MCP_SCOPES.contactsWrite, - remove_contact_tag: MCP_SCOPES.contactsWrite, - import_contacts: MCP_SCOPES.contactsWrite, - export_contacts: MCP_SCOPES.contactsRead, - - list_segments: MCP_SCOPES.contactsRead, - get_segment: MCP_SCOPES.contactsRead, - create_segment: MCP_SCOPES.contactsWrite, - update_segment: MCP_SCOPES.contactsWrite, - delete_segment: MCP_SCOPES.contactsWrite, - list_tags: MCP_SCOPES.contactsRead, - get_tag_references: MCP_SCOPES.contactsRead, - rename_tag: MCP_SCOPES.contactsWrite, - delete_tag: MCP_SCOPES.contactsWrite, - list_signup_forms: MCP_SCOPES.contactsRead, - get_signup_form: MCP_SCOPES.contactsRead, - create_signup_form: MCP_SCOPES.contactsWrite, - update_signup_form: MCP_SCOPES.contactsWrite, - delete_signup_form: MCP_SCOPES.contactsWrite, - - list_system_templates: MCP_SCOPES.templatesRead, - list_templates: MCP_SCOPES.templatesRead, - get_template: MCP_SCOPES.templatesRead, - create_template: MCP_SCOPES.templatesWrite, - update_template: MCP_SCOPES.templatesWrite, - duplicate_template: MCP_SCOPES.templatesWrite, - delete_template: MCP_SCOPES.templatesWrite, - - list_media: MCP_SCOPES.mediaRead, - get_media: MCP_SCOPES.mediaRead, - list_media_references: MCP_SCOPES.mediaRead, - update_media: MCP_SCOPES.mediaWrite, - delete_media: MCP_SCOPES.mediaWrite, - - list_sequences: MCP_SCOPES.sequencesRead, - get_sequence: MCP_SCOPES.sequencesRead, - get_sequence_stats: MCP_SCOPES.sequencesRead, - get_sequence_schedule: MCP_SCOPES.sequencesRead, - list_sequence_email_waiting_contacts: MCP_SCOPES.sequencesRead, - get_sequence_subscribers: MCP_SCOPES.sequencesRead, - create_sequence: MCP_SCOPES.sequencesWrite, - update_sequence: MCP_SCOPES.sequencesWrite, - add_sequence_email: MCP_SCOPES.sequencesWrite, - update_sequence_email: MCP_SCOPES.sequencesWrite, - send_sequence_email_test: MCP_SCOPES.sequencesWrite, - delete_sequence_email: MCP_SCOPES.sequencesWrite, - delete_sequence: MCP_SCOPES.sequencesWrite, - preview_sequence_deletion: MCP_SCOPES.sequencesRead, - archive_broadcast: MCP_SCOPES.sequencesWrite, - unarchive_broadcast: MCP_SCOPES.sequencesWrite, - start_sequence: MCP_SCOPES.sequencesWrite, - pause_sequence: MCP_SCOPES.sequencesWrite, - - get_email: MCP_SCOPES.emailsRead, - list_emails: MCP_SCOPES.emailsRead, - send_email: MCP_SCOPES.emailsSend, - - get_broadcast_web_publishing: MCP_SCOPES.sequencesRead, - update_broadcast_web_publishing: MCP_SCOPES.sequencesWrite, - list_web_issues: MCP_SCOPES.sequencesRead, - get_web_issue: MCP_SCOPES.sequencesRead, - get_web_settings: MCP_SCOPES.settingsRead, - get_reputation: MCP_SCOPES.settingsRead, - connect_google_postmaster: MCP_SCOPES.settingsWrite, - disconnect_google_postmaster: MCP_SCOPES.settingsWrite, - connect_snds: MCP_SCOPES.settingsWrite, - disconnect_snds: MCP_SCOPES.settingsWrite, - sync_reputation: MCP_SCOPES.settingsWrite, - update_web_settings: MCP_SCOPES.settingsWrite, - get_general_settings: MCP_SCOPES.settingsRead, - update_general_settings: MCP_SCOPES.settingsWrite, - get_tracking_domain: MCP_SCOPES.settingsRead, - set_tracking_domain: MCP_SCOPES.settingsWrite, - verify_tracking_domain: MCP_SCOPES.settingsWrite, - - get_esp_config: MCP_SCOPES.espRead, - list_esps: MCP_SCOPES.espRead, - get_esp: MCP_SCOPES.espRead, - check_esp_domain_authentication: MCP_SCOPES.espRead, - update_esp_config: MCP_SCOPES.espWrite, - delete_esp_config: MCP_SCOPES.espWrite, - send_test_email: MCP_SCOPES.espWrite, - create_esp: MCP_SCOPES.espWrite, - update_esp: MCP_SCOPES.espWrite, - delete_esp: MCP_SCOPES.espWrite, - test_esp: MCP_SCOPES.espWrite, - activate_esp: MCP_SCOPES.espWrite, - retire_esp: MCP_SCOPES.espWrite, - - list_teams: MCP_SCOPES.teamsRead, - get_plan_usage: MCP_SCOPES.teamsRead, - create_team: MCP_SCOPES.teamsWrite, - rename_team: MCP_SCOPES.teamsWrite, - delete_team: MCP_SCOPES.teamsWrite, - list_api_keys: MCP_SCOPES.apiKeysRead, - create_api_key: MCP_SCOPES.apiKeysWrite, - delete_api_key: MCP_SCOPES.apiKeysWrite, - - get_esp_feedback_connection: MCP_SCOPES.feedbackRead, - upsert_esp_feedback_connection: MCP_SCOPES.feedbackWrite, - test_esp_feedback_connection: MCP_SCOPES.feedbackWrite, - delete_esp_feedback_connection: MCP_SCOPES.feedbackWrite, - list_delivery_events: MCP_SCOPES.deliveryEventsRead, - get_delivery_event: MCP_SCOPES.deliveryEventsRead, - list_suppressions: MCP_SCOPES.suppressionsRead, - get_suppression: MCP_SCOPES.suppressionsRead, - release_suppression: MCP_SCOPES.suppressionsWrite, - add_suppression: MCP_SCOPES.suppressionsWrite, +/** + * The OAuth scope each MCP-reachable REST operation needs, keyed by its + * contract operation id (see the parity manifest in `@sendlit/api-contract`). + * Dedicated tools and the generic `api.*` tools both authorize through this + * table, so an operation needs the same scope however it is reached. An + * operation missing here is refused (default deny). + */ +const operationScopes = { + "contacts.create": MCP_SCOPES.contactsWrite, + "contacts.list": MCP_SCOPES.contactsRead, + "contacts.get": MCP_SCOPES.contactsRead, + "contacts.update": MCP_SCOPES.contactsWrite, + "contacts.addTag": MCP_SCOPES.contactsWrite, + "contacts.removeTag": MCP_SCOPES.contactsWrite, + "contacts.deliveries": MCP_SCOPES.contactsRead, + "contacts.events": MCP_SCOPES.contactsRead, + "contacts.remove": MCP_SCOPES.contactsWrite, + "contacts.previewBulkDelete": MCP_SCOPES.contactsRead, + "contacts.bulkDelete": MCP_SCOPES.contactsWrite, + "contacts.bulkDeleteStatus": MCP_SCOPES.contactsRead, + "tags.list": MCP_SCOPES.contactsRead, + "tags.references": MCP_SCOPES.contactsRead, + "tags.rename": MCP_SCOPES.contactsWrite, + "tags.remove": MCP_SCOPES.contactsWrite, + "segments.create": MCP_SCOPES.contactsWrite, + "segments.list": MCP_SCOPES.contactsRead, + "segments.get": MCP_SCOPES.contactsRead, + "segments.update": MCP_SCOPES.contactsWrite, + "segments.remove": MCP_SCOPES.contactsWrite, + "media.list": MCP_SCOPES.mediaRead, + "media.get": MCP_SCOPES.mediaRead, + "media.update": MCP_SCOPES.mediaWrite, + "media.remove": MCP_SCOPES.mediaWrite, + "media.references": MCP_SCOPES.mediaRead, + "templates.listSystem": MCP_SCOPES.templatesRead, + "templates.create": MCP_SCOPES.templatesWrite, + "templates.list": MCP_SCOPES.templatesRead, + "templates.get": MCP_SCOPES.templatesRead, + "templates.update": MCP_SCOPES.templatesWrite, + "templates.duplicate": MCP_SCOPES.templatesWrite, + "templates.remove": MCP_SCOPES.templatesWrite, + "sequences.create": MCP_SCOPES.sequencesWrite, + "sequences.list": MCP_SCOPES.sequencesRead, + "sequences.get": MCP_SCOPES.sequencesRead, + "sequences.update": MCP_SCOPES.sequencesWrite, + "sequences.remove": MCP_SCOPES.sequencesWrite, + "sequences.deletionPreview": MCP_SCOPES.sequencesRead, + "sequences.archive": MCP_SCOPES.sequencesWrite, + "sequences.unarchive": MCP_SCOPES.sequencesWrite, + "sequences.addEmail": MCP_SCOPES.sequencesWrite, + "sequences.updateEmail": MCP_SCOPES.sequencesWrite, + "contacts.import": MCP_SCOPES.contactsWrite, + "suppressions.create": MCP_SCOPES.suppressionsWrite, + "forms.list": MCP_SCOPES.contactsRead, + "forms.create": MCP_SCOPES.contactsWrite, + "forms.get": MCP_SCOPES.contactsRead, + "forms.update": MCP_SCOPES.contactsWrite, + "forms.remove": MCP_SCOPES.contactsWrite, + "contacts.export": MCP_SCOPES.contactsRead, + "sequences.sendEmailTest": MCP_SCOPES.sequencesWrite, + "sequences.removeEmail": MCP_SCOPES.sequencesWrite, + "sequences.start": MCP_SCOPES.sequencesWrite, + "sequences.pause": MCP_SCOPES.sequencesWrite, + "sequences.stats": MCP_SCOPES.sequencesRead, + "sequences.schedule": MCP_SCOPES.sequencesRead, + "sequences.waitingContacts": MCP_SCOPES.sequencesRead, + "sequences.getWeb": MCP_SCOPES.sequencesRead, + "sequences.updateWeb": MCP_SCOPES.sequencesWrite, + "web.list": MCP_SCOPES.sequencesRead, + "web.get": MCP_SCOPES.sequencesRead, + "reputation.get": MCP_SCOPES.settingsRead, + "reputation.connectGoogle": MCP_SCOPES.settingsWrite, + "reputation.disconnectGoogle": MCP_SCOPES.settingsWrite, + "reputation.connectSnds": MCP_SCOPES.settingsWrite, + "reputation.disconnectSnds": MCP_SCOPES.settingsWrite, + "reputation.sync": MCP_SCOPES.settingsWrite, + "sequences.subscribers": MCP_SCOPES.sequencesRead, + "transactional.send": MCP_SCOPES.emailsSend, + "transactional.get": MCP_SCOPES.emailsRead, + "transactional.list": MCP_SCOPES.emailsRead, + "events.track": MCP_SCOPES.contactsWrite, + "events.definitions": MCP_SCOPES.contactsRead, + "events.definition": MCP_SCOPES.contactsRead, + "events.createDefinition": MCP_SCOPES.contactsWrite, + "events.updateDefinition": MCP_SCOPES.contactsWrite, + "events.deleteDefinition": MCP_SCOPES.contactsWrite, + "settings.esps.list": MCP_SCOPES.espRead, + "settings.esps.create": MCP_SCOPES.espWrite, + "settings.esps.get": MCP_SCOPES.espRead, + "settings.esps.update": MCP_SCOPES.espWrite, + "settings.esps.remove": MCP_SCOPES.espWrite, + "settings.esps.test": MCP_SCOPES.espWrite, + "settings.esps.domainAuthentication": MCP_SCOPES.espRead, + "settings.esps.activate": MCP_SCOPES.espWrite, + "settings.esps.retire": MCP_SCOPES.espWrite, + "settings.general.get": MCP_SCOPES.settingsRead, + "settings.general.update": MCP_SCOPES.settingsWrite, + "settings.trackingDomain.get": MCP_SCOPES.settingsRead, + "settings.trackingDomain.update": MCP_SCOPES.settingsWrite, + "settings.trackingDomain.verify": MCP_SCOPES.settingsWrite, + "settings.web.get": MCP_SCOPES.settingsRead, + "settings.web.update": MCP_SCOPES.settingsWrite, + "teams.list": MCP_SCOPES.teamsRead, + "teams.create": MCP_SCOPES.teamsWrite, + "teams.rename": MCP_SCOPES.teamsWrite, + "teams.remove": MCP_SCOPES.teamsWrite, + "teams.listKeys": MCP_SCOPES.apiKeysRead, + "teams.createKey": MCP_SCOPES.apiKeysWrite, + "teams.removeKey": MCP_SCOPES.apiKeysWrite, + "feedback.get": MCP_SCOPES.feedbackRead, + "feedback.upsert": MCP_SCOPES.feedbackWrite, + "feedback.test": MCP_SCOPES.feedbackWrite, + "feedback.remove": MCP_SCOPES.feedbackWrite, + "deliveryEvents.list": MCP_SCOPES.deliveryEventsRead, + "deliveryEvents.get": MCP_SCOPES.deliveryEventsRead, + "suppressions.list": MCP_SCOPES.suppressionsRead, + "suppressions.get": MCP_SCOPES.suppressionsRead, + "suppressions.release": MCP_SCOPES.suppressionsWrite, + "billing.organizationPlanUsage": MCP_SCOPES.teamsRead, } as const satisfies Record; -export type McpToolName = keyof typeof toolPolicies; +export type McpOperationId = keyof typeof operationScopes; /** - * Tools a team API key may never call, mirroring REST, which refuses key + * Operations a team API key may never run, mirroring REST, which refuses key * management to key callers. Otherwise a leaked key could mint replacement * keys for itself, so revoking it would not cut off the attacker, or revoke * the team's other keys. */ -const teamKeyDeniedTools: ReadonlySet = new Set([ - "list_api_keys", - "create_api_key", - "delete_api_key", +const teamKeyDeniedOperations: ReadonlySet = new Set([ + "teams.listKeys", + "teams.createKey", + "teams.removeKey", ]); -export function isMcpToolName(name: string): name is McpToolName { - return Object.hasOwn(toolPolicies, name); +export function isMcpOperation(operationId: string): operationId is McpOperationId { + return Object.hasOwn(operationScopes, operationId); } -export function getRequiredScope(name: string): McpScope { - if (!isMcpToolName(name)) { - throw new Error(`MCP tool '${name}' has no authorization policy.`); +export function getRequiredScope(operationId: string): McpScope { + if (!isMcpOperation(operationId)) { + throw new Error(`MCP operation '${operationId}' has no authorization policy.`); } - return toolPolicies[name]; + return operationScopes[operationId]; } -export function authorizeMcpTool(name: string, ctx: ServerContext) { - const requiredScope = getRequiredScope(name); - const authInfo = ctx.http?.authInfo; - const authKind = authInfo?.extra?.authKind; - - if (!authInfo) { - return errorResult("Authentication required."); +/** Why the caller may not run the operation, or null when it may. */ +export function authorizeMcpOperation( + operationId: string, + context: SendLitMcpContext, +): PlatformError | null { + if (!isMcpOperation(operationId)) { + return createPlatformError("forbidden", { + safeDetails: { reason: "not_mcp_operation" }, + }); } - // A team API key is already a full-access credential for exactly one team, // except for managing API keys, which requires a user (OAuth) session. - // Scoped team keys are a separate API-key product feature. - if (authKind === "team_key") { - if (teamKeyDeniedTools.has(name as McpToolName)) { - return errorResult( - "user_auth_required: managing API keys requires a user-authenticated session, not an API key.", - ); - } - return null; - } - - if (authKind !== "oauth" || !authInfo.scopes.includes(requiredScope)) { - return errorResult(`insufficient_scope: this tool requires '${requiredScope}'.`); + if (context.authKind === "team_key") { + return teamKeyDeniedOperations.has(operationId) + ? createPlatformError("forbidden", { + safeDetails: { reason: "user_auth_required" }, + }) + : null; } - - return null; + const requiredScope = operationScopes[operationId]; + return context.scopes.includes(requiredScope) + ? null + : createPlatformError("forbidden", { + safeDetails: { reason: "insufficient_scope", requiredScope }, + }); } -export function listMcpToolPolicies(): Readonly> { - return toolPolicies; +export function listMcpOperationScopes(): Readonly> { + return operationScopes; } diff --git a/apps/api/src/mcp/routes.test.ts b/apps/api/src/mcp/routes.test.ts index 245b9173..9f95c315 100644 --- a/apps/api/src/mcp/routes.test.ts +++ b/apps/api/src/mcp/routes.test.ts @@ -1,221 +1,274 @@ -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - mock, - vi, -} from "bun:test"; +import { afterEach, beforeAll, describe, expect, it, mock, vi } from "bun:test"; import type { AddressInfo } from "node:net"; +import { createMcpServerKit } from "@codelitdev/mcp-server-kit"; import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; -import { McpServer } from "@modelcontextprotocol/server"; import express, { Router } from "express"; +import { z } from "zod"; const mocks = { createDiscoveryRoutes: vi.fn(), - buildServer: vi.fn(), + resolveAuth: vi.fn(), + resolveUserTeam: vi.fn(), }; +mock.module("../db/client", () => ({ db: {}, pool: {} })); mock.module("../auth/better-auth", () => ({ SESSION_COOKIE_NAME: "sendlit.session_token", - auth: { - options: { baseURL: "http://localhost:5000", basePath: "/api/auth" }, - }, + auth: { options: { baseURL: "http://localhost:5000", basePath: "/api/auth" } }, mcpResourceUrl: "http://localhost:5000/mcp", + mcpProtectedResourceMetadataUrl: + "http://localhost:5000/.well-known/oauth-protected-resource/mcp", oauthResourceClient: {}, })); mock.module("@codelitdev/oauth-server-kit/mcp", () => ({ createMcpOAuthDiscoveryRoutes: mocks.createDiscoveryRoutes, })); -mock.module("../auth/middleware", () => ({ - mcpAuth: vi.fn((req: any, _res: any, next: any) => { - req.authKind = "team_key"; - req.apiKeyId = "tak_test"; - req.teamId = "team-1"; - next(); - }), -})); -mock.module("../auth/require-team", () => ({ - requireTeam: vi.fn((_req, _res, next) => next()), -})); -mock.module("./server.js", () => ({ - buildMcpServer: () => mocks.buildServer(), -})); +mock.module("../auth/resolve-auth", () => ({ resolveAuth: mocks.resolveAuth })); +mock.module("../auth/require-team", () => ({ resolveUserTeam: mocks.resolveUserTeam })); + +const contexts: unknown[] = []; +mock.module("./server", async () => { + const { authenticateMcpRequest, resolveMcpContext } = await import("./context"); + return { + createSendLitMcp: () => + createMcpServerKit({ + name: "SendLit Test", + version: "0.0.0", + authenticate: authenticateMcpRequest, + resolveContext: resolveMcpContext, + tools: [ + { + name: "whoami", + description: "Echo the context", + risk: "read", + inputSchema: z.object({}), + async handler({ context }) { + contexts.push(context); + const { teamId, authKind } = context as { + teamId: string; + authKind: string; + }; + return { teamId, authKind }; + }, + }, + ], + }), + }; +}); -type ListeningApp = { - origin: string; - close(): Promise; +const teamKeyAuth = { + status: "authenticated", + kind: "team_key", + user: null, + apiKeyId: "key_1", + teamId: "team-1", }; -const listeningApps: ListeningApp[] = []; -const clients: Client[] = []; let routes: Router; +const servers: Array<{ close(): void }> = []; +const clients: Client[] = []; -async function listen(app: express.Express): Promise { - const server = app.listen(0, "127.0.0.1"); - await new Promise((resolve) => server.once("listening", resolve)); - const { port } = server.address() as AddressInfo; - const instance = { - origin: `http://127.0.0.1:${port}`, - close: () => - new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ), - }; - listeningApps.push(instance); - return instance; -} +beforeAll(async () => { + mocks.createDiscoveryRoutes.mockImplementation(() => { + const router = Router(); + router.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => + res.json({ resource: "http://localhost:5000/mcp" }), + ); + return router; + }); + const { createMcpRoutes } = await import("./routes"); + routes = createMcpRoutes({ app: () => express() }); +}); afterEach(async () => { + mocks.resolveAuth.mockReset(); + mocks.resolveUserTeam.mockReset(); + contexts.length = 0; await Promise.allSettled(clients.splice(0).map((client) => client.close())); - await Promise.allSettled(listeningApps.splice(0).map((app) => app.close())); + for (const server of servers.splice(0)) server.close(); }); -describe("MCP Streamable HTTP route", () => { - beforeAll(async () => { - mocks.buildServer.mockImplementation( - () => new McpServer({ name: "SendLit Test", version: "2.0.0" }), - ); - mocks.createDiscoveryRoutes.mockImplementation(() => { - const router = Router(); - router.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => - res.json({ resource: "http://localhost:5000/mcp" }), - ); - return router; - }); - routes = ((await import("./routes")) as any).default; +async function origin() { + const app = express(); + app.use((_req, res, next) => { + res.locals.requestId = "req-1"; + next(); }); + app.use(routes); + const server = app.listen(0, "127.0.0.1"); + servers.push(server); + await new Promise((resolve) => server.once("listening", resolve)); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} - beforeEach(() => { - mocks.buildServer.mockClear(); - }); +async function connect(base: string, headers: Record) { + const client = new Client( + { name: "route-test", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + clients.push(client); + await client.connect( + new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { + requestInit: { headers }, + }), + ); + return client; +} - async function createApp() { - const app = express(); - app.use(routes); - return listen(app); - } +const listTools = (base: string, headers: Record) => + fetch(`${base}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }); +describe("MCP route", () => { it("publishes protected-resource metadata with every enforced scope", async () => { - const listening = await createApp(); - const response = await fetch( - `${listening.origin}/.well-known/oauth-protected-resource/mcp`, - ); - + const base = await origin(); + const response = await fetch(`${base}/.well-known/oauth-protected-resource/mcp`); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - resource: "http://localhost:5000/mcp", - }); expect(mocks.createDiscoveryRoutes).toHaveBeenCalledWith( expect.objectContaining({ resourceUrl: "http://localhost:5000/mcp", allowedOrigins: "*", - scopesSupported: expect.arrayContaining([ - "contacts:read", - "emails:send", - "esp:write", - "api_keys:write", - "suppressions:write", - ]), + scopesSupported: expect.arrayContaining(["contacts:read", "esp:write"]), }), ); }); - it("connects a client pinned to the modern protocol", async () => { - const listening = await createApp(); - const client = new Client( - { name: "route-test", version: "1.0.0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, - ); - clients.push(client); - const transport = new StreamableHTTPClientTransport( - new URL(`${listening.origin}/mcp`), - { - requestInit: { - headers: { "x-sendlit-apikey": "sl_live_test" }, - }, - }, - ); - - await client.connect(transport); - expect(client.getProtocolEra()).toBe("modern"); - expect(mocks.buildServer).toHaveBeenCalled(); - }); - - it("serves the existing Streamable HTTP initialize handshake statelessly", async () => { - const listening = await createApp(); - const response = await fetch(`${listening.origin}/mcp`, { - method: "POST", - headers: { - accept: "application/json, text/event-stream", - "content-type": "application/json", - "x-sendlit-apikey": "sl_live_test", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "legacy", version: "1.0.0" }, - }, - }), + it("serves a team key sent as x-sendlit-apikey, with the caller's IP and request id", async () => { + mocks.resolveAuth.mockResolvedValue(teamKeyAuth); + const base = await origin(); + const client = await connect(base, { "x-sendlit-apikey": "sl_live_key" }); + const result = await client.callTool({ name: "whoami", arguments: {} }); + expect(result.structuredContent).toEqual({ + teamId: "team-1", + authKind: "team_key", }); + // The kit reads X-API-Key; SendLit's own header is aliased to it, and the + // key reaches resolveAuth once (in the headers), or it is ambiguous. + const [authInput] = mocks.resolveAuth.mock.calls[0] as [any]; + expect(authInput).toMatchObject({ transport: "mcp" }); + expect(authInput.headers["x-api-key"]).toBe("sl_live_key"); + expect(authInput.headers["x-sendlit-apikey"]).toBeUndefined(); + expect(authInput.apiKeyHeader).toBeUndefined(); + expect(contexts[0]).toMatchObject({ + requestId: "req-1", + clientIp: "127.0.0.1", + replayHeaders: { "x-sendlit-apikey": "sl_live_key" }, + }); + }); - expect(response.ok).toBe(true); - expect(response.headers.has("mcp-session-id")).toBe(false); - expect(response.headers.get("content-type")).toContain("text/event-stream"); - await expect(response.text()).resolves.toContain('"protocolVersion":"2025-11-25"'); + it("challenges a request without credentials so OAuth clients can discover the server", async () => { + mocks.resolveAuth.mockResolvedValue({ status: "missing" }); + const response = await listTools(await origin(), {}); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer resource_metadata="http://localhost:5000/.well-known/oauth-protected-resource/mcp"', + ); + expect((await response.json()).code).toBe("unauthenticated"); }); - it("does not expose the retired SSE transport", async () => { - const listening = await createApp(); - const response = await fetch(`${listening.origin}/mcp`, { - headers: { "x-sendlit-apikey": "sl_live_test" }, - }); + it("rejects an invalid token and two API-key headers at once", async () => { + mocks.resolveAuth.mockResolvedValue({ status: "invalid_token" }); + const base = await origin(); + const invalid = await listTools(base, { authorization: "Bearer bad" }); + expect(invalid.status).toBe(401); + expect(invalid.headers.get("www-authenticate")).toContain("resource_metadata="); - expect(response.status).toBe(404); + const both = await listTools(base, { "x-sendlit-apikey": "a", "x-api-key": "b" }); + expect(both.status).toBe(400); + expect((await both.json()).code).toBe("credential_ambiguous"); }); - it("rejects non-JSON request bodies before protocol dispatch", async () => { - const listening = await createApp(); - const response = await fetch(`${listening.origin}/mcp`, { - method: "POST", - headers: { - accept: "application/json", - "content-type": "text/plain", - "x-sendlit-apikey": "sl_live_test", + it("resolves an OAuth caller's team like REST and refuses organization keys", async () => { + mocks.resolveAuth.mockResolvedValue({ + status: "authenticated", + kind: "oauth", + user: { id: "user-1" }, + userId: "user-1", + token: "tok", + clientId: "client", + scopes: ["contacts:read"], + }); + mocks.resolveUserTeam.mockResolvedValue({ ok: true, teamId: "team-9" }); + const base = await origin(); + const client = await connect(base, { + authorization: "Bearer tok", + "x-sendlit-team-id": "team_public", + }); + const result = await client.callTool({ name: "whoami", arguments: {} }); + expect(result.structuredContent).toEqual({ teamId: "team-9", authKind: "oauth" }); + expect(mocks.resolveUserTeam).toHaveBeenCalledWith("user-1", "team_public"); + expect(contexts[0]).toMatchObject({ + scopes: ["contacts:read"], + replayHeaders: { + authorization: "Bearer tok", + "x-sendlit-team-id": "team_public", }, - body: "not an MCP envelope", }); - expect(response.status).toBe(415); - expect(mocks.buildServer).not.toHaveBeenCalled(); + mocks.resolveUserTeam.mockResolvedValue({ + ok: false, + status: 409, + error: "team_required", + body: {}, + }); + const ambiguousTeam = await listTools(base, { authorization: "Bearer tok" }); + expect(ambiguousTeam.status).toBe(400); + expect(await ambiguousTeam.json()).toMatchObject({ + code: "tenant_required", + details: { reason: "team_required" }, + }); + + mocks.resolveAuth.mockResolvedValue({ + status: "authenticated", + kind: "organization_key", + user: null, + organizationId: "org-1", + organizationApiKeyId: "ok_1", + organizationScopes: [], + }); + const organizationKey = await listTools(base, { + "x-sendlit-apikey": "sl_org_live_x", + }); + expect(organizationKey.status).toBe(401); }); - it("allows Streamable HTTP protocol headers in browser preflight", async () => { - const listening = await createApp(); - const response = await fetch(`${listening.origin}/mcp`, { + it("answers CORS preflights with SendLit's headers, and refuses non-JSON and GET", async () => { + const base = await origin(); + const preflight = await fetch(`${base}/mcp`, { method: "OPTIONS", - headers: { - origin: "https://client.example", - "access-control-request-method": "POST", - "access-control-request-headers": - "mcp-protocol-version,mcp-method,mcp-name,authorization", - }, + headers: { origin: "https://client.example" }, }); - - expect(response.status).toBe(204); - expect(response.headers.get("access-control-allow-origin")).toBe( + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe( "https://client.example", ); - const allowed = response.headers.get("access-control-allow-headers") || ""; - expect(allowed).toContain("MCP-Protocol-Version"); - expect(allowed).toContain("Mcp-Method"); - expect(allowed).toContain("Mcp-Name"); - expect(allowed).not.toContain("Mcp-Session-Id"); + expect(preflight.headers.get("access-control-allow-headers")).toContain( + "x-sendlit-apikey", + ); + + const text = await fetch(`${base}/mcp`, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + expect(text.status).toBe(415); + + const malformed = await fetch(`${base}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }); + expect(malformed.status).toBe(400); + expect((await malformed.json()).error.code).toBe(-32700); + + const get = await fetch(`${base}/mcp`); + expect(get.status).toBe(405); }); }); diff --git a/apps/api/src/mcp/routes.ts b/apps/api/src/mcp/routes.ts index e172c1b0..2a67729d 100644 --- a/apps/api/src/mcp/routes.ts +++ b/apps/api/src/mcp/routes.ts @@ -1,43 +1,40 @@ import { createMcpOAuthDiscoveryRoutes } from "@codelitdev/oauth-server-kit/mcp"; -import { toNodeHandler } from "@modelcontextprotocol/node"; -import { createMcpHandler } from "@modelcontextprotocol/server"; -import { type NextFunction, type Request, type Response, Router } from "express"; +import { createPlatformError, toPublicHttpError } from "@codelitdev/platform"; +import express, { + type NextFunction, + type Request, + type Response, + Router, +} from "express"; import rateLimit from "express-rate-limit"; -import { auth, mcpResourceUrl, oauthResourceClient } from "../auth/better-auth"; -import { mcpAuth } from "../auth/middleware"; -import { requireTeam } from "../auth/require-team"; +import { CLIENT_IP_HEADER } from "../abuse/signup"; +import { + auth, + mcpProtectedResourceMetadataUrl, + mcpResourceUrl, + oauthResourceClient, +} from "../auth/better-auth"; import logger from "../services/log"; -import { createSendLitMcpAuthInfo } from "./auth-context"; +import type { ApiToolDeps } from "./api-tools"; +import { runMcpRequest } from "./context"; import { MCP_SCOPES_SUPPORTED } from "./policy"; -import { buildMcpServer } from "./server"; +import { createSendLitMcp } from "./server"; -const router = Router(); - -router.use( - createMcpOAuthDiscoveryRoutes({ - auth, - resourceUrl: mcpResourceUrl, - oauthResourceClient, - scopesSupported: [...MCP_SCOPES_SUPPORTED], - allowedOrigins: "*", - }), -); +/** SendLit's own headers, allowed on top of the kit's CORS list. */ +const SENDLIT_CORS_HEADERS = "x-sendlit-apikey, X-Sendlit-Team-Id"; const highImpactTools = new Set([ "send_email", - "send_test_email", + "send_sequence_email_test", "track_event", - "test_esp", "start_sequence", - "create_api_key", - "delete_api_key", - "delete_team", - "delete_contacts", - "release_suppression", + "api.write", + "api.delete", ]); const mcpLimiter = rateLimit({ windowMs: 60_000, + skip: (req) => req.method === "OPTIONS", max: (req) => (highImpactTools.has(String(req.headers["mcp-name"] || "")) ? 20 : 60), standardHeaders: true, legacyHeaders: false, @@ -47,108 +44,124 @@ const mcpLimiter = rateLimit({ }, handler(req, res, _next, options) { logger.warn( - { - method: req.headers["mcp-method"], - tool: req.headers["mcp-name"], - authKind: (req as any).authKind, - }, + { method: req.headers["mcp-method"], tool: req.headers["mcp-name"] }, "MCP rate limit exceeded", ); res.status(options.statusCode).json(options.message); }, }); -function mcpCors(req: Request, res: Response, next: NextFunction) { - const origin = req.headers.origin || "*"; - res.header("Access-Control-Allow-Origin", origin); - res.header("Vary", "Origin"); - res.header("Access-Control-Allow-Methods", "POST, OPTIONS"); - res.header( - "Access-Control-Allow-Headers", - "Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, x-sendlit-apikey, Authorization", - ); - if (req.method === "OPTIONS") { - res.status(204).end(); - return; - } - next(); -} - -function attachMcpAuthInfo(req: Request, res: Response, next: NextFunction) { - const authInfo = createSendLitMcpAuthInfo(req); - if (!authInfo) { - res.status(401).json({ - error: "unauthorized", - error_description: "MCP authentication context is incomplete.", +/** The kit owns protocol parsing errors as JSON-RPC, not Express HTML. */ +const jsonBody = (req: Request, res: Response, next: NextFunction) => { + if (req.method !== "POST") return next(); + if (!req.is("application/json")) { + res.status(415).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Content-Type must be application/json" }, + id: null, }); return; } - (req as any).auth = authInfo; - next(); -} - -function observeMcpRequest(req: Request, res: Response, next: NextFunction) { - const startedAt = performance.now(); - res.once("finish", () => { - // Deliberately exclude arguments, credentials, message content, and - // results. These low-cardinality fields are safe to aggregate into - // latency/error dashboards at the logging layer. - logger.info( - { - method: req.headers["mcp-method"], - tool: req.headers["mcp-name"], - statusCode: res.statusCode, - durationMs: Math.round(performance.now() - startedAt), - authKind: (req as any).authKind, - }, - "MCP request completed", - ); + express.json({ limit: "4mb" })(req, res, (error?: unknown) => { + if (error) { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32700, message: "Parse error" }, + id: null, + }); + return; + } + next(); }); - next(); -} +}; -export const mcpHandler = createMcpHandler( - // One request-local server instance works for both supported eras. Modern - // clients use stateless 2026 request envelopes; existing MCP clients such - // as VS Code still begin with the 2025 `initialize` handshake. - () => buildMcpServer(), - { - // Keep 2025 traffic stateless too: no session IDs or retained - // transports are introduced, while clients that have not upgraded to - // the 2026 wire revision can complete their Streamable HTTP handshake. - legacy: "stateless", - onerror(error) { - logger.error({ errorType: error.name }, "MCP protocol error"); - }, - }, -); +export function createMcpRoutes(deps: ApiToolDeps): Router { + const router = Router(); + const mcp = createSendLitMcp(deps); -const nodeMcpHandler = toNodeHandler(mcpHandler, { - onerror(error) { - logger.error({ errorType: error.name }, "MCP Node adapter error"); - }, -}); + router.use( + createMcpOAuthDiscoveryRoutes({ + auth, + resourceUrl: mcpResourceUrl, + oauthResourceClient, + scopesSupported: [...MCP_SCOPES_SUPPORTED], + allowedOrigins: "*", + }), + ); -router.post( - "/mcp", - mcpCors, - mcpLimiter, - mcpAuth, - requireTeam, - attachMcpAuthInfo, - observeMcpRequest, - async (req: Request, res: Response, next: NextFunction) => { - try { - // This router is mounted before Express body parsing. The official - // adapter owns JSON parsing/content-type validation so protocol - // parse errors and 415 responses remain MCP-compliant. - await nodeMcpHandler(req as any, res); - } catch (error) { - next(error); - } - }, -); + router.all( + "/mcp", + mcpLimiter, + jsonBody, + async (req: Request, res: Response, next) => { + const startedAt = performance.now(); + try { + const headers: Record = { + ...req.headers, + }; + // Existing SendLit clients send their team key as `x-sendlit-apikey`; + // the kit reads `X-API-Key`. Both at once stay ambiguous. + const sendlitKey = headers["x-sendlit-apikey"]; + delete headers["x-sendlit-apikey"]; + if (sendlitKey && headers["x-api-key"]) { + const ambiguous = toPublicHttpError( + createPlatformError("credential_ambiguous"), + ); + res.status(ambiguous.status).json(ambiguous.body); + return; + } + if (sendlitKey) headers["x-api-key"] = sendlitKey; + headers[CLIENT_IP_HEADER] = req.ip ?? ""; + headers["x-request-id"] = String(res.locals.requestId ?? ""); -router.options("/mcp", mcpCors); + const controller = new AbortController(); + res.once("close", () => { + if (!res.writableFinished) controller.abort(); + }); + const result = await runMcpRequest(() => + mcp.handle({ + method: req.method, + headers, + body: req.body, + signal: controller.signal, + }), + ); -export default router; + for (const [name, value] of Object.entries(result.headers)) + res.setHeader(name, value); + const allowed = res.getHeader("Access-Control-Allow-Headers"); + if (allowed) { + res.setHeader( + "Access-Control-Allow-Headers", + `${allowed}, ${SENDLIT_CORS_HEADERS}`, + ); + } + if (result.status === 401) { + // Spec-compliant MCP clients discover OAuth from this challenge. + res.setHeader( + "WWW-Authenticate", + `Bearer resource_metadata="${mcpProtectedResourceMetadataUrl}"`, + ); + } + res.status(result.status); + if (result.body === null) res.end(); + else res.json(result.body); + + // Arguments, credentials, message content and results stay out of logs. + logger.info( + { + method: req.headers["mcp-method"], + tool: req.headers["mcp-name"], + statusCode: result.status, + durationMs: Math.round(performance.now() - startedAt), + }, + "MCP request completed", + ); + } catch (error) { + next(error); + } + }, + ); + + return router; +} diff --git a/apps/api/src/mcp/schema.ts b/apps/api/src/mcp/schema.ts deleted file mode 100644 index 0170bc29..00000000 --- a/apps/api/src/mcp/schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - fromJsonSchema, - type JsonSchemaType, - type StandardSchemaWithJSON, -} from "@modelcontextprotocol/server"; -import { type ZodRawShape, type ZodTypeAny, z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; - -export function toMcpSchema(schema: ZodTypeAny): StandardSchemaWithJSON { - const jsonSchema = zodToJsonSchema(schema, { - target: "jsonSchema7", - $refStrategy: "none", - errorMessages: true, - }) as JsonSchemaType; - return fromJsonSchema(jsonSchema); -} - -export function toMcpObjectSchema( - shape: ZodRawShape | undefined, -): StandardSchemaWithJSON { - return toMcpSchema(z.object(shape ?? {})); -} diff --git a/apps/api/src/mcp/server.test.ts b/apps/api/src/mcp/server.test.ts index 02a60631..b6cd6443 100644 --- a/apps/api/src/mcp/server.test.ts +++ b/apps/api/src/mcp/server.test.ts @@ -1,245 +1,100 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; -import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; -import { - type AuthInfo, - createMcpHandler, - type McpHttpHandler, - type ServerContext, -} from "@modelcontextprotocol/server"; +import { describe, expect, it, mock } from "bun:test"; +import { mcpParityManifest } from "@sendlit/api-contract"; +import express from "express"; mock.module("../db/client", () => ({ db: {}, pool: {} })); -const apiKeyMocks = { - createApiKey: mock(), - deleteApiKey: mock(), - getApiKeysByTeamId: mock(), +const { createSendLitMcp, dedicatedTools } = await import("./server"); +const { authorizeMcpOperation, listMcpOperationScopes, MCP_SCOPES } = await import( + "./policy" +); + +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [] as string[], + requestId: "", + clientIp: "", + replayHeaders: {}, }; -const actualApiKeyQueries = await import("../apikey/queries"); -mock.module("../apikey/queries", () => ({ - ...actualApiKeyQueries, - ...apiKeyMocks, -})); -const { authorizeMcpTool, listMcpToolPolicies, MCP_SCOPES } = await import("./policy"); -const { buildMcpServer } = await import("./server"); - -const endpoint = new URL("http://sendlit.test/mcp"); -const teamKeyAuth: AuthInfo = { - token: "tak_test", - clientId: "team-key:tak_test", - scopes: [], - extra: { - authKind: "team_key", - teamId: "team-1", - user: null, - }, -}; - -function createHandler() { - return createMcpHandler(() => buildMcpServer(), { - legacy: "stateless", +describe("SendLit MCP catalog and policy", () => { + it("serves the dedicated tools then the generic api.* tools, in a stable order", () => { + const names = createSendLitMcp({ app: () => express() }) + .listTools() + .map((tool) => tool.name); + expect(names.slice(-5)).toEqual([ + "api.list_operations", + "api.describe_operation", + "api.read", + "api.write", + "api.delete", + ]); + expect(names).toHaveLength(34); + expect(new Set(names).size).toBe(names.length); + expect(names.slice(0, 3)).toEqual([ + "list_contacts", + "get_contact", + "create_contact", + ]); }); -} -function transportFor(handler: McpHttpHandler, authInfo = teamKeyAuth) { - return new StreamableHTTPClientTransport(endpoint, { - fetch: (input, init) => handler.fetch(new Request(input, init), { authInfo }), + it("gives every MCP-reachable operation a scope, and no other operation one", () => { + const reachable = mcpParityManifest + .flatMap((entry) => (entry.parity === "required" ? [entry.rest.operationId] : [])) + .sort(); + expect(Object.keys(listMcpOperationScopes()).sort()).toEqual(reachable); }); -} - -const closeables: Array<{ close(): Promise }> = []; - -afterEach(async () => { - for (const fn of Object.values(apiKeyMocks)) fn.mockReset(); - await Promise.allSettled(closeables.splice(0).map((item) => item.close())); -}); -describe("MCP server", () => { - it("serves the complete deterministic tool catalog on 2026-07-28", async () => { - const handler = createHandler(); - const client = new Client( - { name: "sendlit-test", version: "1.0.0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + it("maps each dedicated tool to the operation its manifest entry names", () => { + const toolOperation = new Map( + mcpParityManifest.flatMap((entry) => + entry.parity === "required" + ? [[entry.mcp.tool, entry.rest.operationId] as const] + : [], + ), ); - closeables.push(client, handler); - - await client.connect(transportFor(handler)); - expect(client.getProtocolEra()).toBe("modern"); - - const result = await client.listTools(); - expect(result.tools).toHaveLength(115); - expect(result.tools.map((tool) => tool.name).sort()).toEqual( - Object.keys(listMcpToolPolicies()).sort(), - ); - expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(115); - expect(result.ttlMs).toBe(300_000); - expect(result.cacheScope).toBe("private"); - for (const tool of result.tools) { - expect(tool.description).toBeTruthy(); - expect(tool.inputSchema).toMatchObject({ type: "object" }); - expect(tool.outputSchema).toBeTruthy(); - expect(tool.annotations).toBeTruthy(); + for (const tool of dedicatedTools()) { + expect(toolOperation.has(tool.name)).toBe(true); } - - const refreshed = await client.listTools(undefined, { - cacheMode: "refresh", - }); - expect(refreshed.tools.map((tool) => tool.name)).toEqual( - result.tools.map((tool) => tool.name), - ); }); - it("rejects protocol-envelope/header mismatches", async () => { - const handler = createHandler(); - const client = new Client( - { name: "sendlit-header-test", version: "1.0.0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, - ); - closeables.push(client, handler); - let tamperHeaders = false; - const transport = new StreamableHTTPClientTransport(endpoint, { - fetch: (input, init) => { - const request = new Request(input, init); - if (!tamperHeaders) { - return handler.fetch(request, { authInfo: teamKeyAuth }); - } - const headers = new Headers(request.headers); - headers.set("Mcp-Method", "tools/call"); - return handler.fetch(new Request(request, { headers }), { - authInfo: teamKeyAuth, - }); - }, - }); - - await client.connect(transport); - tamperHeaders = true; - - await expect( - client.listTools(undefined, { cacheMode: "refresh" }), - ).rejects.toThrow(); - }); - - it("enforces default-deny OAuth scopes while allowing fixed-team keys", () => { - const baseContext = { - mcpReq: {}, - http: { authInfo: teamKeyAuth }, - } as unknown as ServerContext; - expect(authorizeMcpTool("send_email", baseContext)).toBeNull(); - - const oauthContext = { - mcpReq: {}, - http: { - authInfo: { - token: "oauth-token", - clientId: "client-1", - scopes: [MCP_SCOPES.contactsRead], - extra: { - authKind: "oauth", - teamId: "team-1", - user: null, - }, - }, - }, - } as unknown as ServerContext; - - expect(authorizeMcpTool("list_contacts", oauthContext)).toBeNull(); - expect(authorizeMcpTool("send_email", oauthContext)).toMatchObject({ - isError: true, - content: [ - expect.objectContaining({ - text: expect.stringContaining(MCP_SCOPES.emailsSend), - }), - ], - }); - expect(() => authorizeMcpTool("unregistered_tool", oauthContext)).toThrow( - "has no authorization policy", - ); - }); - - it("denies API key management to team API keys", () => { - const teamKeyContext = { - mcpReq: {}, - http: { authInfo: teamKeyAuth }, - } as unknown as ServerContext; - - for (const tool of ["list_api_keys", "create_api_key", "delete_api_key"]) { - expect(authorizeMcpTool(tool, teamKeyContext)).toMatchObject({ - isError: true, - content: [ - expect.objectContaining({ - text: expect.stringContaining("user_auth_required"), - }), - ], + it("lets team keys run everything but key management", () => { + expect(authorizeMcpOperation("transactional.send", context)).toBeNull(); + expect(authorizeMcpOperation("settings.esps.remove", context)).toBeNull(); + for (const operationId of [ + "teams.listKeys", + "teams.createKey", + "teams.removeKey", + ]) { + expect(authorizeMcpOperation(operationId, context)).toMatchObject({ + code: "forbidden", + safeDetails: { reason: "user_auth_required" }, }); } - - const oauthContext = { - mcpReq: {}, - http: { - authInfo: { - token: "oauth-token", - clientId: "client-1", - scopes: [MCP_SCOPES.apiKeysRead, MCP_SCOPES.apiKeysWrite], - extra: { - authKind: "oauth", - teamId: "team-1", - user: null, - }, - }, - }, - } as unknown as ServerContext; - for (const tool of ["list_api_keys", "create_api_key", "delete_api_key"]) { - expect(authorizeMcpTool(tool, oauthContext)).toBeNull(); - } - }); - - it("refuses team-key calls to key tools before touching keys", async () => { - const handler = createHandler(); - const client = new Client( - { name: "sendlit-team-key-test", version: "1.0.0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, - ); - closeables.push(client, handler); - await client.connect(transportFor(handler)); - - const calls = [ - { name: "list_api_keys", arguments: {} }, - { name: "create_api_key", arguments: { name: "replacement" } }, - { name: "delete_api_key", arguments: { keyId: "tak_other" } }, - ]; - for (const call of calls) { - const result = await client.callTool(call); - expect(result.isError).toBe(true); - expect(JSON.stringify(result.content)).toContain("user_auth_required"); - } - - expect(apiKeyMocks.getApiKeysByTeamId).not.toHaveBeenCalled(); - expect(apiKeyMocks.createApiKey).not.toHaveBeenCalled(); - expect(apiKeyMocks.deleteApiKey).not.toHaveBeenCalled(); }); - it("still lets OAuth sessions with api_keys scopes manage keys", async () => { - apiKeyMocks.deleteApiKey.mockResolvedValue(undefined); - const handler = createHandler(); - const client = new Client( - { name: "sendlit-oauth-key-test", version: "1.0.0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, - ); - closeables.push(client, handler); - await client.connect( - transportFor(handler, { - token: "oauth-token", - clientId: "client-1", + it("requires the operation's scope for OAuth and refuses unknown operations", () => { + const oauth = { + ...context, + authKind: "oauth" as const, + scopes: [MCP_SCOPES.contactsRead], + }; + expect(authorizeMcpOperation("contacts.list", oauth)).toBeNull(); + expect(authorizeMcpOperation("transactional.send", oauth)).toMatchObject({ + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "emails:send" }, + }); + expect( + authorizeMcpOperation("teams.createKey", { + ...oauth, scopes: [MCP_SCOPES.apiKeysWrite], - extra: { authKind: "oauth", teamId: "team-1", user: null }, }), - ); - - const result = await client.callTool({ - name: "delete_api_key", - arguments: { keyId: "tak_other" }, + ).toBeNull(); + expect(authorizeMcpOperation("billing.portal", oauth)).toMatchObject({ + code: "forbidden", + safeDetails: { reason: "not_mcp_operation" }, }); - expect(result.isError).toBeFalsy(); - expect(apiKeyMocks.deleteApiKey).toHaveBeenCalledWith("team-1", "tak_other"); }); }); diff --git a/apps/api/src/mcp/server.ts b/apps/api/src/mcp/server.ts index 283caf77..4e0f93c6 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -1,73 +1,55 @@ -import { McpServer } from "@modelcontextprotocol/server"; -import { createMcpToolRegistrar } from "./tool-registry"; -import { registerContactTools } from "./tools/contacts"; -import { registerDeliveryFeedbackTools } from "./tools/delivery-feedback"; -import { registerEspTools } from "./tools/esp"; -import { registerEventTools } from "./tools/events"; -import { registerFormTools } from "./tools/forms"; -import { registerGeneralSettingsTools } from "./tools/general-settings"; -import { registerMediaTools } from "./tools/media"; -import { registerReputationTools } from "./tools/reputation"; -import { registerSegmentTools } from "./tools/segments"; -import { registerSequenceTools } from "./tools/sequences"; -import { registerTagTools } from "./tools/tags"; -import { registerTeamTools } from "./tools/teams"; -import { registerTemplateTools } from "./tools/templates"; -import { registerTrackingDomainTools } from "./tools/tracking-domain"; -import { registerTransactionalTools } from "./tools/transactional"; -import { registerWebTools } from "./tools/web"; +import { createMcpServerKit, type McpServerKit } from "@codelitdev/mcp-server-kit"; +import { captureError } from "../observability"; +import { type ApiToolDeps, apiTools } from "./api-tools"; +import { + authenticateMcpRequest, + resolveMcpContext, + type SendLitMcpContext, + type SendLitMcpTool, +} from "./context"; +import { contactTools } from "./tools/contacts"; +import { deliveryEventTools } from "./tools/delivery-feedback"; +import { eventTools } from "./tools/events"; +import { segmentTools } from "./tools/segments"; +import { sequenceTools } from "./tools/sequences"; +import { teamTools } from "./tools/teams"; +import { templateTools } from "./tools/templates"; +import { transactionalTools } from "./tools/transactional"; -export const SENDLIT_MCP_VERSION = "2.0.0"; +export const SENDLIT_MCP_VERSION = "3.0.0"; + +/** Dedicated tools for the common flows, in a stable order so catalog caches + * and upstream prompt caches see a deterministic tool list. */ +export function dedicatedTools(): SendLitMcpTool[] { + return [ + ...contactTools(), + ...segmentTools(), + ...templateTools(), + ...sequenceTools(), + ...transactionalTools(), + ...eventTools(), + ...deliveryEventTools(), + ...teamTools(), + ]; +} /** - * Build one request-local MCP server for both the existing 2025 and modern - * 2026 protocol eras. This factory is deliberately pure: tenant/principal - * state arrives through the SDK request context and all durable state lives in - * SendLit's existing database/queue services. + * SendLit's MCP endpoint on the CodeLit Platform kit (ADR 0010): stateless, + * protocol 2026-07-28 plus 2025-era Streamable HTTP, with SendLit's own + * credential and team resolution. Dedicated tools cover the everyday flows; + * the generic api.* tools reach every other team REST operation. */ -export function buildMcpServer(): McpServer { - const server = new McpServer( - { - name: "SendLit", - version: SENDLIT_MCP_VERSION, - description: - "SendLit MCP server — compose, send and automate email for a SendLit team. Manage contacts and segments, templates and media, broadcasts and sequences, transactional email, sending providers, settings, team API keys, and delivery feedback. Organization bootstrap, shared-delivery policy, and team provisioning use the REST API, not MCP.", - }, - { - instructions: - "Use read tools to inspect existing state before mutations. Sending, activation, deletion, key management, and suppression-release tools have external or destructive effects; confirm the requested target and inputs before calling them. MCP is team-scoped and does not accept organization API keys. Use the REST API and /openapi.json for organization discovery, headless shared-delivery setup, and team provisioning.", - cacheHints: { - "server/discover": { - ttlMs: 300_000, - cacheScope: "private", - }, - "tools/list": { - ttlMs: 300_000, - cacheScope: "private", - }, - }, - }, - ); - const tools = createMcpToolRegistrar(server); - - // Registration order is stable so catalog caching and upstream prompt - // caches receive deterministic tool lists. - registerContactTools(tools); - registerSegmentTools(tools); - registerTagTools(tools); - registerFormTools(tools); - registerWebTools(tools); - registerReputationTools(tools); - registerTemplateTools(tools); - registerMediaTools(tools); - registerSequenceTools(tools); - registerTransactionalTools(tools); - registerEventTools(tools); - registerGeneralSettingsTools(tools); - registerTrackingDomainTools(tools); - registerEspTools(tools); - registerTeamTools(tools); - registerDeliveryFeedbackTools(tools); - - return server; +export function createSendLitMcp(deps: ApiToolDeps): McpServerKit { + return createMcpServerKit({ + name: "SendLit", + version: SENDLIT_MCP_VERSION, + description: + "SendLit MCP server: compose, send and automate email for a SendLit team. Dedicated tools manage contacts, segments, templates, broadcasts and sequences, transactional email, events and delivery events. Everything else the team REST API offers (sending providers, settings, reputation, signup forms, media, tags, suppressions, teams, API keys and deletes) is reachable through the api.* tools.", + instructions: + "Every call acts on the connected team. Use the dedicated tools for contacts, segments, templates, sequences and broadcasts, sending email and tracking events. For anything else, find the operation with api.list_operations, read its parameters and body with api.describe_operation, then run it with api.read, api.write or api.delete. Read before you write. Sending, activation, deletion, key management and suppression changes have external or destructive effects: confirm the target and inputs with the user first, and pass confirm: true to api.delete. MCP is team-scoped: organization administration, shared-delivery setup, team provisioning and billing use the REST API (see /openapi.json), not MCP.", + authenticate: authenticateMcpRequest, + resolveContext: resolveMcpContext, + onError: ({ error, source }) => captureError({ error, source }), + tools: [...dedicatedTools(), ...apiTools(deps)], + }); } diff --git a/apps/api/src/mcp/tool-registry.ts b/apps/api/src/mcp/tool-registry.ts deleted file mode 100644 index f4f1d8d9..00000000 --- a/apps/api/src/mcp/tool-registry.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { - McpServer, - ServerContext, - StandardSchemaWithJSON, - ToolAnnotations, -} from "@modelcontextprotocol/server"; -import type { ZodRawShape, ZodTypeAny } from "zod"; -import logger from "../services/log"; -import { authorizeMcpTool, getRequiredScope, type McpToolName } from "./policy"; -import { toMcpObjectSchema, toMcpSchema } from "./schema"; - -export type McpToolConfig = { - title?: string; - description?: string; - inputSchema?: ZodRawShape; - outputSchema: ZodTypeAny; - annotations?: ToolAnnotations; -}; - -export type McpToolHandler = ( - argsOrContext: any, - context?: ServerContext, -) => Promise | any; - -export interface McpToolRegistrar { - registerTool(name: McpToolName, config: McpToolConfig, handler: McpToolHandler): void; -} - -type CompiledToolSchemas = { - inputSchema: StandardSchemaWithJSON; - outputSchema: StandardSchemaWithJSON; -}; - -// SDK 2 creates a fresh server for every stateless request, but SendLit's tool -// schemas are immutable. Cache their adapters by the exhaustively policy-bound -// tool name so 68 Zod-to-JSON-Schema conversions happen once per process, not -// once per request. -const compiledSchemas = new Map(); - -function getCompiledSchemas( - name: McpToolName, - config: McpToolConfig, -): CompiledToolSchemas { - const cached = compiledSchemas.get(name); - if (cached) return cached; - - const schemas = { - inputSchema: toMcpObjectSchema(config.inputSchema), - outputSchema: toMcpSchema(config.outputSchema), - }; - compiledSchemas.set(name, schemas); - return schemas; -} - -/** - * Adapt SendLit's canonical Zod 3 tool schemas and thin handlers to the MCP - * SDK 2 Standard Schema/context APIs. Every registration is default-deny: - * looking up the required scope throws if policy is missing. - */ -export function createMcpToolRegistrar(server: McpServer): McpToolRegistrar { - return { - registerTool(name, config, handler) { - const requiredScope = getRequiredScope(name); - const hasDeclaredInput = config.inputSchema !== undefined; - const schemas = getCompiledSchemas(name, config); - - server.registerTool( - name, - { - title: config.title, - description: config.description, - ...schemas, - annotations: config.annotations, - }, - async (args, ctx) => { - const startedAt = performance.now(); - const authKind = ctx.http?.authInfo?.extra?.authKind; - const denied = authorizeMcpTool(name, ctx); - if (denied) { - logger.warn( - { - tool: name, - requiredScope, - authKind, - scopeDecision: "denied", - }, - "MCP tool authorization denied", - ); - return denied; - } - - try { - const result = await (hasDeclaredInput ? handler(args, ctx) : handler(ctx)); - logger.info( - { - tool: name, - requiredScope, - authKind, - scopeDecision: "allowed", - outcome: result?.isError ? "tool_error" : "success", - durationMs: Math.round(performance.now() - startedAt), - }, - "MCP tool completed", - ); - return result; - } catch (error) { - logger.error( - { - tool: name, - requiredScope, - authKind, - scopeDecision: "allowed", - outcome: "exception", - durationMs: Math.round(performance.now() - startedAt), - errorType: error instanceof Error ? error.name : typeof error, - }, - "MCP tool failed", - ); - throw error; - } - }, - ); - }, - }; -} diff --git a/apps/api/src/mcp/tool.ts b/apps/api/src/mcp/tool.ts new file mode 100644 index 00000000..107c8d17 --- /dev/null +++ b/apps/api/src/mcp/tool.ts @@ -0,0 +1,76 @@ +import type { McpToolRisk } from "@codelitdev/mcp-server-kit"; +import type { z } from "zod"; +import logger from "../services/log"; +import { serializeDates } from "../utils/serialize"; +import type { SendLitMcpContext, SendLitMcpTool } from "./context"; +import { authorizeMcpOperation, getRequiredScope, type McpOperationId } from "./policy"; + +export type DedicatedToolDefinition = { + name: string; + /** The REST operation this tool performs; its scope authorizes the call. */ + operationId: McpOperationId; + title?: string; + description: string; + risk: McpToolRisk; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + annotations?: SendLitMcpTool["annotations"]; + handler: (input: { context: SendLitMcpContext; args: Args }) => Promise; +}; + +/** + * A dedicated MCP tool for a common flow. Every call is authorized against + * its REST operation's scope before the handler runs (default deny: an + * operation without a policy throws here, at startup), and dates in the + * result become ISO strings, as REST sends them. + */ +export function dedicatedTool( + definition: DedicatedToolDefinition, +): SendLitMcpTool { + const { operationId, handler, ...tool } = definition; + const requiredScope = getRequiredScope(operationId); + return { + ...tool, + inputSchema: definition.inputSchema, + async handler({ context, args }) { + const startedAt = performance.now(); + const log = { tool: tool.name, requiredScope, authKind: context.authKind }; + const denied = authorizeMcpOperation(operationId, context); + if (denied) { + logger.warn( + { ...log, scopeDecision: "denied" }, + "MCP tool authorization denied", + ); + return { error: denied }; + } + try { + const result = await handler({ context, args: args as Args }); + const failed = Boolean( + result && typeof result === "object" && "error" in result, + ); + logger.info( + { + ...log, + scopeDecision: "allowed", + outcome: failed ? "tool_error" : "success", + durationMs: Math.round(performance.now() - startedAt), + }, + "MCP tool completed", + ); + return failed ? result : serializeDates(result); + } catch (error) { + logger.error( + { + ...log, + scopeDecision: "allowed", + outcome: "exception", + durationMs: Math.round(performance.now() - startedAt), + errorType: error instanceof Error ? error.name : typeof error, + }, + "MCP tool failed", + ); + throw error; + } + }, + }; +} diff --git a/apps/api/src/mcp/tools/auth.ts b/apps/api/src/mcp/tools/auth.ts deleted file mode 100644 index 06b414b3..00000000 --- a/apps/api/src/mcp/tools/auth.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ServerContext } from "@modelcontextprotocol/server"; -import type { User } from "../../user/queries"; -import { getSendLitMcpAuthInfo } from "../auth-context"; - -/** The team id is explicit tenant context and is never overloaded into the - * OAuth `clientId`. It is attached after credential and team verification. */ -export function getTeamId(ctx: ServerContext): string | null { - return getSendLitMcpAuthInfo(ctx)?.extra.teamId ?? null; -} - -/** The logged-in human, when authenticated as a user — `null` for API-key - * sessions (a key has no single owning account; a team can have several - * members). Only used for cosmetic fallbacks (e.g. "send the test email to - * me"), never for authorization. */ -export function getAuthUser(ctx: ServerContext): User | null { - return getSendLitMcpAuthInfo(ctx)?.extra.user ?? null; -} diff --git a/apps/api/src/mcp/tools/contacts.test.ts b/apps/api/src/mcp/tools/contacts.test.ts new file mode 100644 index 00000000..27162494 --- /dev/null +++ b/apps/api/src/mcp/tools/contacts.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + importContacts: vi.fn(), + countContacts: vi.fn(), + createContact: vi.fn(), + getContactByContactId: vi.fn(), + listContacts: vi.fn(), + updateContact: vi.fn(), + addTagToContact: vi.fn(), + removeTagFromContact: vi.fn(), + getSegment: vi.fn(), +}; + +mock.module("../../contacts/import", () => ({ importContacts: mocks.importContacts })); +mock.module("../../contacts/queries", () => ({ + countContacts: mocks.countContacts, + createContact: mocks.createContact, + getContactByContactId: mocks.getContactByContactId, + listContacts: mocks.listContacts, + updateContact: mocks.updateContact, + addTagToContact: mocks.addTagToContact, + removeTagFromContact: mocks.removeTagFromContact, +})); +// segments.test.ts mocks the same module; bun shares mocks across files run +// in one process, so both list every export either one needs. +mock.module("../../contacts/segments-queries", () => ({ + getSegment: mocks.getSegment, + createSegment: vi.fn(), + listSegments: vi.fn(), +})); + +const { contactTools } = await import("./contacts"); +const { PlanGateError } = await import("../../billing/errors"); + +const signal = new AbortController().signal; +const teamKeyContext = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", + clientIp: "", + replayHeaders: {}, +}; +const oauthContext = { + ...teamKeyContext, + authKind: "oauth" as const, + user: { id: "user-1", email: "owner@example.com", name: "Owner" } as any, + scopes: ["contacts:read", "contacts:write"], +}; + +const tools = new Map(contactTools().map((tool) => [tool.name, tool])); +function call( + name: string, + args: unknown, + context: any = teamKeyContext, +): Promise { + return tools.get(name)!.handler({ context, args, signal }); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP contact tools", () => { + it("keeps only the dedicated contact tools", () => { + expect([...tools.keys()].sort()).toEqual( + [ + "add_contact_tag", + "create_contact", + "get_contact", + "import_contacts", + "list_contacts", + "remove_contact_tag", + "update_contact", + ].sort(), + ); + expect(tools.get("list_contacts")!.risk).toBe("read"); + expect(tools.get("create_contact")!.risk).toBe("write"); + }); + + it("scopes list/create calls to the resolved team and serializes dates", async () => { + const firstContactId = crypto.randomUUID(); + mocks.listContacts.mockResolvedValue([ + { contactId: firstContactId, id: "internal" }, + ]); + mocks.countContacts.mockResolvedValue(1); + + await expect(call("list_contacts", { q: "ada", offset: 2 })).resolves.toEqual({ + items: [{ contactId: firstContactId }], + total: 1, + }); + expect(mocks.listContacts).toHaveBeenCalledWith({ + teamId: "team-1", + searchText: "ada", + offset: 2, + }); + + mocks.createContact.mockResolvedValue({ + contactId: crypto.randomUUID(), + email: "reader@example.com", + subscribed: true, + customFields: { plan: "pro" }, + tags: [], + unsubscribeToken: "unsubscribe-token", + createdAt: new Date("2026-08-09T10:00:00.000Z"), + updatedAt: new Date("2026-08-09T10:00:00.000Z"), + }); + const created = await call("create_contact", { + email: "reader@example.com", + customFields: { plan: "pro" }, + }); + expect(mocks.createContact).toHaveBeenCalledWith({ + teamId: "team-1", + email: "reader@example.com", + customFields: { plan: "pro" }, + }); + expect(created).toMatchObject({ + createdAt: "2026-08-09T10:00:00.000Z", + updatedAt: "2026-08-09T10:00:00.000Z", + }); + expect(tools.get("create_contact")!.outputSchema!.safeParse(created).success).toBe( + true, + ); + }); + + it("filters by a saved segment of the team only", async () => { + mocks.getSegment.mockResolvedValue({ teamId: "team-2", filter: {} }); + await expect(call("list_contacts", { segmentId: "seg_1" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + expect(mocks.listContacts).not.toHaveBeenCalled(); + }); + + it("does not leak contacts from another team", async () => { + mocks.getContactByContactId.mockResolvedValue({ + contactId: "cnt_1", + teamId: "team-2", + }); + await expect(call("get_contact", { contactId: "cnt_1" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("returns not_found when updating or tagging an unknown contact", async () => { + mocks.updateContact.mockResolvedValue(null); + mocks.addTagToContact.mockResolvedValue(null); + mocks.removeTagFromContact.mockResolvedValue(null); + for (const [name, args] of [ + ["update_contact", { contactId: "cnt_1", name: "Ada" }], + ["add_contact_tag", { contactId: "cnt_1", tag: "vip" }], + ["remove_contact_tag", { contactId: "cnt_1", tag: "vip" }], + ] as const) { + await expect(call(name, args)).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + } + expect(mocks.updateContact).toHaveBeenCalledWith("team-1", "cnt_1", { + name: "Ada", + }); + expect(mocks.addTagToContact).toHaveBeenCalledWith("team-1", "cnt_1", "vip"); + }); + + it("passes the signed-in user to an import and maps plan limits", async () => { + mocks.importContacts.mockResolvedValue({ created: 1, updated: 0, unchanged: 0 }); + await call( + "import_contacts", + { contacts: [{ email: "a@example.com" }] }, + oauthContext, + ); + expect(mocks.importContacts).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "team-1", userId: "user-1" }), + ); + + mocks.createContact.mockRejectedValue( + new PlanGateError("plan_limit_reached", { limit: 500, nested: { no: true } }), + ); + const gated = await call("create_contact", { email: "a@example.com" }); + expect(gated.error.code).toBe("forbidden"); + expect(gated.error.safeDetails).toEqual({ + reason: "plan_limit_reached", + limit: 500, + }); + }); + + it("rethrows unexpected failures", async () => { + mocks.createContact.mockRejectedValue(new Error("db down")); + await expect(call("create_contact", { email: "a@example.com" })).rejects.toThrow( + "db down", + ); + }); + + it("refuses an OAuth token without the operation's scope", async () => { + const result = await call( + "create_contact", + { email: "a@example.com" }, + { ...oauthContext, scopes: ["contacts:read"] }, + ); + expect(result).toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "contacts:write" }, + }, + }); + expect(mocks.createContact).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/contacts.ts b/apps/api/src/mcp/tools/contacts.ts index 99958ba6..3fc02bf2 100644 --- a/apps/api/src/mcp/tools/contacts.ts +++ b/apps/api/src/mcp/tools/contacts.ts @@ -1,163 +1,91 @@ import { - contactDeletionSchema, contactFilterSchema, - contactSelectionObjectSchema, - contactSelectionSchema, customFieldsSchema, - deleteContactsBodySchema, - deleteContactsObjectSchema, importContactsBodySchema, importContactsResultSchema, } from "@sendlit/api-contract"; import { z } from "zod"; -import { countSelection, selectionCondition } from "../../contacts/bulk-delete"; -import { - getContactDeletion, - startContactDeletion, -} from "../../contacts/deletion-queue"; -import { exportContactsCsv } from "../../contacts/export"; import { importContacts } from "../../contacts/import"; import { addTagToContact, countContacts, createContact, - deleteContact, getContactByContactId, - getDeliveriesByContact, listContacts, removeTagFromContact, updateContact, } from "../../contacts/queries"; -import type { ContactFilterWithAggregator } from "../../contacts/segment"; import { getSegment } from "../../contacts/segments-queries"; import { omitInternal } from "../../utils/public"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getAuthUser, getTeamId } from "./auth"; -import { - AUTH_ERROR, - errorResult, - INTERNAL_ERROR, - isPlanGateError, - jsonResult, - NOT_FOUND, - planGateResult, -} from "./responses"; -import { - contactDeliveryListSchema, - contactListSchema, - contactSchema, - successMessageSchema, -} from "./schemas"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { isPlanGateError, notFound, planGateError, toolError } from "./responses"; +import { contactListSchema, contactSchema } from "./schemas"; -export function registerContactTools(server: McpToolRegistrar): void { - server.registerTool( - "import_contacts", - { - description: - "Imports up to 1000 contacts per call from another platform's export (beehiiv, Mailchimp, Substack, Kit, ...), matched by email. Pass each row's source status as-is (e.g. Active, Paused, Inactive, Pending, unsubscribed, cleaned): a status only gets stricter, so an import never re-subscribes anyone, bounced/complained rows are imported unsubscribed and suppressed, and paused contacts get the `paused` tag. Tags and custom fields merge into existing contacts and an earlier createdAt is kept. Imports don't trigger sequences. Returns created/updated/unchanged counts and the skipped rows with reasons.", - inputSchema: importContactsBodySchema.shape, - outputSchema: importContactsResultSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; +export const contactTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "import_contacts", + operationId: "contacts.import", + risk: "write", + description: + "Imports up to 1000 contacts per call from another platform's export (beehiiv, Mailchimp, Substack, Kit, ...), matched by email. Pass each row's source status as-is (e.g. Active, Paused, Inactive, Pending, unsubscribed, cleaned): a status only gets stricter, so an import never re-subscribes anyone, bounced/complained rows are imported unsubscribed and suppressed, and paused contacts get the `paused` tag. Tags and custom fields merge into existing contacts and an earlier createdAt is kept. Imports don't trigger sequences. Returns created/updated/unchanged counts and the skipped rows with reasons.", + inputSchema: importContactsBodySchema, + outputSchema: importContactsResultSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { - return jsonResult( - await importContacts({ teamId, userId: getAuthUser(extra)?.id, ...args }), - ); + return await importContacts({ + teamId: context.teamId, + userId: context.user?.id, + ...args, + }); } catch (error) { - if (isPlanGateError(error)) return planGateResult(error); - return INTERNAL_ERROR; + if (isPlanGateError(error)) return planGateError(error); + throw error; } }, - ); + }), - server.registerTool( - "export_contacts", - { - description: - "Exports every contact, or those matching a saved segment and/or inline filter, as CSV text: email, name, status (subscribed/unsubscribed/bounced/complained), tags, created_at, suppression_reason, then one column per custom field.", - inputSchema: { - segmentId: z.string().optional().describe("Only export this saved segment"), - filter: contactFilterSchema - .optional() - .describe("Inline contact filter; combines with segmentId using AND"), - }, - outputSchema: z.object({ csv: z.string(), contacts: z.number().int() }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const filters: ContactFilterWithAggregator[] = []; - if (args.segmentId) { - const segment = await getSegment(args.segmentId); - if (!segment || segment.teamId !== teamId) return NOT_FOUND; - const parsed = contactFilterSchema.safeParse(segment.filter); - if (!parsed.success) return INTERNAL_ERROR; - filters.push(parsed.data as ContactFilterWithAggregator); - } - if (args.filter) filters.push(args.filter); - const { csv, count } = await exportContactsCsv({ - teamId, - filter: filters.length ? filters : undefined, - }); - return jsonResult({ csv, contacts: count }); - }, - ); - - server.registerTool( - "list_contacts", - { - description: - "Returns a paginated list of contacts (subscribers), optionally filtered by a search term matching email or name, and/or by a saved segment's filter.", - inputSchema: { - q: z.string().optional().describe("Search by email or name"), - segmentId: z - .string() - .optional() - .describe( - "Only return contacts currently matching this saved segment's filter", - ), - filter: contactFilterSchema - .optional() - .describe("Inline contact filter; combines with q and segmentId using AND"), - excludeFilter: contactFilterSchema - .optional() - .describe("Contacts matching this filter are left out"), - offset: z - .number() - .int() - .min(1) - .optional() - .describe("Page number, 1-indexed (default: 1)"), - }, - outputSchema: contactListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "list_contacts", + operationId: "contacts.list", + risk: "read", + description: + "Returns a paginated list of contacts (subscribers), optionally filtered by a search term matching email or name, and/or by a saved segment's filter.", + inputSchema: z.object({ + q: z.string().optional().describe("Search by email or name"), + segmentId: z + .string() + .optional() + .describe( + "Only return contacts currently matching this saved segment's filter", + ), + filter: contactFilterSchema + .optional() + .describe("Inline contact filter; combines with q and segmentId using AND"), + excludeFilter: contactFilterSchema + .optional() + .describe("Contacts matching this filter are left out"), + offset: z + .number() + .int() + .min(1) + .optional() + .describe("Page number, 1-indexed (default: 1)"), + }), + outputSchema: contactListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const teamId = context.teamId; try { const filters = []; if (args.segmentId) { const segment = await getSegment(args.segmentId); - if (!segment || segment.teamId !== teamId) return NOT_FOUND; + if (!segment || segment.teamId !== teamId) return notFound(); const parsedSegmentFilter = contactFilterSchema.safeParse(segment.filter); - if (!parsedSegmentFilter.success) return INTERNAL_ERROR; + if (!parsedSegmentFilter.success) { + throw new Error("Saved segment filter is invalid"); + } filters.push(parsedSegmentFilter.data as any); } if (args.filter) { @@ -169,338 +97,138 @@ export function registerContactTools(server: McpToolRegistrar): void { teamId, searchText: args.q, filter, - excludeFilter: args.excludeFilter, + excludeFilter: args.excludeFilter as any, offset: args.offset, }), countContacts(teamId, { searchText: args.q, filter, - excludeFilter: args.excludeFilter, + excludeFilter: args.excludeFilter as any, }), ]); - return jsonResult({ + return { items: items.map((item) => omitInternal(item)), total, - }); + }; } catch (error) { - if (isPlanGateError(error)) return planGateResult(error); - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "get_contact", - { - description: "Returns a single contact by its contact ID.", - inputSchema: { - contactId: z.string().describe("Contact ID"), - }, - outputSchema: contactSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const contact = await getContactByContactId(args.contactId); - if (!contact || contact.teamId !== teamId) return NOT_FOUND; - return jsonResult(omitInternal(contact)); - } catch { - return INTERNAL_ERROR; + if (isPlanGateError(error)) return planGateError(error); + throw error; } }, - ); + }), - server.registerTool( - "get_contact_deliveries", - { - description: - "Returns the broadcasts and sequence emails a contact has received, newest first.", - inputSchema: { - contactId: z.string().describe("Contact ID"), - }, - outputSchema: contactDeliveryListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const contact = await getContactByContactId(args.contactId); - if (!contact || contact.teamId !== teamId) return NOT_FOUND; - const deliveries = await getDeliveriesByContact(teamId, contact.id); - return jsonResult({ items: deliveries }); - } catch { - return INTERNAL_ERROR; - } - }, - ); + dedicatedTool({ + name: "get_contact", + operationId: "contacts.get", + risk: "read", + description: "Returns a single contact by its contact ID.", + inputSchema: z.object({ + contactId: z.string().describe("Contact ID"), + }), + outputSchema: contactSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const contact = await getContactByContactId(args.contactId); + if (!contact || contact.teamId !== context.teamId) return notFound(); + return omitInternal(contact); + }, + }), - server.registerTool( - "create_contact", - { - description: - "Creates a contact (subscriber). If a contact with the same email already exists, the existing contact is returned instead. Any attributes beyond email and name (e.g. company, age, plan, role) must be passed as scalar or scalar-array values in customFields.", - inputSchema: { - email: z.string().email().describe("Contact's email address"), - name: z.string().optional().describe("Contact's display name"), - tags: z.array(z.string()).optional().describe("Initial tags"), - customFields: customFieldsSchema - .optional() - .describe( - "Arbitrary scalar or scalar-array attributes such as company, age, plan, roles, product ids, etc.", - ), - }, - outputSchema: contactSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "create_contact", + operationId: "contacts.create", + risk: "write", + description: + "Creates a contact (subscriber). If a contact with the same email already exists, the existing contact is returned instead. Any attributes beyond email and name (e.g. company, age, plan, role) must be passed as scalar or scalar-array values in customFields.", + inputSchema: z.object({ + email: z.string().email().describe("Contact's email address"), + name: z.string().optional().describe("Contact's display name"), + tags: z.array(z.string()).optional().describe("Initial tags"), + customFields: customFieldsSchema + .optional() + .describe( + "Arbitrary scalar or scalar-array attributes such as company, age, plan, roles, product ids, etc.", + ), + }), + outputSchema: contactSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { - const contact = await createContact({ teamId, ...args }); - return jsonResult(omitInternal(contact)); + const contact = await createContact({ teamId: context.teamId, ...args }); + return omitInternal(contact); } catch (err: any) { - if (isPlanGateError(err)) return planGateResult(err); - return { - content: [{ type: "text" as const, text: err.message }], - isError: true, - }; + if (isPlanGateError(err)) return planGateError(err); + if (err?.message === "team_not_found") { + return toolError("not_found", { reason: "team_not_found" }); + } + throw err; } }, - ); + }), - server.registerTool( - "update_contact", - { - description: - "Updates a contact's name, tags, subscription, or custom fields. customFields accepts scalar or scalar-array values and replaces all existing custom fields — include any you want to keep.", - inputSchema: { - contactId: z.string().describe("Contact ID"), - name: z.string().optional(), - subscribed: z.boolean().optional(), - tags: z.array(z.string()).optional(), - customFields: customFieldsSchema - .optional() - .describe("Custom fields (replaces existing)"), - }, - outputSchema: contactSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "update_contact", + operationId: "contacts.update", + risk: "write", + description: + "Updates a contact's name, tags, subscription, or custom fields. customFields accepts scalar or scalar-array values and replaces all existing custom fields — include any you want to keep.", + inputSchema: z.object({ + contactId: z.string().describe("Contact ID"), + name: z.string().optional(), + subscribed: z.boolean().optional(), + tags: z.array(z.string()).optional(), + customFields: customFieldsSchema + .optional() + .describe("Custom fields (replaces existing)"), + }), + outputSchema: contactSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { const { contactId, ...patch } = args; try { - const contact = await updateContact(teamId, contactId, patch); - if (!contact) return NOT_FOUND; - return jsonResult(omitInternal(contact)); + const contact = await updateContact(context.teamId, contactId, patch); + if (!contact) return notFound(); + return omitInternal(contact); } catch (error) { - if (isPlanGateError(error)) return planGateResult(error); - return INTERNAL_ERROR; + if (isPlanGateError(error)) return planGateError(error); + throw error; } }, - ); + }), - server.registerTool( - "add_contact_tag", - { - description: - "Adds a tag to a contact. May trigger any sequence enrolled on tag-added.", - inputSchema: { contactId: z.string(), tag: z.string() }, - outputSchema: contactSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const contact = await addTagToContact(teamId, args.contactId, args.tag); - if (!contact) return NOT_FOUND; - return jsonResult(omitInternal(contact)); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "remove_contact_tag", - { - description: - "Removes a tag from a contact. May trigger any sequence enrolled on tag-removed.", - inputSchema: { contactId: z.string(), tag: z.string() }, - outputSchema: contactSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const contact = await removeTagFromContact(teamId, args.contactId, args.tag); - if (!contact) return NOT_FOUND; - return jsonResult(omitInternal(contact)); - } catch { - return INTERNAL_ERROR; - } - }, - ); + dedicatedTool({ + name: "add_contact_tag", + operationId: "contacts.addTag", + risk: "write", + description: + "Adds a tag to a contact. May trigger any sequence enrolled on tag-added.", + inputSchema: z.object({ contactId: z.string(), tag: z.string() }), + outputSchema: contactSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const contact = await addTagToContact(context.teamId, args.contactId, args.tag); + if (!contact) return notFound(); + return omitInternal(contact); + }, + }), - server.registerTool( - "delete_contact", - { - description: "Permanently deletes a contact. This action cannot be undone.", - inputSchema: { contactId: z.string() }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - await deleteContact(teamId, args.contactId, getAuthUser(extra)?.id ?? null); - return jsonResult({ message: "Contact deleted." }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "preview_delete_contacts", - { - description: - "Counts the contacts delete_contacts would remove, and how many of them are unsubscribed, without deleting anything. Select either contactIds or match (q, segmentId and filter, combined with AND; {} is every contact). Pass the count to delete_contacts as expectedCount.", - inputSchema: contactSelectionObjectSchema.shape, - outputSchema: z.object({ - count: z.number().int(), - unsubscribed: z.number().int(), - }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const parsed = contactSelectionSchema.safeParse(args); - if (!parsed.success) - return errorResult("Pass exactly one of contactIds or match."); - try { - const resolved = await selectionCondition(teamId, parsed.data); - if ("error" in resolved) { - return resolved.error === "segment_not_found" - ? NOT_FOUND - : errorResult(resolved.error); - } - return jsonResult(await countSelection(resolved.condition)); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "delete_contacts", - { - description: - "Permanently deletes contacts in bulk: up to 1000 contactIds, or every contact matching match (q, segmentId, filter; {} is every contact). Call preview_delete_contacts first and confirm the count with the user; with match, expectedCount is required and nothing is deleted if the live count differs (count_changed). Up to 1000 contacts are deleted immediately; larger selections run in the background — poll get_contact_deletion with the returned deletionId. Deleting doesn't trigger sequences. Unsubscribed contacts' addresses are added to the suppression list so they are never mailed again. Delivery logs and suppressions keep the address, so this is not a full privacy erasure. Cannot be undone.", - inputSchema: deleteContactsObjectSchema.shape, - outputSchema: contactDeletionSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const parsed = deleteContactsBodySchema.safeParse(args); - if (!parsed.success) { - return errorResult( - parsed.error.issues.map((issue) => issue.message).join("; "), - ); - } - const { expectedCount, ...selection } = parsed.data; - try { - const result = await startContactDeletion({ - teamId, - selection, - expectedCount, - actorUserId: getAuthUser(extra)?.id ?? null, - }); - if (result.status === 404) return NOT_FOUND; - if (result.status === 400 || result.status === 409) { - return { - ...errorResult(JSON.stringify(result.body)), - structuredContent: result.body, - }; - } - return jsonResult(result.body); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "get_contact_deletion", - { - description: - "Returns the progress of a background delete_contacts run: status (queued, running, completed, failed), count, deleted and suppressed. Kept for 24 hours after it finishes.", - inputSchema: { - deletionId: z.string().describe("deletionId from delete_contacts"), - }, - outputSchema: contactDeletionSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const deletion = await getContactDeletion(teamId, args.deletionId); - if (!deletion) return NOT_FOUND; - return jsonResult(deletion); - } catch { - return INTERNAL_ERROR; - } - }, - ); -} + dedicatedTool({ + name: "remove_contact_tag", + operationId: "contacts.removeTag", + risk: "write", + description: + "Removes a tag from a contact. May trigger any sequence enrolled on tag-removed.", + inputSchema: z.object({ contactId: z.string(), tag: z.string() }), + outputSchema: contactSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const contact = await removeTagFromContact( + context.teamId, + args.contactId, + args.tag, + ); + if (!contact) return notFound(); + return omitInternal(contact); + }, + }), +]; diff --git a/apps/api/src/mcp/tools/delivery-feedback.test.ts b/apps/api/src/mcp/tools/delivery-feedback.test.ts index e60287ce..575728df 100644 --- a/apps/api/src/mcp/tools/delivery-feedback.test.ts +++ b/apps/api/src/mcp/tools/delivery-feedback.test.ts @@ -3,265 +3,126 @@ import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; const mocks = { getEspConfigByEspId: vi.fn(), listEspConfigs: vi.fn(), - getFeedbackConnectionForTeamEsp: vi.fn(), - upsertFeedbackConnection: vi.fn(), - feedbackConnectionReadinessError: vi.fn(), - recordFeedbackConnectionVerified: vi.fn(), - disableFeedbackConnection: vi.fn(), listDeliveryEvents: vi.fn(), countDeliveryEvents: vi.fn(), - getDeliveryEventByEventId: vi.fn(), getOutboundMessagesByIds: vi.fn(), - listSuppressions: vi.fn(), - countSuppressions: vi.fn(), - getSuppressionBySuppressionId: vi.fn(), - releaseSuppression: vi.fn(), }; mock.module("../../settings/esp/queries", () => ({ - checkAndRememberDomainAuthentication: vi.fn(), getEspConfigByEspId: mocks.getEspConfigByEspId, listEspConfigs: mocks.listEspConfigs, })); -mock.module("../../delivery-feedback/feedback-connection-queries", () => ({ - getFeedbackConnectionForTeamEsp: mocks.getFeedbackConnectionForTeamEsp, - upsertFeedbackConnection: mocks.upsertFeedbackConnection, - feedbackConnectionReadinessError: mocks.feedbackConnectionReadinessError, - recordFeedbackConnectionVerified: mocks.recordFeedbackConnectionVerified, - disableFeedbackConnection: mocks.disableFeedbackConnection, -})); mock.module("../../delivery-feedback/delivery-event-queries", () => ({ listDeliveryEvents: mocks.listDeliveryEvents, countDeliveryEvents: mocks.countDeliveryEvents, - getDeliveryEventByEventId: mocks.getDeliveryEventByEventId, })); mock.module("../../delivery-feedback/outbound-queries", () => ({ getOutboundMessagesByIds: mocks.getOutboundMessagesByIds, })); -mock.module("../../delivery-feedback/suppression-queries", () => ({ - addOrStrengthenSuppression: vi.fn(), - listSuppressions: mocks.listSuppressions, - countSuppressions: mocks.countSuppressions, - getSuppressionBySuppressionId: mocks.getSuppressionBySuppressionId, - releaseSuppression: mocks.releaseSuppression, -})); - -const { registerDeliveryFeedbackTools } = await import("./delivery-feedback"); -const { AUTH_ERROR, NOT_FOUND } = await import("./responses"); - -type Tool = { handler: (args: any, extra?: any) => Promise }; -function makeToolRegistry() { - const tools = new Map(); - registerDeliveryFeedbackTools({ - registerTool: (name: string, _config: any, handler: Tool["handler"]) => { - tools.set(name, { handler }); - }, - } as any); - return tools; -} - -const auth: any = { - http: { - authInfo: { - token: "test-token", - clientId: "test-client", - scopes: [], - extra: { - authKind: "oauth", - teamId: "team-1", - user: { id: "user-1" }, - }, - }, - }, +const { deliveryEventTools } = await import("./delivery-feedback"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", + clientIp: "", + replayHeaders: {}, }; +const [tool] = deliveryEventTools(); +function call(args: unknown): Promise { + return tool!.handler({ context, args, signal }); +} beforeEach(() => { - for (const mock of Object.values(mocks)) mock.mockReset(); + for (const fn of Object.values(mocks)) fn.mockReset(); + mocks.listEspConfigs.mockResolvedValue([{ id: "esp-internal", espId: "esp_1" }]); mocks.getOutboundMessagesByIds.mockResolvedValue(new Map()); - mocks.listEspConfigs.mockResolvedValue([]); }); -describe("MCP delivery-feedback tools", () => { - it("requires auth on every tool", async () => { - const tools = makeToolRegistry(); - await expect( - tools.get("get_esp_feedback_connection")!.handler({ espId: "x" }, {}), - ).resolves.toEqual(AUTH_ERROR); - await expect(tools.get("list_suppressions")!.handler({}, {})).resolves.toEqual( - AUTH_ERROR, - ); - }); - - it("rejects configuring feedback for a provider without a reviewed adapter", async () => { - const tools = makeToolRegistry(); - mocks.getEspConfigByEspId.mockResolvedValue({ - id: "esp-internal-1", - espId: "esp_1", - provider: "smtp", - }); - - const result = await tools - .get("upsert_esp_feedback_connection")! - .handler({ espId: "esp_1", credential: "secret" }, auth); - - expect(result.isError).toBe(true); - expect(mocks.upsertFeedbackConnection).not.toHaveBeenCalled(); +describe("MCP list_delivery_events", () => { + it("is the only dedicated delivery-feedback tool", () => { + expect(deliveryEventTools().map((t) => t.name)).toEqual(["list_delivery_events"]); + expect(tool!.risk).toBe("read"); }); - it("requires an SNS topic ARN instead of a credential for SES", async () => { - const tools = makeToolRegistry(); - mocks.getEspConfigByEspId.mockResolvedValue({ - id: "esp-internal-1", - espId: "esp_1", - provider: "ses", - }); - - const missing = await tools - .get("upsert_esp_feedback_connection")! - .handler({ espId: "esp_1", credential: "secret" }, auth); - expect(missing.isError).toBe(true); - expect(mocks.upsertFeedbackConnection).not.toHaveBeenCalled(); - - mocks.upsertFeedbackConnection.mockResolvedValue({ - id: "conn-1", - connectionId: "whc_ses", - provider: "ses", - encryptedCredentials: null, - expectedTopicArn: "arn:aws:sns:us-east-1:123456789012:feedback", - subscriptionConfirmedAt: null, - status: "pending", - lastReceivedAt: null, - lastVerifiedAt: null, - lastErrorCode: null, - }); - const created = await tools.get("upsert_esp_feedback_connection")!.handler( + it("lists the team's events with the ESP and message of each", async () => { + const occurredAt = new Date("2026-10-01T00:00:00.000Z"); + mocks.listDeliveryEvents.mockResolvedValue([ { - espId: "esp_1", - expectedTopicArn: "arn:aws:sns:us-east-1:123456789012:feedback", - }, - auth, - ); - expect(created.isError).toBeFalsy(); - expect(mocks.upsertFeedbackConnection).toHaveBeenCalledWith( - expect.objectContaining({ + eventId: "dev_1", provider: "ses", - expectedTopicArn: "arn:aws:sns:us-east-1:123456789012:feedback", - }), + outboundMessageId: "out-1", + recipientEmail: "a@example.com", + eventType: "hard_bounce", + bounceClass: "permanent", + reason: "mailbox missing", + occurredAt, + receivedAt: occurredAt, + }, + ]); + mocks.countDeliveryEvents.mockResolvedValue(1); + mocks.getOutboundMessagesByIds.mockResolvedValue( + new Map([ + [ + "out-1", + { + deliverySourceType: "team", + espConfigId: "esp-internal", + messageId: "", + }, + ], + ]), ); - }); - it("upserts a feedback connection for a capable provider", async () => { - const tools = makeToolRegistry(); - mocks.getEspConfigByEspId.mockResolvedValue({ - id: "esp-internal-1", - espId: "esp_1", - provider: "postmark", + const result = await call({ + eventType: "hard_bounce", + offset: 2, + itemsPerPage: 10, }); - mocks.upsertFeedbackConnection.mockResolvedValue({ - id: "conn-1", - connectionId: "whc_abc", - provider: "postmark", - encryptedCredentials: "enc", - status: "pending", - lastReceivedAt: null, - lastVerifiedAt: null, - lastErrorCode: null, + expect(result).toEqual({ + items: [ + expect.objectContaining({ + eventId: "dev_1", + espId: "esp_1", + deliverySourceType: "team", + messageId: "", + occurredAt: "2026-10-01T00:00:00.000Z", + }), + ], + total: 1, }); - - const result = await tools - .get("upsert_esp_feedback_connection")! - .handler({ espId: "esp_1", credential: "top-secret" }, auth); - - expect(mocks.upsertFeedbackConnection).toHaveBeenCalledWith( + expect(mocks.listDeliveryEvents).toHaveBeenCalledWith( expect.objectContaining({ teamId: "team-1", - espConfigId: "esp-internal-1", - provider: "postmark", - credential: "top-secret", + eventType: "hard_bounce", + offset: 2, + rowsPerPage: 10, }), ); - expect(result.structuredContent).toMatchObject({ - connectionId: "whc_abc", - espId: "esp_1", - hasCredential: true, - }); - }); - - it("returns not-found for an unknown espId on get", async () => { - const tools = makeToolRegistry(); - mocks.getEspConfigByEspId.mockResolvedValue(null); - - const result = await tools - .get("get_esp_feedback_connection")! - .handler({ espId: "esp_missing" }, auth); - - expect(result).toEqual(NOT_FOUND); - }); - - it("blocks a workspace user from releasing a complaint suppression", async () => { - const tools = makeToolRegistry(); - mocks.releaseSuppression.mockRejectedValue(new Error("suppression_not_releasable")); - - const result = await tools - .get("release_suppression")! - .handler({ suppressionId: "sup_1" }, auth); - - expect(mocks.releaseSuppression).toHaveBeenCalledWith( - expect.objectContaining({ - teamId: "team-1", - suppressionId: "sup_1", - actorType: "workspace_user", - actorUserId: "user-1", - }), + expect(mocks.countDeliveryEvents).toHaveBeenCalledWith( + expect.not.objectContaining({ offset: 2 }), ); - expect(result.isError).toBe(true); }); - it("releases an eligible suppression", async () => { - const tools = makeToolRegistry(); - mocks.releaseSuppression.mockResolvedValue({ - suppressionId: "sup_1", - recipientEmail: "ada@example.com", - reason: "hard_bounce", - active: false, - firstSuppressedAt: new Date(), - lastSuppressedAt: new Date(), - releasedAt: new Date(), - releaseReason: "mailbox fixed", - }); - - const result = await tools - .get("release_suppression")! - .handler({ suppressionId: "sup_1", explanation: "mailbox fixed" }, auth); - - expect(result.structuredContent).toMatchObject({ - suppressionId: "sup_1", - active: false, + it("filters by an ESP of the team, and returns nothing for an unknown one", async () => { + mocks.getEspConfigByEspId.mockResolvedValue(null); + await expect(call({ espId: "esp_other" })).resolves.toEqual({ + items: [], + total: 0, }); - }); - - it("lists suppressions scoped to the team", async () => { - const tools = makeToolRegistry(); - mocks.listSuppressions.mockResolvedValue([ - { - suppressionId: "sup_1", - recipientEmail: "ada@example.com", - reason: "hard_bounce", - active: true, - firstSuppressedAt: new Date(), - lastSuppressedAt: new Date(), - releasedAt: null, - releaseReason: null, - }, - ]); - mocks.countSuppressions.mockResolvedValue(1); - - const result = await tools.get("list_suppressions")!.handler({}, auth); - - expect(mocks.listSuppressions).toHaveBeenCalledWith( - expect.objectContaining({ teamId: "team-1" }), + expect(mocks.getEspConfigByEspId).toHaveBeenCalledWith("team-1", "esp_other"); + expect(mocks.listDeliveryEvents).not.toHaveBeenCalled(); + + mocks.getEspConfigByEspId.mockResolvedValue({ id: "esp-internal" }); + mocks.listDeliveryEvents.mockResolvedValue([]); + mocks.countDeliveryEvents.mockResolvedValue(0); + await call({ espId: "esp_1" }); + expect(mocks.listDeliveryEvents).toHaveBeenCalledWith( + expect.objectContaining({ espConfigId: "esp-internal" }), ); - expect(result.structuredContent).toMatchObject({ total: 1 }); }); }); diff --git a/apps/api/src/mcp/tools/delivery-feedback.ts b/apps/api/src/mcp/tools/delivery-feedback.ts index 15974d1a..8f514f49 100644 --- a/apps/api/src/mcp/tools/delivery-feedback.ts +++ b/apps/api/src/mcp/tools/delivery-feedback.ts @@ -1,62 +1,14 @@ import { z } from "zod"; -import { feedbackCapableProviders as feedbackCapableProvidersConst } from "../../config/constants"; import { countDeliveryEvents, type DeliveryEvent, - getDeliveryEventByEventId, listDeliveryEvents, } from "../../delivery-feedback/delivery-event-queries"; -import { - disableFeedbackConnection, - feedbackConnectionReadinessError, - getFeedbackConnectionForTeamEsp, - recordFeedbackConnectionVerified, - upsertFeedbackConnection, -} from "../../delivery-feedback/feedback-connection-queries"; -import { - feedbackConnectionInputError, - toPublicFeedbackConnection, -} from "../../delivery-feedback/feedback-connection-shape"; import { getOutboundMessagesByIds } from "../../delivery-feedback/outbound-queries"; -import { - addOrStrengthenSuppression, - countSuppressions, - getSuppressionBySuppressionId, - listSuppressions, - releaseSuppression, - type Suppression, -} from "../../delivery-feedback/suppression-queries"; import { getEspConfigByEspId, listEspConfigs } from "../../settings/esp/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getAuthUser, getTeamId } from "./auth"; -import { AUTH_ERROR, errorResult, jsonResult, NOT_FOUND } from "./responses"; -import { - deliveryEventListSchema, - deliveryEventSchema, - feedbackConnectionSchema, - successMessageSchema, - suppressionListSchema, - suppressionSchema, - testEspResultSchema, -} from "./schemas"; - -function toPublicSuppression(suppression: Suppression) { - return { - suppressionId: suppression.suppressionId, - recipientEmail: suppression.recipientEmail, - reason: suppression.reason as - | "hard_bounce" - | "complaint" - | "repeated_soft_bounce" - | "provider_suppression" - | "manual", - active: suppression.active, - firstSuppressedAt: suppression.firstSuppressedAt, - lastSuppressedAt: suppression.lastSuppressedAt, - releasedAt: suppression.releasedAt, - releaseReason: suppression.releaseReason, - }; -} +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { deliveryEventListSchema } from "./schemas"; async function toPublicEvents(teamId: string, events: DeliveryEvent[]) { const [esps, outboundById] = await Promise.all([ @@ -91,205 +43,55 @@ async function toPublicEvents(teamId: string, events: DeliveryEvent[]) { } /** - * MCP tools for `docs/bounces-and-complaints.md` — mirrors the - * `/settings/esps/:espId/feedback`, `/delivery-events`, and `/suppressions` - * REST resources so MCP clients get the same collection-aware, tenant-scoped - * behavior. Only providers with a reviewed adapter - * (`feedbackCapableProviders`) can be configured. + * The delivery-events side of `docs/bounces-and-complaints.md`, mirroring + * `GET /delivery-events`. Feedback connections and suppressions are reachable + * through the generic `api.*` tools. */ -export function registerDeliveryFeedbackTools(server: McpToolRegistrar): void { - server.registerTool( - "get_esp_feedback_connection", - { - description: - "Returns a user ESP's bounce/complaint webhook connection (URL, status, health), or null if not configured yet. Never returns the credential.", - inputSchema: { espId: z.string().min(1) }, - outputSchema: z.object({ - connection: feedbackConnectionSchema.nullable(), - }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const esp = await getEspConfigByEspId(teamId, args.espId); - if (!esp) return NOT_FOUND; - const connection = await getFeedbackConnectionForTeamEsp(teamId, esp.id); - return jsonResult({ - connection: connection - ? toPublicFeedbackConnection(esp.espId, connection) - : null, - }); - }, - ); - - server.registerTool( - "upsert_esp_feedback_connection", - { - description: - "Creates a user ESP's feedback connection (first call) or rotates its credential (later calls) — the webhook URL stays stable across rotation. The provider is always the ESP's current provider (must be one of: " + - feedbackCapableProvidersConst.join(", ") + - "). Amazon SES takes `expectedTopicArn` (the SNS topic subscribed to the returned webhook URL) instead of a credential; every other provider requires `credential`.", - inputSchema: { - espId: z.string().min(1), - credential: z - .string() - .min(1) - .optional() - .describe( - "The provider's webhook signing secret / shared header value. Required for every provider except SES.", - ), - expectedTopicArn: z - .string() - .optional() - .describe( - "SES only, required: the ARN of the SNS topic SES publishes bounce/complaint events to", - ), - }, - outputSchema: feedbackConnectionSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const esp = await getEspConfigByEspId(teamId, args.espId); - if (!esp) return NOT_FOUND; - if ( - !feedbackCapableProvidersConst.includes( - esp.provider as (typeof feedbackCapableProvidersConst)[number], - ) - ) { - return errorResult( - `Provider "${esp.provider}" does not have a reviewed feedback adapter yet.`, - ); - } - const inputError = feedbackConnectionInputError(esp.provider, args); - if (inputError) return errorResult(inputError); - const connection = await upsertFeedbackConnection({ - teamId, - espConfigId: esp.id, - provider: esp.provider as (typeof feedbackCapableProvidersConst)[number], - credential: args.credential, - expectedTopicArn: args.expectedTopicArn, - }); - return jsonResult(toPublicFeedbackConnection(esp.espId, connection)); - }, - ); - - server.registerTool( - "test_esp_feedback_connection", - { - description: - "Checks a feedback connection is fully armed (a smoke test — it does not call the provider): the stored credential decrypts, or for SES, the SNS subscription has been confirmed.", - inputSchema: { espId: z.string().min(1) }, - outputSchema: testEspResultSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const esp = await getEspConfigByEspId(teamId, args.espId); - if (!esp) return NOT_FOUND; - const connection = await getFeedbackConnectionForTeamEsp(teamId, esp.id); - if (!connection) { - return jsonResult({ - success: false, - error: "feedback_not_configured", - }); - } - const readinessError = feedbackConnectionReadinessError(connection); - if (readinessError) { - return jsonResult({ success: false, error: readinessError }); - } - await recordFeedbackConnectionVerified(connection.id); - return jsonResult({ success: true }); - }, - ); - - server.registerTool( - "delete_esp_feedback_connection", - { - description: "Disables a user ESP's feedback connection.", - inputSchema: { espId: z.string().min(1) }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const esp = await getEspConfigByEspId(teamId, args.espId); - if (!esp) return NOT_FOUND; - const disabled = await disableFeedbackConnection(teamId, esp.id); - if (!disabled) return NOT_FOUND; - return jsonResult({ message: "Feedback connection disabled." }); - }, - ); - - server.registerTool( - "list_delivery_events", - { - description: - "Returns a paginated list of normalized bounce/complaint/delivery events for the team, most recent first.", - inputSchema: { - espId: z.string().min(1).optional(), - deliverySourceType: z.enum(["organization", "team"]).optional(), - eventType: z - .enum([ - "accepted", - "delivered", - "delayed", - "soft_bounce", - "hard_bounce", - "failed", - "complaint", - "suppressed", - "rejected", - "unknown", - ]) - .optional(), - createdAfter: z - .number() - .int() - .optional() - .describe("Millisecond timestamp lower bound (inclusive)"), - createdBefore: z - .number() - .int() - .optional() - .describe("Millisecond timestamp upper bound (exclusive)"), - offset: z.number().int().min(1).optional(), - itemsPerPage: z.number().int().min(1).optional(), - }, - outputSchema: deliveryEventListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; +export const deliveryEventTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_delivery_events", + operationId: "deliveryEvents.list", + risk: "read", + description: + "Returns a paginated list of normalized bounce/complaint/delivery events for the team, most recent first.", + inputSchema: z.object({ + espId: z.string().min(1).optional(), + deliverySourceType: z.enum(["organization", "team"]).optional(), + eventType: z + .enum([ + "accepted", + "delivered", + "delayed", + "soft_bounce", + "hard_bounce", + "failed", + "complaint", + "suppressed", + "rejected", + "unknown", + ]) + .optional(), + createdAfter: z + .number() + .int() + .optional() + .describe("Millisecond timestamp lower bound (inclusive)"), + createdBefore: z + .number() + .int() + .optional() + .describe("Millisecond timestamp upper bound (exclusive)"), + offset: z.number().int().min(1).optional(), + itemsPerPage: z.number().int().min(1).optional(), + }), + outputSchema: deliveryEventListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const teamId = context.teamId; let espConfigId: string | undefined; if (args.espId) { const esp = await getEspConfigByEspId(teamId, args.espId); - if (!esp) return jsonResult({ items: [], total: 0 }); + if (!esp) return { items: [], total: 0 }; espConfigId = esp.id; } const filters = { @@ -308,177 +110,7 @@ export function registerDeliveryFeedbackTools(server: McpToolRegistrar): void { }), countDeliveryEvents(filters), ]); - return jsonResult({ - items: await toPublicEvents(teamId, events), - total, - }); - }, - ); - - server.registerTool( - "get_delivery_event", - { - description: "Returns a single normalized delivery event by its ID.", - inputSchema: { eventId: z.string() }, - outputSchema: deliveryEventSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, + return { items: await toPublicEvents(teamId, events), total }; }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const event = await getDeliveryEventByEventId(teamId, args.eventId); - if (!event) return NOT_FOUND; - const [publicEvent] = await toPublicEvents(teamId, [event]); - return jsonResult(publicEvent); - }, - ); - - server.registerTool( - "list_suppressions", - { - description: - "Returns a paginated list of the team's suppressed (do-not-send) recipients.", - inputSchema: { - active: z.boolean().optional(), - reason: z - .enum([ - "hard_bounce", - "complaint", - "repeated_soft_bounce", - "provider_suppression", - "manual", - ]) - .optional(), - offset: z.number().int().min(1).optional(), - itemsPerPage: z.number().int().min(1).optional(), - }, - outputSchema: suppressionListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const filters = { - teamId, - active: args.active, - reason: args.reason, - }; - const [items, total] = await Promise.all([ - listSuppressions({ - ...filters, - offset: args.offset, - rowsPerPage: args.itemsPerPage, - }), - countSuppressions(filters), - ]); - return jsonResult({ - items: items.map(toPublicSuppression), - total, - }); - }, - ); - - server.registerTool( - "get_suppression", - { - description: "Returns a single suppression by its ID.", - inputSchema: { suppressionId: z.string() }, - outputSchema: suppressionSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const suppression = await getSuppressionBySuppressionId( - teamId, - args.suppressionId, - ); - if (!suppression) return NOT_FOUND; - return jsonResult(toPublicSuppression(suppression)); - }, - ); - - server.registerTool( - "release_suppression", - { - description: - "Releases an active suppression. Only hard_bounce, repeated_soft_bounce, and manual suppressions can be released this way — complaint suppressions require a SendLit operator and are never releasable through this tool.", - inputSchema: { - suppressionId: z.string(), - explanation: z.string().min(1).max(2000).optional(), - }, - outputSchema: suppressionSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const suppression = await releaseSuppression({ - teamId, - suppressionId: args.suppressionId, - actorType: "workspace_user", - actorUserId: getAuthUser(extra)?.id ?? null, - explanation: args.explanation, - }); - return jsonResult(toPublicSuppression(suppression)); - } catch (err: any) { - if (err.message === "suppression_not_found") return NOT_FOUND; - if (err.message === "suppression_not_releasable") { - return errorResult( - "This suppression cannot be released by a workspace user (e.g. a complaint suppression) — it requires a SendLit operator.", - ); - } - throw err; - } - }, - ); - - server.registerTool( - "add_suppression", - { - description: - "Suppresses an address for the team (reason `manual`): SendLit never mails it again (broadcasts, sequences or transactional) until released. For role accounts, legal requests or known spam traps.", - inputSchema: { - email: z.string().email(), - note: z.string().max(2000).optional().describe("Why; kept in the audit trail"), - }, - outputSchema: suppressionSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const suppression = await addOrStrengthenSuppression({ - teamId, - recipientEmail: args.email, - reason: "manual", - actorType: "workspace_user", - actorUserId: getAuthUser(extra)?.id ?? null, - explanation: args.note ?? null, - }); - return jsonResult(toPublicSuppression(suppression)); - }, - ); -} + }), +]; diff --git a/apps/api/src/mcp/tools/esp.ts b/apps/api/src/mcp/tools/esp.ts deleted file mode 100644 index 18faec4c..00000000 --- a/apps/api/src/mcp/tools/esp.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { - domainAuthenticationQuerySchema, - domainAuthenticationSchema, - upsertEspConfigBodySchema, -} from "@sendlit/api-contract"; -import { z } from "zod"; -import { invalidateEspTransport, invalidateTeamTransport } from "../../mail/transport"; -import { warmupState } from "../../mail/warmup"; -import { espDeleteBlockerMessage } from "../../settings/esp/delete-blockers"; -import { - checkAndRememberDomainAuthentication, - createEspConfig, - deleteEspConfig, - type EspConfig, - getEspConfig, - getEspConfigByEspId, - listEspConfigs, - transitionEspConfig, - updateEspConfig, - upsertEspConfig, -} from "../../settings/esp/queries"; -import { testEspConfig } from "../../settings/esp/test"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getAuthUser, getTeamId } from "./auth"; -import { AUTH_ERROR, errorResult, jsonResult, NOT_FOUND } from "./responses"; -import { - espConfigListSchema, - espConfigSchema, - espProviders, - successMessageSchema, - testEspResultSchema, -} from "./schemas"; - -function toPublicShape(config: EspConfig | null) { - if (!config) return null; - return { - espId: config.espId, - name: config.name, - provider: config.provider, - host: config.host, - port: config.port, - secure: config.secure, - username: config.username, - hasPassword: Boolean(config.encryptedSecret), - fromName: config.fromName, - fromEmail: config.fromEmail, - replyTo: config.replyTo, - maxSendRate: config.maxSendRate, - dailySendLimit: config.dailySendLimit, - warmup: warmupState(config), - status: config.status, - secretVersion: config.secretVersion, - lastTestedAt: config.lastTestedAt, - lastTestStatus: config.lastTestStatus, - lastTestError: config.lastTestError, - }; -} - -const connectionFields = { - provider: z - .enum(espProviders) - .describe("A label for which ESP this is — the transport is always SMTP"), - host: z.string().min(1).describe("SMTP host, e.g. smtp.sendgrid.net"), - port: z.number().int().min(1).max(65535).describe("SMTP port, e.g. 587"), - secure: z.boolean().describe("Use implicit TLS (usually true only for port 465)"), - username: z.string().optional(), - password: z - .string() - .optional() - .describe("Omit to keep unchanged; empty string clears it"), - fromName: z.string().optional(), - fromEmail: z.string().email().optional(), - replyTo: z - .union([z.string().email(), z.literal("")]) - .optional() - .describe("Reply-To for mail sent through this ESP; empty string clears it"), - maxSendRate: z - .number() - .positive() - .max(10_000) - .nullable() - .optional() - .describe( - "Messages per second across all workers; null uses the provider default (SES 14, Resend 2, others unlimited). Set SES to your account's MaxSendRate.", - ), - dailySendLimit: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe( - "Most broadcast/sequence emails per UTC day through this ESP, e.g. a warm-up ramp; emails over it wait for the next day. null removes the cap.", - ), - warmupTargetDailyVolume: upsertEspConfigBodySchema.shape.warmupTargetDailyVolume, -}; - -export function registerEspTools(server: McpToolRegistrar): void { - server.registerTool( - "get_esp_config", - { - description: - "Returns the team's configured email sending provider (ESP/SMTP), or null if none is configured yet. Teams without ESP cannot send campaign mail. Never includes the password.", - outputSchema: z.object({ config: espConfigSchema.nullable() }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const config = await getEspConfig(teamId); - return jsonResult({ config: toPublicShape(config) }); - }, - ); - - server.registerTool( - "update_esp_config", - { - description: - "Creates or updates the team's *default* ESP (SMTP) configuration — an alias over the first/default row in the team's ESP collection (see `create_esp`/`update_esp` for named, non-default configurations). Works with any provider that exposes an SMTP relay (SendGrid, Mailgun, Postmark, SES, Resend, or a custom/self-hosted SMTP server). Omit `password` to keep the existing secret; send an empty string to clear it.", - inputSchema: connectionFields, - outputSchema: espConfigSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const config = await upsertEspConfig(teamId, args); - invalidateTeamTransport(teamId); - return jsonResult(toPublicShape(config)); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); - - server.registerTool( - "delete_esp_config", - { - description: - "Legacy: removes the team's first ESP configuration. A live team default can't be removed (make another source the default, or retire it with retire_esp, first), and an ESP with delivery history or queued work is never deleted.", - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - await deleteEspConfig(teamId); - } catch (err: any) { - return errorResult(espDeleteBlockerMessage(err.message) ?? err.message); - } - invalidateTeamTransport(teamId); - return jsonResult({ message: "ESP configuration removed." }); - }, - ); - - server.registerTool( - "send_test_email", - { - description: - "Sends a test email through the team's default ESP to verify it works. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", - inputSchema: { - to: z.string().email().optional(), - }, - outputSchema: testEspResultSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: true, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - - const config = await getEspConfig(teamId); - if (!config) { - return jsonResult({ - success: false, - error: "No ESP configured for this team yet.", - }); - } - - const result = await testEspConfig({ - config, - to: args.to, - account: getAuthUser(extra), - source: "mcp.send_test_email", - }); - return jsonResult({ - success: result.success, - error: result.error, - }); - }, - ); - - server.registerTool( - "list_esps", - { - description: - "Lists every user-managed ESP configuration the team has created. Never includes passwords.", - outputSchema: espConfigListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const configs = await listEspConfigs(teamId); - return jsonResult({ items: configs.map(toPublicShape) }); - }, - ); - - server.registerTool( - "create_esp", - { - description: - "Creates a new team-owned ESP configuration. Delivery selection is managed separately.", - inputSchema: { - name: z.string().trim().min(1).max(100), - ...connectionFields, - }, - outputSchema: espConfigSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const config = await createEspConfig(teamId, args); - invalidateTeamTransport(teamId); - return jsonResult(toPublicShape(config)); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); - - server.registerTool( - "get_esp", - { - description: "Returns a single user-managed ESP configuration by its ESP ID.", - inputSchema: { espId: z.string().min(1) }, - outputSchema: espConfigSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const config = await getEspConfigByEspId(teamId, args.espId); - if (!config) return NOT_FOUND; - return jsonResult(toPublicShape(config)); - }, - ); - - server.registerTool( - "check_esp_domain_authentication", - { - description: - "Checks SPF, DKIM and DMARC for the domain of an ESP's From address (live DNS lookups) and returns each check's status, plus the DNS records to add. Records whose value only the provider's console knows (e.g. DKIM keys) have a null value. Pass dkimSelector if the provider uses a custom DKIM selector (for Amazon SES, one Easy DKIM token); a selector that passes is saved on the ESP and reused by later checks.", - inputSchema: { - espId: z.string().min(1), - dkimSelector: domainAuthenticationQuerySchema.shape.dkimSelector, - }, - outputSchema: domainAuthenticationSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: true, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const config = await getEspConfigByEspId(teamId, args.espId); - if (!config) return NOT_FOUND; - const result = await checkAndRememberDomainAuthentication( - config, - args.dkimSelector, - ); - if (!result) - return errorResult("from_email_missing: set the ESP's From address first"); - return jsonResult(result); - }, - ); - - server.registerTool( - "update_esp", - { - description: - "Updates a team-owned ESP configuration by its ESP ID. Omit `password` to keep the existing secret; send an empty string to clear it.", - inputSchema: { - espId: z.string().min(1), - name: z.string().trim().min(1).max(100).optional(), - provider: connectionFields.provider.optional(), - host: connectionFields.host.optional(), - port: connectionFields.port.optional(), - secure: connectionFields.secure.optional(), - username: connectionFields.username, - password: connectionFields.password, - fromName: connectionFields.fromName, - fromEmail: connectionFields.fromEmail, - replyTo: connectionFields.replyTo, - maxSendRate: connectionFields.maxSendRate, - dailySendLimit: connectionFields.dailySendLimit, - warmupTargetDailyVolume: connectionFields.warmupTargetDailyVolume, - }, - outputSchema: espConfigSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const { espId, ...patch } = args; - try { - const config = await updateEspConfig(teamId, espId, patch); - if (!config) return NOT_FOUND; - invalidateEspTransport(teamId, config.id); - return jsonResult(toPublicShape(config)); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); - - server.registerTool( - "delete_esp", - { - description: - "Deletes an ESP that has never carried delivery history. Blocked when it is the team's live default, is used by a sending sequence or broadcast or by queued email, or has transactional email, delivery feedback or grants (retire it with retire_esp instead). Draft, paused and completed sequences are detached first; sent-mail history is kept.", - inputSchema: { espId: z.string().min(1) }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const config = await getEspConfigByEspId(teamId, args.espId); - if (!config) return NOT_FOUND; - try { - await deleteEspConfig(teamId, args.espId); - } catch (err: any) { - return errorResult(espDeleteBlockerMessage(err.message) ?? err.message); - } - invalidateEspTransport(teamId, config.id); - return jsonResult({ message: "ESP configuration removed." }); - }, - ); - - server.registerTool( - "activate_esp", - { - description: - "Activates a verified team-owned ESP so it can be selected as the team's default or pinned for delivery. Run test_esp successfully after the latest credential or sender change before activating.", - inputSchema: { espId: z.string().min(1) }, - outputSchema: espConfigSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const config = await getEspConfigByEspId(teamId, args.espId); - if (!config) return NOT_FOUND; - try { - return jsonResult(toPublicShape(await transitionEspConfig(config, "activate"))); - } catch (err: any) { - return errorResult( - err.message === "esp_verification_required" - ? "ESP verification is required: send a successful test after its latest configuration change, and configure a sender email." - : err.message, - ); - } - }, - ); - - server.registerTool( - "retire_esp", - { - description: - "Retires a team-owned ESP (active, suspended or draining) so it no longer sends. This is how an ESP with delivery history is archived; its history stays intact. A retired ESP can be deleted with delete_esp only if it has no history.", - inputSchema: { espId: z.string().min(1) }, - outputSchema: espConfigSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const config = await getEspConfigByEspId(teamId, args.espId); - if (!config) return NOT_FOUND; - try { - const retired = await transitionEspConfig(config, "retire", { cancel: true }); - invalidateEspTransport(teamId, config.id); - return jsonResult(toPublicShape(retired)); - } catch { - return errorResult( - "Only an active, suspended or draining ESP can be retired. Delete a draft ESP instead.", - ); - } - }, - ); - - server.registerTool( - "test_esp", - { - description: - "Sends a test email through a user-managed ESP (by its ESP ID) to verify it works. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", - inputSchema: { - espId: z.string().min(1), - to: z.string().email().optional(), - }, - outputSchema: testEspResultSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: true, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const config = await getEspConfigByEspId(teamId, args.espId); - if (!config) return NOT_FOUND; - const result = await testEspConfig({ - config, - to: args.to, - account: getAuthUser(extra), - source: "mcp.test_esp", - }); - return jsonResult({ - success: result.success, - error: result.error, - }); - }, - ); -} diff --git a/apps/api/src/mcp/tools/events.test.ts b/apps/api/src/mcp/tools/events.test.ts new file mode 100644 index 00000000..dc02151e --- /dev/null +++ b/apps/api/src/mcp/tools/events.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +class FakeEventsError extends Error { + constructor(readonly code: string) { + super(code); + } +} +const trackEvent = vi.fn(); +mock.module("../../events/queries", () => ({ + EventsError: FakeEventsError, + trackEvent, +})); + +const { eventTools } = await import("./events"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", + clientIp: "", + replayHeaders: {}, +}; +const [tool] = eventTools(); +function call(args: unknown): Promise { + return tool!.handler({ context, args, signal }); +} + +beforeEach(() => trackEvent.mockReset()); + +describe("MCP track_event", () => { + it("is the only dedicated event tool", () => { + expect(eventTools().map((t) => t.name)).toEqual(["track_event"]); + expect(tool!.risk).toBe("write"); + }); + + it("tracks an event for the resolved team with source mcp", async () => { + trackEvent.mockResolvedValue({ + eventId: "evt_1", + contactId: "cnt_1", + duplicate: false, + contactCreated: true, + }); + await expect( + call({ + email: "a@example.com", + eventName: " course purchased ", + properties: { price: 49 }, + idempotencyKey: "order_1", + }), + ).resolves.toMatchObject({ eventId: "evt_1" }); + expect(trackEvent).toHaveBeenCalledWith({ + teamId: "team-1", + source: "mcp", + body: expect.objectContaining({ + email: "a@example.com", + eventName: "course purchased", + idempotencyKey: "order_1", + }), + }); + }); + + it("validates with the REST rules and maps expected errors", async () => { + const invalid = await call({ eventName: "course purchased" }); + expect(invalid.error.code).toBe("validation_failed"); + expect(invalid.error.safeDetails.reason).toBe("invalid_event"); + expect(invalid.error.safeDetails.message).toContain( + "exactly one of email or contactId", + ); + expect(trackEvent).not.toHaveBeenCalled(); + + trackEvent.mockRejectedValue(new FakeEventsError("contact_not_found")); + await expect( + call({ email: "a@example.com", eventName: "x", createContact: false }), + ).resolves.toMatchObject({ + error: { code: "not_found", safeDetails: { reason: "contact_not_found" } }, + }); + + trackEvent.mockRejectedValue(new FakeEventsError("event_name_limit_reached")); + await expect( + call({ email: "a@example.com", eventName: "x" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "event_name_limit_reached" }, + }, + }); + }); +}); diff --git a/apps/api/src/mcp/tools/events.ts b/apps/api/src/mcp/tools/events.ts index 37a65995..90d91797 100644 --- a/apps/api/src/mcp/tools/events.ts +++ b/apps/api/src/mcp/tools/events.ts @@ -1,193 +1,52 @@ import { - contactEventSchema, - createEventDefinitionBodySchema, - eventDefinitionDetailSchema, - eventDefinitionSchema, - eventNameSchema, - listContactEventsQuerySchema, trackEventBodySchema, trackEventFieldsSchema, trackEventResponseSchema, - updateEventDefinitionBodySchema, } from "@sendlit/api-contract"; -import { z } from "zod"; -import { getContactByContactId } from "../../contacts/queries"; -import { - createEventDefinition, - deleteEventDefinition, - EventsError, - getEventDefinition, - listContactEvents, - listEventDefinitions, - trackEvent, - updateEventDefinition, -} from "../../events/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { - AUTH_ERROR, - errorResult, - isPlanGateError, - jsonResult, - NOT_FOUND, - planGateResult, -} from "./responses"; +import { EventsError, trackEvent } from "../../events/queries"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { isPlanGateError, planGateError, toolError } from "./responses"; /** Custom events — the MCP side of `events/routes.ts`. See - * docs/custom_events.md. */ -export function registerEventTools(server: McpToolRegistrar): void { - server.registerTool( - "track_event", - { - description: - "Records a custom event (e.g. `course purchased`) for one contact, by email or contactId, and starts every active sequence whose trigger is event:custom with this name and whose filters match. Event properties render as {{ event. }} in that sequence's emails. An unknown email creates the contact unless createContact is false. The same idempotencyKey within 30 days returns the original event. backfill: true (or an occurredAt more than 72 hours old) stores the event without starting anything.", - inputSchema: trackEventFieldsSchema.shape, - outputSchema: trackEventResponseSchema, - annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + * docs/custom_events.md. Event definitions and a contact's event history + * are reachable through the generic `api.*` tools. */ +export const eventTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "track_event", + operationId: "events.track", + risk: "write", + description: + "Records a custom event (e.g. `course purchased`) for one contact, by email or contactId, and starts every active sequence whose trigger is event:custom with this name and whose filters match. Event properties render as {{ event. }} in that sequence's emails. An unknown email creates the contact unless createContact is false. The same idempotencyKey within 30 days returns the original event. backfill: true (or an occurredAt more than 72 hours old) stores the event without starting anything.", + inputSchema: trackEventFieldsSchema, + outputSchema: trackEventResponseSchema, + annotations: { openWorldHint: true }, + async handler({ context, args }) { + // The cross-field rules (exactly one of email or contactId, ...) live + // on the REST body schema, which is not a plain object schema. const parsed = trackEventBodySchema.safeParse(args); if (!parsed.success) { - return errorResult( - parsed.error.issues.map((issue) => issue.message).join("; "), - ); - } - try { - return jsonResult( - await trackEvent({ teamId, source: "mcp", body: parsed.data }), - ); - } catch (err: any) { - if (isPlanGateError(err)) return planGateResult(err); - if (err instanceof EventsError) return errorResult(err.code); - throw err; - } - }, - ); - - server.registerTool( - "list_event_definitions", - { - description: - "Lists the team's events: registered on first receipt or by hand, with their properties, when they were last received, and how many sequences use each.", - inputSchema: {}, - outputSchema: z.object({ items: z.array(eventDefinitionSchema) }), - annotations: { readOnlyHint: true, openWorldHint: false }, - }, - async (_args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return jsonResult({ items: await listEventDefinitions(teamId) }); - }, - ); - - server.registerTool( - "get_event_definition", - { - description: "Gets one event, including the properties of the last one received.", - inputSchema: { name: eventNameSchema }, - outputSchema: eventDefinitionDetailSchema, - annotations: { readOnlyHint: true, openWorldHint: false }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const row = await getEventDefinition(teamId, args.name); - return row ? jsonResult(row) : NOT_FOUND; - }, - ); - - server.registerTool( - "create_event_definition", - { - description: - "Registers an event before your app sends it, so sequences can use it as a trigger and its properties show up in the builder. Counts toward the team's 500-event limit.", - inputSchema: createEventDefinitionBodySchema.shape, - outputSchema: eventDefinitionDetailSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - return jsonResult(await createEventDefinition(teamId, args)); - } catch (err) { - if (err instanceof EventsError) return errorResult(err.code); - throw err; + return toolError("validation_failed", { + reason: "invalid_event", + message: parsed.error.issues.map((issue) => issue.message).join("; "), + }); } - }, - ); - - server.registerTool( - "update_event_definition", - { - description: - "Updates an event's description or declared properties. properties replaces the declared set; properties seen in received events stay.", - inputSchema: { name: eventNameSchema, ...updateEventDefinitionBodySchema.shape }, - outputSchema: eventDefinitionDetailSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const { name, ...patch } = args; - const row = await updateEventDefinition(teamId, name, patch); - return row ? jsonResult(row) : NOT_FOUND; - }, - ); - - server.registerTool( - "delete_event_definition", - { - description: - "Removes an event from the team's list. Stored events are kept, and a later event with the name registers it again. Fails with event_definition_in_use while a sequence uses it as its trigger.", - inputSchema: { name: eventNameSchema }, - outputSchema: z.object({ deleted: z.boolean() }), - annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; try { - const deleted = await deleteEventDefinition(teamId, args.name); - return deleted ? jsonResult({ deleted: true }) : NOT_FOUND; + return await trackEvent({ + teamId: context.teamId, + source: "mcp", + body: parsed.data, + }); } catch (err) { - if (err instanceof EventsError) return errorResult(err.code); + if (isPlanGateError(err)) return planGateError(err); + if (err instanceof EventsError) { + return toolError( + err.code === "contact_not_found" ? "not_found" : "validation_failed", + { reason: err.code }, + ); + } throw err; } }, - ); - - server.registerTool( - "get_contact_events", - { - description: - "Lists the custom events received for a contact, newest first, with the sequences each one started.", - inputSchema: { contactId: z.string(), ...listContactEventsQuerySchema.shape }, - outputSchema: z.object({ items: z.array(contactEventSchema), total: z.number() }), - annotations: { readOnlyHint: true, openWorldHint: false }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const contact = await getContactByContactId(args.contactId); - if (!contact || contact.teamId !== teamId) return NOT_FOUND; - return jsonResult( - await listContactEvents(contact.id, { - page: args.page, - limit: args.limit, - name: args.name, - }), - ); - }, - ); -} + }), +]; diff --git a/apps/api/src/mcp/tools/forms.ts b/apps/api/src/mcp/tools/forms.ts deleted file mode 100644 index e686a804..00000000 --- a/apps/api/src/mcp/tools/forms.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { createSignupFormBodySchema, signupFormSchema } from "@sendlit/api-contract"; -import { z } from "zod"; -import { - createForm, - deleteForm, - FormSenderError, - getForm, - listForms, - toPublicForms, - updateForm, -} from "../../forms/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { AUTH_ERROR, errorResult, jsonResult, NOT_FOUND } from "./responses"; - -/** Double opt-in needs a sender; explain instead of failing generically. */ -async function withSenderCheck(run: () => Promise) { - try { - return await run(); - } catch (error) { - if (error instanceof FormSenderError) { - return errorResult(`confirmation_sender_unavailable: ${error.issue}`); - } - throw error; - } -} - -export function registerFormTools(server: McpToolRegistrar): void { - server.registerTool( - "list_signup_forms", - { - description: "Lists the team's public signup forms with their hosted URLs.", - outputSchema: z.object({ items: z.array(signupFormSchema) }), - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return jsonResult({ - items: await toPublicForms(teamId, await listForms(teamId)), - }); - }, - ); - - server.registerTool( - "create_signup_form", - { - description: - "Creates a public signup form. People subscribe on its hosted page (`url`) or via an HTML form on any site posting `email` (and `name`) to that URL. Double opt-in (default) emails a confirmation link first; new subscribers get the form's tags and trigger subscriber:added sequences (e.g. a welcome email). Double opt-in needs a sending mailbox: without one this fails with confirmation_sender_unavailable. `sendingIssue` on a form says why its confirmations can't be sent.", - inputSchema: createSignupFormBodySchema.shape, - outputSchema: signupFormSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return withSenderCheck(async () => - jsonResult((await toPublicForms(teamId, [await createForm(teamId, args)]))[0]), - ); - }, - ); - - server.registerTool( - "get_signup_form", - { - description: "Returns one signup form.", - inputSchema: { formId: z.string() }, - outputSchema: signupFormSchema, - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const form = await getForm(teamId, args.formId); - return form ? jsonResult((await toPublicForms(teamId, [form]))[0]) : NOT_FOUND; - }, - ); - - server.registerTool( - "update_signup_form", - { - description: - "Updates a signup form's name, tags, double opt-in or redirect URL. Turning double opt-in on fails with confirmation_sender_unavailable when the team has no sending mailbox.", - inputSchema: { - formId: z.string(), - ...createSignupFormBodySchema.partial().shape, - }, - outputSchema: signupFormSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const { formId, ...patch } = args; - return withSenderCheck(async () => { - const form = await updateForm(teamId, formId, patch); - return form ? jsonResult((await toPublicForms(teamId, [form]))[0]) : NOT_FOUND; - }); - }, - ); - - server.registerTool( - "delete_signup_form", - { - description: "Deletes a signup form; its URL stops accepting signups.", - inputSchema: { formId: z.string() }, - outputSchema: z.object({ deleted: z.boolean() }), - annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return (await deleteForm(teamId, args.formId)) - ? jsonResult({ deleted: true }) - : NOT_FOUND; - }, - ); -} diff --git a/apps/api/src/mcp/tools/general-settings.ts b/apps/api/src/mcp/tools/general-settings.ts deleted file mode 100644 index 183b2765..00000000 --- a/apps/api/src/mcp/tools/general-settings.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { z } from "zod"; -import { - type GeneralSettings, - getGeneralSettings, - upsertGeneralSettings, -} from "../../settings/general/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { AUTH_ERROR, errorResult, jsonResult } from "./responses"; -import { generalSettingsSchema } from "./schemas"; - -function toPublicShape(settings: GeneralSettings) { - return { - mailingAddress: settings.mailingAddress, - updatedAt: settings.updatedAt, - }; -} - -export function registerGeneralSettingsTools(server: McpToolRegistrar): void { - server.registerTool( - "get_general_settings", - { - description: - "Returns the team's general workspace settings. The mailing address is used in the managed marketing footer and is required for broadcasts and sequences. Returns defaults (null fields) when nothing has been saved yet.", - outputSchema: generalSettingsSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const settings = await getGeneralSettings(teamId); - return jsonResult(toPublicShape(settings)); - }, - ); - - server.registerTool( - "update_general_settings", - { - description: - "Updates the team's general workspace settings. Omitted fields are left unchanged; send an empty string to clear a field. `mailingAddress` is the physical postal address rendered in the managed marketing footer and required for broadcasts and sequences.", - inputSchema: { - mailingAddress: z - .string() - .optional() - .describe("Omit to keep unchanged; empty string clears it"), - }, - outputSchema: generalSettingsSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const settings = await upsertGeneralSettings(teamId, args); - return jsonResult(toPublicShape(settings)); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); -} diff --git a/apps/api/src/mcp/tools/media.ts b/apps/api/src/mcp/tools/media.ts deleted file mode 100644 index 6ac0a6b4..00000000 --- a/apps/api/src/mcp/tools/media.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { z } from "zod"; -import { - countMedia, - deleteUnusedMedia, - getMediaByMediaId, - listMedia, - listMediaReferences, - updateMediaMetadata, -} from "../../media/queries"; -import { omitInternal } from "../../utils/public"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { - AUTH_ERROR, - errorResult, - INTERNAL_ERROR, - jsonResult, - NOT_FOUND, -} from "./responses"; -import { - mediaListSchema, - mediaReferenceListSchema, - mediaSchema, - successMessageSchema, -} from "./schemas"; - -function toPublicMedia(row: Record) { - return omitInternal(row); -} - -function toPublicReference(reference: { - resourceType: string; - resourcePublicId: string; - parentResourcePublicId?: string | null; - createdAt?: Date | null; - updatedAt?: Date | null; -}) { - return { - resourceType: reference.resourceType, - resourcePublicId: reference.resourcePublicId, - parentResourcePublicId: reference.parentResourcePublicId, - createdAt: reference.createdAt, - updatedAt: reference.updatedAt, - }; -} - -export function registerMediaTools(server: McpToolRegistrar): void { - server.registerTool( - "list_media", - { - description: - "Returns uploaded MediaLit-backed media for the team. Unsplash and external URL images are not stored here.", - inputSchema: { - query: z.string().optional(), - page: z.number().int().min(1).optional(), - pageSize: z.number().int().min(1).max(100).optional(), - }, - outputSchema: mediaListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const [items, total] = await Promise.all([ - listMedia({ - teamId, - query: args.query, - page: args.page, - pageSize: args.pageSize, - }), - countMedia({ teamId, query: args.query }), - ]); - return jsonResult({ - items: items.map(toPublicMedia), - total, - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "get_media", - { - description: "Returns one uploaded media item by its media ID.", - inputSchema: { mediaId: z.string().min(1) }, - outputSchema: mediaSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const row = await getMediaByMediaId(teamId, args.mediaId); - if (!row) return NOT_FOUND; - return jsonResult(toPublicMedia(row)); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "update_media", - { - description: "Updates editable metadata for uploaded media.", - inputSchema: { - mediaId: z.string().min(1), - alt: z.string().nullable().optional(), - caption: z.string().nullable().optional(), - }, - outputSchema: mediaSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const row = await updateMediaMetadata({ - teamId, - mediaId: args.mediaId, - alt: args.alt, - caption: args.caption, - }); - if (!row) return NOT_FOUND; - return jsonResult(toPublicMedia(row)); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "delete_media", - { - description: - "Deletes uploaded media only when no saved template, broadcast, or sequence email references it.", - inputSchema: { mediaId: z.string().min(1) }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const result = await deleteUnusedMedia(teamId, args.mediaId); - if (result === "not_found") return NOT_FOUND; - if (result === "in_use") { - return errorResult("Media is still in use."); - } - return jsonResult({ message: "Media deleted." }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "list_media_references", - { - description: - "Lists saved templates, broadcasts, or sequence emails that use the uploaded media item.", - inputSchema: { mediaId: z.string().min(1) }, - outputSchema: mediaReferenceListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const references = await listMediaReferences({ - teamId, - mediaId: args.mediaId, - }); - if (!references) return NOT_FOUND; - return jsonResult({ - items: references.map(toPublicReference), - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); -} diff --git a/apps/api/src/mcp/tools/reputation.ts b/apps/api/src/mcp/tools/reputation.ts deleted file mode 100644 index f57206c3..00000000 --- a/apps/api/src/mcp/tools/reputation.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { connectSndsBodySchema, reputationSchema } from "@sendlit/api-contract"; -import { z } from "zod"; -import { startGoogleConnect } from "../../reputation/google"; -import { deleteConnection, getReputation } from "../../reputation/queries"; -import { SndsLinkError } from "../../reputation/snds"; -import { connectSnds, syncTeamNow } from "../../reputation/sync"; -import { consumeRateLimit } from "../../services/rate-counter"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getAuthUser, getTeamId } from "./auth"; -import { AUTH_ERROR, errorResult, jsonResult, NOT_FOUND } from "./responses"; - -const write = { readOnlyHint: false, destructiveHint: false, openWorldHint: true }; - -export function registerReputationTools(server: McpToolRegistrar): void { - server.registerTool( - "get_reputation", - { - description: - "Returns the last 30 days of mailbox provider reputation data and current alerts: Google Postmaster Tools per sending domain (user-reported spam rate, SPF/DKIM/DMARC pass rates, delivery error rate, compliance with Google's sender requirements) and Microsoft SNDS per IP (filter result, complaint rate, spam trap hits). Rates are fractions (0.003 = 0.3%).", - outputSchema: reputationSchema, - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return jsonResult(await getReputation(teamId)); - }, - ); - - server.registerTool( - "connect_google_postmaster", - { - description: - "Returns a Google sign-in URL that a person must open in a browser to connect Google Postmaster Tools. Each sending domain must be verified in Postmaster Tools with that Google account.", - outputSchema: z.object({ authorizationUrl: z.string() }), - annotations: write, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const url = startGoogleConnect(teamId, getAuthUser(extra)?.id ?? null); - if (!url) return errorResult("google_postmaster_not_configured"); - return jsonResult({ authorizationUrl: url }); - }, - ); - - server.registerTool( - "disconnect_google_postmaster", - { - description: "Disconnects Google Postmaster Tools and deletes its stored data.", - outputSchema: z.object({ disconnected: z.boolean() }), - annotations: { ...write, destructiveHint: true, openWorldHint: false }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return (await deleteConnection(teamId, "google_postmaster")) - ? jsonResult({ disconnected: true }) - : NOT_FOUND; - }, - ); - - server.registerTool( - "connect_snds", - { - description: - "Connects Microsoft SNDS with its automated data access link (from SNDS's Automated Data Access page). The link is checked by downloading the data once; it expires after 30 days.", - inputSchema: connectSndsBodySchema.shape, - outputSchema: reputationSchema, - annotations: write, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - await connectSnds(teamId, args.dataUrl); - } catch (error) { - if (error instanceof SndsLinkError) return errorResult("snds_link_invalid"); - throw error; - } - return jsonResult(await getReputation(teamId)); - }, - ); - - server.registerTool( - "disconnect_snds", - { - description: "Disconnects Microsoft SNDS and deletes its stored data.", - outputSchema: z.object({ disconnected: z.boolean() }), - annotations: { ...write, destructiveHint: true, openWorldHint: false }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return (await deleteConnection(teamId, "microsoft_snds")) - ? jsonResult({ disconnected: true }) - : NOT_FOUND; - }, - ); - - server.registerTool( - "sync_reputation", - { - description: - "Refreshes reputation data now instead of waiting for the daily sync. Limited to once every 5 minutes.", - outputSchema: reputationSchema, - annotations: write, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - if (!(await consumeRateLimit(`reputation-sync:${teamId}`, 1, 300))) { - return errorResult("too_many_requests"); - } - await syncTeamNow(teamId); - return jsonResult(await getReputation(teamId)); - }, - ); -} diff --git a/apps/api/src/mcp/tools/responses.ts b/apps/api/src/mcp/tools/responses.ts index e13b5e19..0290eedd 100644 --- a/apps/api/src/mcp/tools/responses.ts +++ b/apps/api/src/mcp/tools/responses.ts @@ -1,64 +1,39 @@ -import { serializeDates } from "../../utils/serialize"; +import { + createPlatformError, + type PlatformError, + type PlatformErrorCode, + type PlatformSafeDetails, +} from "@codelitdev/platform"; +import { PlanGateError } from "../../billing/errors"; -export const AUTH_ERROR = { - content: [ - { - type: "text" as const, - text: "Authentication required: valid API credentials were not provided.", - }, - ], - isError: true, -}; +/** A tool's failure: the kit returns it as an `isError` result carrying the + * platform `code`, plus `details` (a stable `reason`, and a `message` for the + * model when the reason alone is not enough). */ +export type ToolError = { error: PlatformError }; -export const INTERNAL_ERROR = { - content: [ - { - type: "text" as const, - text: "An error occurred while processing your request.", - }, - ], - isError: true, -}; - -export const NOT_FOUND = { - content: [{ type: "text" as const, text: "Not found." }], - isError: true, -}; - -export function errorResult(message: string) { - return { - content: [{ type: "text" as const, text: message }], - isError: true, - }; +export function toolError( + code: PlatformErrorCode, + details?: PlatformSafeDetails, +): ToolError { + return { error: createPlatformError(code, details ? { safeDetails: details } : {}) }; } -export function jsonResult(data: unknown) { - const serialized = serializeDates(data ?? {}) as Record; - return { - content: [{ type: "text" as const, text: JSON.stringify(serialized) }], - structuredContent: serialized, - }; -} +export const notFound = (): ToolError => toolError("not_found"); -export function planGateResult(error: { - code: string; - details?: Record; - status?: number; -}) { - const details = { error: error.code, ...(error.details ?? {}) }; - return { - content: [{ type: "text" as const, text: JSON.stringify(details) }], - structuredContent: details, - isError: true, +/** A plan limit, keeping the REST error code as the `reason` and the + * primitive details (limit, usage, upgrade URL) that REST also returns. */ +export function planGateError(error: PlanGateError): ToolError { + const details: Record = { + reason: error.code, }; + for (const [key, value] of Object.entries(error.details)) { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) { + details[key] = value as string | number | boolean | null; + } + } + return toolError("forbidden", details); } -export function isPlanGateError(error: unknown): error is { - code: string; - details?: Record; - status?: number; -} { - return Boolean( - error && typeof error === "object" && (error as any).name === "PlanGateError", - ); +export function isPlanGateError(error: unknown): error is PlanGateError { + return error instanceof PlanGateError; } diff --git a/apps/api/src/mcp/tools/schemas.ts b/apps/api/src/mcp/tools/schemas.ts index a0e2e257..4135dfd1 100644 --- a/apps/api/src/mcp/tools/schemas.ts +++ b/apps/api/src/mcp/tools/schemas.ts @@ -4,12 +4,8 @@ import { sequenceSchema as contractSequenceSchema, customFieldsSchema, emailContentSchema, - espWarmupSchema, - sequenceScheduleSchema, - waitingContactsPageSchema, } from "@sendlit/api-contract"; import { z } from "zod"; -import { feedbackCapableProviders } from "../../config/constants"; export { emailContentSchema }; @@ -30,18 +26,6 @@ export const contactListSchema = z.object({ total: z.number(), }); -export const contactDeliverySchema = z.object({ - sequenceId: z.string(), - sequenceTitle: z.string(), - sequenceType: z.string(), - emailId: z.string(), - createdAt: z.string().nullable(), -}); - -export const contactDeliveryListSchema = z.object({ - items: z.array(contactDeliverySchema), -}); - export const segmentSchema = z.object({ segmentId: z.string(), name: z.string(), @@ -64,57 +48,6 @@ export const templateSchema = z.object({ updatedAt: z.string(), }); -export const mediaSchema = z.object({ - mediaId: z.string(), - url: z.string(), - thumbnailUrl: z.string().nullable().optional(), - mediaLitId: z.string(), - fileName: z.string().nullable().optional(), - mimeType: z.string().nullable().optional(), - size: z.number().nullable().optional(), - width: z.number().nullable().optional(), - height: z.number().nullable().optional(), - alt: z.string().nullable().optional(), - caption: z.string().nullable().optional(), - createdAt: z.string().nullable().optional(), - updatedAt: z.string().nullable().optional(), -}); - -export const mediaListSchema = z.object({ - items: z.array(mediaSchema), - total: z.number(), -}); - -export const mediaReferenceSchema = z.object({ - resourceType: z.enum(["TEMPLATE", "SEQUENCE_EMAIL"]), - resourcePublicId: z.string(), - parentResourcePublicId: z.string().nullable().optional(), - createdAt: z.string().nullable().optional(), - updatedAt: z.string().nullable().optional(), -}); - -export const mediaReferenceListSchema = z.object({ - items: z.array(mediaReferenceSchema), -}); - -export const systemTemplateSchema = z.object({ - templateId: z.string().min(1), - title: z.string(), - description: z.string(), - purpose: z.enum(["marketing", "transactional"]), - content: emailContentSchema, - requiredVariables: z.array(z.string()), - variableDefinitions: z - .array( - z.object({ - path: z.string(), - description: z.string(), - example: z.any(), - }), - ) - .optional(), -}); - /** What the sequence tools return (`toPublicSequence` in tools/sequences.ts). * Built on the REST contract's schema, which is the source of truth, and * open to extra fields: a hand-kept copy here had drifted (it required an @@ -142,110 +75,9 @@ export const sequenceStatsSchema = z.object({ complaintRate: z.number().optional(), }); -// The REST contract's shapes, so MCP output can't drift from REST. -export { sequenceScheduleSchema, waitingContactsPageSchema as waitingContactsSchema }; - -export const successMessageSchema = z.object({ - message: z.string(), -}); - -export const testEspResultSchema = z.object({ - success: z.boolean(), - error: z.string().optional(), -}); - -export const teamSchema = z.object({ - teamId: z.string(), - name: z.string(), - organizationId: z.string().optional(), - organizationName: z.string().optional(), -}); - -/** The secret is stored hashed; listings only expose `keyPrefix`. Only - * `create_api_key` returns the full secret (see `createdApiKeySchema`). */ -export const apiKeySchema = z.object({ - id: z.string(), - keyPrefix: z.string(), - name: z.string().nullable(), - createdAt: z.string().nullable(), -}); - -export const createdApiKeySchema = apiKeySchema.extend({ - key: z.string(), -}); - -export const espProviders = [ - "smtp", - "sendgrid", - "mailgun", - "postmark", - "ses", - "resend", -] as const; - -export const espConfigSchema = z.object({ - espId: z.string(), - name: z.string(), - provider: z.enum(espProviders), - host: z.string(), - port: z.number(), - secure: z.boolean(), - username: z.string().nullable().optional(), - hasPassword: z.boolean(), - fromName: z.string().nullable().optional(), - fromEmail: z.string().nullable().optional(), - replyTo: z.string().nullable().optional(), - maxSendRate: z.number().nullable().optional(), - dailySendLimit: z.number().int().nullable().optional(), - warmup: espWarmupSchema.nullable().optional(), - status: z.string(), - secretVersion: z.number(), - lastTestedAt: z.string().nullable().optional(), - lastTestStatus: z.string().nullable().optional(), - lastTestError: z.string().nullable().optional(), -}); - -export const espConfigListSchema = z.object({ - items: z.array(espConfigSchema), -}); - -/** General (non-ESP) per-team settings singleton — see settings/general. */ -export const generalSettingsSchema = z.object({ - mailingAddress: z.string().nullable(), - updatedAt: z.string().nullable().optional(), -}); - -/** Custom tracking domain singleton — see settings/tracking-domain. */ -export const trackingDomainSchema = z.object({ - domain: z.string().nullable(), - status: z.enum(["pending", "verified", "failed"]).nullable(), - cnameTarget: z.string(), - error: z.string().nullable(), - verifiedAt: z.string().nullable(), - checkedAt: z.string().nullable(), -}); - // ---- Bounce and complaint processing (docs/bounces-and-complaints.md) ----- -// Re-exported from the single source of truth so the MCP surface can never -// drift from the registered adapters (see `config/constants.ts`). -export { feedbackCapableProviders }; - -export const feedbackConnectionSchema = z.object({ - connectionId: z.string(), - espId: z.string(), - provider: z.string(), - webhookUrl: z.string(), - hasCredential: z.boolean(), - expectedTopicArn: z.string().nullable().optional(), - subscriptionConfirmedAt: z.string().nullable().optional(), - status: z.enum(["pending", "healthy", "stale", "error", "retiring", "disabled"]), - lastReceivedAt: z.string().nullable().optional(), - lastVerifiedAt: z.string().nullable().optional(), - lastErrorCode: z.string().nullable().optional(), -}); - -export const deliveryEventSchema = z.object({ +const deliveryEventSchema = z.object({ eventId: z.string(), provider: z.string(), espId: z.string().nullable(), @@ -263,25 +95,3 @@ export const deliveryEventListSchema = z.object({ items: z.array(deliveryEventSchema), total: z.number(), }); - -export const suppressionSchema = z.object({ - suppressionId: z.string(), - recipientEmail: z.string().nullable(), - reason: z.enum([ - "hard_bounce", - "complaint", - "repeated_soft_bounce", - "provider_suppression", - "manual", - ]), - active: z.boolean(), - firstSuppressedAt: z.string(), - lastSuppressedAt: z.string(), - releasedAt: z.string().nullable().optional(), - releaseReason: z.string().nullable().optional(), -}); - -export const suppressionListSchema = z.object({ - items: z.array(suppressionSchema), - total: z.number(), -}); diff --git a/apps/api/src/mcp/tools/segments.test.ts b/apps/api/src/mcp/tools/segments.test.ts new file mode 100644 index 00000000..30badba7 --- /dev/null +++ b/apps/api/src/mcp/tools/segments.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { createSegment: vi.fn(), listSegments: vi.fn() }; +// contacts.test.ts mocks the same module; see the note there. +mock.module("../../contacts/segments-queries", () => ({ + ...mocks, + getSegment: vi.fn(), +})); + +const { segmentTools } = await import("./segments"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", + clientIp: "", + replayHeaders: {}, +}; +const tools = new Map(segmentTools().map((tool) => [tool.name, tool])); +function call(name: string, args: unknown, ctx: any = context): Promise { + return tools.get(name)!.handler({ context: ctx, args, signal }); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP segment tools", () => { + it("keeps list_segments and create_segment", () => { + expect([...tools.keys()]).toEqual(["list_segments", "create_segment"]); + }); + + it("lists the team's segments without internal ids", async () => { + mocks.listSegments.mockResolvedValue([ + { id: "internal", segmentId: "seg_1", name: "VIP", filter: {} }, + ]); + await expect(call("list_segments", {})).resolves.toEqual({ + items: [{ segmentId: "seg_1", name: "VIP", filter: {} }], + }); + expect(mocks.listSegments).toHaveBeenCalledWith("team-1"); + }); + + it("creates a segment for the team and maps a taken name to conflict", async () => { + const filter = { aggregator: "and", filters: [] }; + mocks.createSegment.mockResolvedValue({ segmentId: "seg_1", name: "VIP", filter }); + await expect( + call("create_segment", { name: "VIP", filter }), + ).resolves.toMatchObject({ + segmentId: "seg_1", + }); + expect(mocks.createSegment).toHaveBeenCalledWith({ + teamId: "team-1", + name: "VIP", + filter, + }); + + mocks.createSegment.mockRejectedValue(new Error("duplicate_name")); + await expect( + call("create_segment", { name: "VIP", filter }), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "duplicate_name" } }, + }); + + mocks.createSegment.mockRejectedValue(new Error("db down")); + await expect(call("create_segment", { name: "VIP", filter })).rejects.toThrow( + "db down", + ); + }); + + it("needs contacts:write to create a segment over OAuth", async () => { + const result = await call( + "create_segment", + { name: "VIP", filter: {} }, + { ...context, authKind: "oauth", scopes: ["contacts:read"] }, + ); + expect(result).toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "insufficient_scope" } }, + }); + expect(mocks.createSegment).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/segments.ts b/apps/api/src/mcp/tools/segments.ts index 8d8dcfb3..f38f573d 100644 --- a/apps/api/src/mcp/tools/segments.ts +++ b/apps/api/src/mcp/tools/segments.ts @@ -1,164 +1,57 @@ import { contactFilterSchema } from "@sendlit/api-contract"; import { z } from "zod"; -import { - createSegment, - deleteSegment, - getSegment, - listSegments, - updateSegment, -} from "../../contacts/segments-queries"; +import { createSegment, listSegments } from "../../contacts/segments-queries"; import { omitInternal } from "../../utils/public"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { AUTH_ERROR, INTERNAL_ERROR, jsonResult, NOT_FOUND } from "./responses"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { toolError } from "./responses"; import { segmentListSchema, segmentSchema } from "./schemas"; -export function registerSegmentTools(server: McpToolRegistrar): void { - server.registerTool( - "list_segments", - { - description: - "Returns all saved segments (named, reusable contact filters) for the team.", - outputSchema: segmentListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const items = await listSegments(teamId); - return jsonResult({ - items: items.map((item) => omitInternal(item)), - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "get_segment", - { - description: "Returns a single saved segment by its segment ID.", - inputSchema: { segmentId: z.string().describe("Segment ID") }, - outputSchema: segmentSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const segment = await getSegment(args.segmentId); - if (!segment || segment.teamId !== teamId) return NOT_FOUND; - return jsonResult(omitInternal(segment)); - } catch { - return INTERNAL_ERROR; - } - }, - ); +export const segmentTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_segments", + operationId: "segments.list", + risk: "read", + description: + "Returns all saved segments (named, reusable contact filters) for the team.", + inputSchema: z.object({}), + outputSchema: segmentListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context }) { + const items = await listSegments(context.teamId); + return { items: items.map((item) => omitInternal(item)) }; + }, + }), - server.registerTool( - "create_segment", - { - description: - "Creates a saved segment: a named, reusable contact filter that can be applied to broadcasts/sequences or listed on demand.", - inputSchema: { - name: z.string().describe("Segment name (must be unique)"), - filter: contactFilterSchema.describe("The filter to save"), - }, - outputSchema: segmentSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "create_segment", + operationId: "segments.create", + risk: "write", + description: + "Creates a saved segment: a named, reusable contact filter that can be applied to broadcasts/sequences or listed on demand.", + inputSchema: z.object({ + name: z.string().describe("Segment name (must be unique)"), + filter: contactFilterSchema.describe("The filter to save"), + }), + outputSchema: segmentSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { const segment = await createSegment({ - teamId, + teamId: context.teamId, name: args.name, filter: args.filter, }); - return jsonResult(omitInternal(segment)); + return omitInternal(segment); } catch (err: any) { - return { - content: [{ type: "text" as const, text: err.message }], - isError: true, - }; - } - }, - ); - - server.registerTool( - "update_segment", - { - description: "Updates a saved segment's name and/or filter.", - inputSchema: { - segmentId: z.string().describe("Segment ID"), - name: z.string().optional(), - filter: contactFilterSchema.optional(), - }, - outputSchema: segmentSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const { segmentId, ...patch } = args; - try { - const segment = await updateSegment({ - teamId, - segmentId, - ...patch, - }); - if (!segment) return NOT_FOUND; - return jsonResult(omitInternal(segment)); - } catch (err: any) { - return { - content: [{ type: "text" as const, text: err.message }], - isError: true, - }; - } - }, - ); - - server.registerTool( - "delete_segment", - { - description: - "Permanently deletes a saved segment. This action cannot be undone; it does not delete the contacts themselves.", - inputSchema: { segmentId: z.string() }, - outputSchema: z.object({ message: z.string() }), - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - await deleteSegment(teamId, args.segmentId); - return jsonResult({ message: "Segment deleted." }); - } catch { - return INTERNAL_ERROR; + if (err?.message === "duplicate_name") { + return toolError("conflict", { + reason: "duplicate_name", + message: "A segment with this name already exists", + }); + } + throw err; } }, - ); -} + }), +]; diff --git a/apps/api/src/mcp/tools/sequences.test.ts b/apps/api/src/mcp/tools/sequences.test.ts new file mode 100644 index 00000000..f1dd5d45 --- /dev/null +++ b/apps/api/src/mcp/tools/sequences.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + addMailToSequence: vi.fn(), + countSequences: vi.fn(), + createSequence: vi.fn(), + getEmailSentCount: vi.fn(), + getSequenceBySequenceId: vi.fn(), + getSequenceClickThroughRate: vi.fn(), + getSequenceOpenRate: vi.fn(), + getSubscribersCount: vi.fn(), + listSequences: vi.fn(), + pauseSequence: vi.fn(), + startSequence: vi.fn(), + updateMailInSequence: vi.fn(), + updateSequence: vi.fn(), + sendSequenceEmailTest: vi.fn(), +}; + +mock.module("../../sequences/queries", () => ({ + addMailToSequence: mocks.addMailToSequence, + countSequences: mocks.countSequences, + createSequence: mocks.createSequence, + getEmailSentCount: mocks.getEmailSentCount, + getSequenceBySequenceId: mocks.getSequenceBySequenceId, + getSequenceClickThroughRate: mocks.getSequenceClickThroughRate, + getSequenceOpenRate: mocks.getSequenceOpenRate, + getSubscribersCount: mocks.getSubscribersCount, + listSequences: mocks.listSequences, + pauseSequence: mocks.pauseSequence, + startSequence: mocks.startSequence, + updateMailInSequence: mocks.updateMailInSequence, + updateSequence: mocks.updateSequence, +})); +mock.module("../../sequences/test-send", () => ({ + sendSequenceEmailTest: mocks.sendSequenceEmailTest, +})); +mock.module("../../abuse/pacing-status", () => ({ broadcastPacing: async () => null })); +mock.module("../../automation/sending-health", () => ({ + sequenceDeliveryRates: async () => ({ bounceRate: 0, complaintRate: 0 }), +})); + +const { sequenceTools } = await import("./sequences"); +const { sequenceSchema } = await import("./schemas"); +const { PlanGateError } = await import("../../billing/errors"); +const { responses } = await import("../../config/strings"); + +const teamKey = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", + clientIp: "", + replayHeaders: {}, +}; +const oauth = { + ...teamKey, + authKind: "oauth" as const, + user: { id: "user-1", email: "owner@example.com", name: "Owner" } as any, + scopes: ["sequences:read", "sequences:write"], +}; + +const tools = new Map(sequenceTools().map((tool) => [tool.name, tool])); + +function call(name: string, args: Record, context: any = teamKey) { + const tool = tools.get(name); + if (!tool) throw new Error(`unknown tool ${name}`); + return tool.handler({ context, args, signal: new AbortController().signal }); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP sequence tools", () => { + it("keeps only the dedicated sequence tools", () => { + expect([...tools.keys()]).toEqual([ + "list_sequences", + "get_sequence", + "create_sequence", + "update_sequence", + "add_sequence_email", + "update_sequence_email", + "send_sequence_email_test", + "start_sequence", + "pause_sequence", + "get_sequence_stats", + ]); + }); + + it("refuses an OAuth token without the sequences scope", async () => { + await expect( + call( + "list_sequences", + { type: "broadcast" }, + { ...oauth, scopes: ["contacts:read"] }, + ), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "sequences:read" }, + }, + }); + expect(mocks.listSequences).not.toHaveBeenCalled(); + }); + + it("validates non-empty template IDs on sequence creation/add-email inputs", () => { + expect( + tools + .get("create_sequence")! + .inputSchema.safeParse({ type: "broadcast", templateId: "" }).success, + ).toBe(false); + expect( + tools + .get("add_sequence_email")! + .inputSchema.safeParse({ sequenceId: "seq-1", templateId: "" }).success, + ).toBe(false); + }); + + it("lists sequences of the resolved team", async () => { + mocks.listSequences.mockResolvedValue([ + { + sequenceId: "seq-1", + outboxId: "internal", + emails: [{ emailId: "e1", sequenceId: "x" }], + }, + ]); + mocks.countSequences.mockResolvedValue(1); + await expect(call("list_sequences", { type: "sequence" })).resolves.toEqual({ + items: [{ sequenceId: "seq-1", emails: [{ emailId: "e1" }] }], + total: 1, + }); + expect(mocks.listSequences).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "team-1", type: "sequence" }), + ); + expect(mocks.countSequences).toHaveBeenCalledWith("team-1", "sequence", undefined); + }); + + it("checks sequence ownership before returning stats", async () => { + mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); + await expect( + call("get_sequence_stats", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + expect(mocks.getSequenceBySequenceId).toHaveBeenCalledWith("team-1", "seq-1"); + expect(mocks.getEmailSentCount).not.toHaveBeenCalled(); + + mocks.getSequenceBySequenceId.mockResolvedValueOnce({ + id: "s1", + sequenceId: "seq-1", + }); + mocks.getEmailSentCount.mockResolvedValue(5); + mocks.getSequenceOpenRate.mockResolvedValue(0.4); + mocks.getSequenceClickThroughRate.mockResolvedValue(0.2); + mocks.getSubscribersCount.mockResolvedValue(3); + await expect(call("get_sequence_stats", { sequenceId: "seq-1" })).resolves.toEqual({ + sent: 5, + openRate: 0.4, + clickThroughRate: 0.2, + subscribersCount: 3, + bounceRate: 0, + complaintRate: 0, + }); + }); + + it("sends a sequence email test to the signed-in user by default", async () => { + mocks.sendSequenceEmailTest.mockResolvedValue({ + sent: ["owner@example.com"], + skipped: [], + lastTestSentAt: "2026-10-19T00:00:00.000Z", + }); + await expect( + call( + "send_sequence_email_test", + { sequenceId: "seq_1", emailId: "email_1" }, + oauth, + ), + ).resolves.toMatchObject({ sent: ["owner@example.com"] }); + expect(mocks.sendSequenceEmailTest).toHaveBeenCalledWith({ + teamId: "team-1", + sequenceId: "seq_1", + emailId: "email_1", + to: undefined, + fallbackRecipient: "owner@example.com", + }); + + // A team API key has no owner to fall back to. + mocks.sendSequenceEmailTest.mockRejectedValueOnce( + new Error("test_recipients_required"), + ); + await expect( + call("send_sequence_email_test", { sequenceId: "seq_1", emailId: "email_1" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "test_recipients_required" }, + }, + }); + expect(mocks.sendSequenceEmailTest).toHaveBeenLastCalledWith( + expect.objectContaining({ fallbackRecipient: undefined }), + ); + + mocks.sendSequenceEmailTest.mockRejectedValueOnce(new Error("email_not_found")); + await expect( + call("send_sequence_email_test", { sequenceId: "seq_1", emailId: "email_x" }), + ).resolves.toMatchObject({ + error: { code: "not_found", safeDetails: { reason: "email_not_found" } }, + }); + + mocks.sendSequenceEmailTest.mockRejectedValueOnce( + new Error("test_send_limit_reached"), + ); + await expect( + call("send_sequence_email_test", { sequenceId: "seq_1", emailId: "email_1" }), + ).resolves.toMatchObject({ + error: { + code: "rate_limited", + safeDetails: { reason: "test_send_limit_reached" }, + }, + }); + }); + + it("maps start failures to platform codes with the REST reason", async () => { + mocks.startSequence.mockRejectedValueOnce(new Error("mailing_address_required")); + await expect( + call("start_sequence", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { + reason: "mailing_address_required", + message: "A mailing address is required for broadcasts and sequences.", + }, + }, + }); + + mocks.startSequence.mockRejectedValueOnce( + new Error(responses.sequence_already_started), + ); + await expect( + call("start_sequence", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ + error: { + code: "conflict", + safeDetails: { + reason: "sequence_already_started", + message: responses.sequence_already_started, + }, + }, + }); + + mocks.startSequence.mockRejectedValueOnce( + new Error(`${responses.sequence_details_missing}: trigger`), + ); + await expect( + call("start_sequence", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ + error: { code: "validation_failed" }, + }); + + mocks.startSequence.mockRejectedValueOnce( + new PlanGateError("plan_limit_reached" as any, { limit: 3, nested: { a: 1 } }), + ); + const gated: any = await call("start_sequence", { sequenceId: "seq-1" }); + expect(gated.error.code).toBe("forbidden"); + expect(gated.error.safeDetails).toMatchObject({ + reason: "plan_limit_reached", + limit: 3, + }); + expect(gated.error.safeDetails).not.toHaveProperty("nested"); + }); + + it("rethrows errors that are not a known code", async () => { + mocks.createSequence.mockRejectedValueOnce( + new Error("Connection terminated unexpectedly"), + ); + await expect( + call("create_sequence", { type: "broadcast", templateId: "tpl_1" }), + ).rejects.toThrow("Connection terminated unexpectedly"); + + mocks.createSequence.mockRejectedValueOnce(new Error("template_not_marketing")); + await expect( + call("create_sequence", { type: "broadcast", templateId: "tpl_1" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "template_not_marketing" }, + }, + }); + }); + + it("returns not_found when updating another team's sequence", async () => { + mocks.updateSequence.mockResolvedValueOnce(null); + await expect( + call("update_sequence", { sequenceId: "seq-x", title: "New" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + expect(mocks.updateSequence).toHaveBeenCalledWith({ + teamId: "team-1", + sequenceId: "seq-x", + title: "New", + }); + }); +}); + +describe("MCP sequence output schema", () => { + it("accepts what the sequence tools return", () => { + // Regression: the schema required an `espId` the tools never return, + // so every sequence tool failed MCP output validation. + const parsed = sequenceSchema.safeParse({ + sequenceId: "seq_1", + type: "sequence", + title: "Follow-up", + status: "active", + triggerType: "event:custom", + triggerData: "course purchased", + triggerFilter: { aggregator: "and", filters: [] }, + reentry: "once", + reentryPeriodDays: null, + filter: null, + excludeFilter: null, + emailsOrder: ["email_1"], + entrants: [], + report: {}, + viewInBrowser: false, + webPublished: false, + createdAt: "2026-10-28T00:00:00.000Z", + updatedAt: "2026-10-28T00:00:00.000Z", + emails: [ + { + emailId: "email_1", + subject: "Hi", + content: { style: {}, meta: {}, content: [] }, + delayInMillis: 0, + published: true, + contentScanVerdict: null, + createdAt: null, + updatedAt: null, + }, + ], + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/apps/api/src/mcp/tools/sequences.ts b/apps/api/src/mcp/tools/sequences.ts index 66ffd858..23aaca96 100644 --- a/apps/api/src/mcp/tools/sequences.ts +++ b/apps/api/src/mcp/tools/sequences.ts @@ -2,7 +2,6 @@ import { archivedFilters, contactFilterSchema, emailContentInputSchema, - sequenceDeletionPreviewSchema, sequenceEmailTestResultSchema, updateSequenceBodySchema, } from "@sendlit/api-contract"; @@ -10,55 +9,36 @@ import { z } from "zod"; import { broadcastPacing } from "../../abuse/pacing-status"; import { sequenceDeliveryRates } from "../../automation/sending-health"; import { emailActionTypes, mailTypes } from "../../config/constants"; -import { - broadcastArchiveErrors, - sequenceDeleteBlockerMessage, -} from "../../sequences/delete-blockers"; +import { responses } from "../../config/strings"; import type { HydratedSequence } from "../../sequences/queries"; import { addMailToSequence, countSequences, createSequence, - deleteMailFromSequence, - deleteSequence, getEmailSentCount, getSequenceBySequenceId, getSequenceClickThroughRate, - getSequenceDeletionImpact, getSequenceOpenRate, - getSequenceSchedule, - getSubscribers, getSubscribersCount, - getWaitingContacts, - InvalidCursorError, listSequences, pauseSequence, - setBroadcastArchived, startSequence, updateMailInSequence, updateSequence, } from "../../sequences/queries"; import { sendSequenceEmailTest } from "../../sequences/test-send"; +import { MAILING_ADDRESS_REQUIRED } from "../../settings/general/constants"; import { omitInternal } from "../../utils/public"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getAuthUser, getTeamId } from "./auth"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; import { - AUTH_ERROR, - errorResult, - INTERNAL_ERROR, isPlanGateError, - jsonResult, - NOT_FOUND, - planGateResult, + notFound, + planGateError, + type ToolError, + toolError, } from "./responses"; -import { - sequenceListSchema, - sequenceScheduleSchema, - sequenceSchema, - sequenceStatsSchema, - successMessageSchema, - waitingContactsSchema, -} from "./schemas"; +import { sequenceListSchema, sequenceSchema, sequenceStatsSchema } from "./schemas"; // `sequenceEmails.sequenceId` stores its *parent* sequence's internal `id` // (see the file-level doc comment in `db/schema.ts`) — dropped here rather @@ -77,637 +57,383 @@ function toPublicSequence(sequence: HydratedSequence) { }; } -export function registerSequenceTools(server: McpToolRegistrar): void { - server.registerTool( - "list_sequences", - { - description: - "Returns a paginated list of broadcasts (type=broadcast, one-off) or sequences (type=sequence, multi-step, event-triggered). Archived broadcasts are left out unless archived is only or include.", - inputSchema: { - type: z.enum(mailTypes), - archived: z - .enum(archivedFilters) - .optional() - .describe("exclude (default), only or include archived broadcasts"), - offset: z.number().int().min(1).optional().describe("Page number (default: 1)"), - itemsPerPage: z.number().int().min(1).max(50).optional(), - }, - outputSchema: sequenceListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const [items, total] = await Promise.all([ - listSequences({ - teamId, - type: args.type, - offset: args.offset, - itemsPerPage: args.itemsPerPage, - archived: args.archived, - }), - countSequences(teamId, args.type, args.archived), - ]); - return jsonResult({ - items: items.map(toPublicSequence), - total, - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); +const NOT_FOUND_REASONS = new Set([ + "sequence_not_found", + "email_not_found", + "template_not_found", + "team_not_found", + "item_not_found", +]); +const RATE_LIMITED_REASONS = new Set([ + "test_send_limit_reached", + "test_send_email_limit_reached", + "organization_team_quota_exhausted", + "organization_quota_exhausted", +]); +const CONFLICT_REASONS = new Set([ + "sequence_already_started", + "sequence_not_active", + "mail_already_sent", + "broadcast_archived", + "broadcast_held_for_review", + "esp_cannot_change_after_start", + "esp_not_configured", + "duplicate_title", +]); +const CODE = /^([a-z][a-z0-9_]*)(?::\s*(.+))?$/; +/** The `responses` sentence a sequence query threw (alone, or as + * `sentence: detail`), keyed back to its code. */ +function responseReason(raw: string): string | undefined { + return Object.entries(responses).find( + ([, sentence]) => raw === sentence || raw.startsWith(`${sentence}: `), + )?.[0]; +} - server.registerTool( - "get_sequence", - { - description: - "Returns a broadcast or sequence, including its emails, by its sequence ID.", - inputSchema: { sequenceId: z.string() }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const sequence = await getSequenceBySequenceId(teamId, args.sequenceId); - if (!sequence) return NOT_FOUND; - return jsonResult({ - ...toPublicSequence(sequence), - pacing: await broadcastPacing(sequence), - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); +/** + * A sequence query's failure as a tool error. The queries throw either a + * snake_case code (optionally `code: detail`) or one of the `responses` + * sentences; both keep their code as `reason`. Anything else is unexpected + * and is rethrown, so the kit reports it as `internal_error`. + */ +function sequenceError(err: unknown): ToolError { + if (isPlanGateError(err)) return planGateError(err); + const raw = err instanceof Error ? err.message : ""; + let reason = responseReason(raw); + let message: string | undefined = reason ? raw : undefined; + if (!reason) { + const match = CODE.exec(raw); + if (!match) throw err; + reason = match[1]; + message = match[2]; + } + const details: Record = message ? { reason, message } : { reason }; + if (reason === "action_not_allowed") return toolError("forbidden", details); + if (NOT_FOUND_REASONS.has(reason)) return toolError("not_found", details); + if (RATE_LIMITED_REASONS.has(reason)) return toolError("rate_limited", details); + if (CONFLICT_REASONS.has(reason)) return toolError("conflict", details); + if (reason === MAILING_ADDRESS_REQUIRED) { + return toolError("validation_failed", { + reason, + message: "A mailing address is required for broadcasts and sequences.", + }); + } + return toolError("validation_failed", details); +} - server.registerTool( - "create_sequence", - { - description: - "Creates a new broadcast or sequence from a marketing template or marketing system starter. Transactional templates return template_not_marketing.", - inputSchema: { - type: z.enum(mailTypes), - templateId: z.string().min(1), - deliverySource: z - .discriminatedUnion("type", [ - z.object({ type: z.literal("organization") }), - z.object({ - type: z.literal("team"), - espId: z.string().min(1).optional(), - }), - ]) - .optional(), - }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; +const deliverySourceSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("organization") }), + z.object({ + type: z.literal("team"), + espId: z.string().min(1).optional(), + }), +]); + +export const sequenceTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_sequences", + operationId: "sequences.list", + risk: "read", + description: + "Returns a paginated list of broadcasts (type=broadcast, one-off) or sequences (type=sequence, multi-step, event-triggered). Archived broadcasts are left out unless archived is only or include.", + inputSchema: z.object({ + type: z.enum(mailTypes), + archived: z + .enum(archivedFilters) + .optional() + .describe("exclude (default), only or include archived broadcasts"), + offset: z.number().int().min(1).optional().describe("Page number (default: 1)"), + itemsPerPage: z.number().int().min(1).max(50).optional(), + }), + outputSchema: sequenceListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const [items, total] = await Promise.all([ + listSequences({ + teamId: context.teamId, + type: args.type, + offset: args.offset, + itemsPerPage: args.itemsPerPage, + archived: args.archived, + }), + countSequences(context.teamId, args.type, args.archived), + ]); + return { items: items.map(toPublicSequence), total }; + }, + }), + + dedicatedTool({ + name: "get_sequence", + operationId: "sequences.get", + risk: "read", + description: + "Returns a broadcast or sequence, including its emails, by its sequence ID.", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: sequenceSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const sequence = await getSequenceBySequenceId(context.teamId, args.sequenceId); + if (!sequence) return notFound(); + return { ...toPublicSequence(sequence), pacing: await broadcastPacing(sequence) }; + }, + }), + + dedicatedTool({ + name: "create_sequence", + operationId: "sequences.create", + risk: "write", + description: + "Creates a new broadcast or sequence from a marketing template or marketing system starter. Transactional templates return template_not_marketing.", + inputSchema: z.object({ + type: z.enum(mailTypes), + templateId: z.string().min(1), + deliverySource: deliverySourceSchema.optional(), + }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { const sequence = await createSequence({ - teamId, + teamId: context.teamId, type: args.type, templateId: args.templateId, deliverySource: args.deliverySource, }); - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - return errorResult(err.message); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); } }, - ); + }), - server.registerTool( - "update_sequence", - { - description: - "Updates a broadcast/sequence's title, trigger (including event:custom with the event name in triggerData and an optional property filter), re-entry setting, audience filter, exclusion filter, selected user ESP, or whether its emails link a hosted web version (\"View in browser\"). A sent broadcast's title (its email's subject) can't be changed.", - inputSchema: { - sequenceId: z.string(), - title: z.string().optional(), - triggerType: updateSequenceBodySchema.shape.triggerType, - triggerData: updateSequenceBodySchema.shape.triggerData, - triggerFilter: updateSequenceBodySchema.shape.triggerFilter, - reentry: updateSequenceBodySchema.shape.reentry, - reentryPeriodDays: updateSequenceBodySchema.shape.reentryPeriodDays, - filter: z - .object({ - aggregator: z.enum(["and", "or"]), - filters: z.array(z.record(z.any())), - }) - .optional() - .describe( - "Audience filter (tag/email/subscription/signedUp/customField); empty means every subscribed contact", - ), - excludeFilter: contactFilterSchema - .nullable() - .optional() - .describe( - "Contacts matching this filter never receive the email, even if they match `filter` (e.g. leave out customers). null clears it.", - ), - emailsOrder: z.array(z.string()).optional(), - deliverySource: z - .discriminatedUnion("type", [ - z.object({ type: z.literal("organization") }), - z.object({ - type: z.literal("team"), - espId: z.string().min(1).optional(), - }), - ]) - .nullable() - .optional(), - viewInBrowser: z - .boolean() - .optional() - .describe( - 'false leaves the "View in browser" link out of later sends and takes down the web versions behind links already sent (e.g. for onboarding or trial emails). Can be changed at any time.', - ), - }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "update_sequence", + operationId: "sequences.update", + risk: "write", + description: + "Updates a broadcast/sequence's title, trigger (including event:custom with the event name in triggerData and an optional property filter), re-entry setting, audience filter, exclusion filter, selected user ESP, or whether its emails link a hosted web version (\"View in browser\"). A sent broadcast's title (its email's subject) can't be changed.", + inputSchema: z.object({ + sequenceId: z.string(), + title: z.string().optional(), + triggerType: updateSequenceBodySchema.shape.triggerType, + triggerData: updateSequenceBodySchema.shape.triggerData, + triggerFilter: updateSequenceBodySchema.shape.triggerFilter, + reentry: updateSequenceBodySchema.shape.reentry, + reentryPeriodDays: updateSequenceBodySchema.shape.reentryPeriodDays, + filter: z + .object({ + aggregator: z.enum(["and", "or"]), + filters: z.array(z.record(z.any())), + }) + .optional() + .describe( + "Audience filter (tag/email/subscription/signedUp/customField); empty means every subscribed contact", + ), + excludeFilter: contactFilterSchema + .nullable() + .optional() + .describe( + "Contacts matching this filter never receive the email, even if they match `filter` (e.g. leave out customers). null clears it.", + ), + emailsOrder: z.array(z.string()).optional(), + deliverySource: deliverySourceSchema.nullable().optional(), + viewInBrowser: z + .boolean() + .optional() + .describe( + 'false leaves the "View in browser" link out of later sends and takes down the web versions behind links already sent (e.g. for onboarding or trial emails). Can be changed at any time.', + ), + }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { const { sequenceId, ...patch } = args; try { const sequence = await updateSequence({ - teamId, + teamId: context.teamId, sequenceId, ...patch, - }); - if (!sequence) return NOT_FOUND; - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - return errorResult(err.message); + } as Parameters[0]); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); } }, - ); + }), - server.registerTool( - "add_sequence_email", - { - description: - "Adds a new email step to a sequence from a marketing template. Transactional templates return template_not_marketing.", - inputSchema: { - sequenceId: z.string(), - templateId: z.string().min(1), - }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "add_sequence_email", + operationId: "sequences.addEmail", + risk: "write", + description: + "Adds a new email step to a sequence from a marketing template. Transactional templates return template_not_marketing.", + inputSchema: z.object({ + sequenceId: z.string(), + templateId: z.string().min(1), + }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { const sequence = await addMailToSequence({ - teamId, + teamId: context.teamId, sequenceId: args.sequenceId, templateId: args.templateId, }); - if (!sequence) return NOT_FOUND; - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - return errorResult(err.message); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); } }, - ); + }), - server.registerTool( - "update_sequence_email", - { - description: - 'Updates one email within a sequence/broadcast: subject, content, delay, publish status, or the tag action fired on send. Edits apply to later sends only; the "view in browser" page of an email already sent keeps showing what was sent. A sent broadcast can\'t be edited.', - inputSchema: { - sequenceId: z.string(), - emailId: z.string(), - subject: z.string().optional(), - content: emailContentInputSchema.optional(), - delayInMillis: z - .number() - .optional() - .describe( - "Sequences: milliseconds after the previous email, or after the contact enters for the first email. Broadcasts: the send time (epoch millis)", - ), - actionType: z.enum(emailActionTypes).optional(), - actionData: z.record(z.any()).optional().describe('e.g. { "tag": "vip" }'), - published: z - .boolean() - .optional() - .describe("Must be true before the sequence/broadcast can be started"), - }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "update_sequence_email", + operationId: "sequences.updateEmail", + risk: "write", + description: + 'Updates one email within a sequence/broadcast: subject, content, delay, publish status, or the tag action fired on send. Edits apply to later sends only; the "view in browser" page of an email already sent keeps showing what was sent. A sent broadcast can\'t be edited.', + inputSchema: z.object({ + sequenceId: z.string(), + emailId: z.string(), + subject: z.string().optional(), + content: emailContentInputSchema.optional(), + delayInMillis: z + .number() + .optional() + .describe( + "Sequences: milliseconds after the previous email, or after the contact enters for the first email. Broadcasts: the send time (epoch millis)", + ), + actionType: z.enum(emailActionTypes).optional(), + actionData: z.record(z.any()).optional().describe('e.g. { "tag": "vip" }'), + published: z + .boolean() + .optional() + .describe("Must be true before the sequence/broadcast can be started"), + }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { const { sequenceId, emailId, ...patch } = args; try { const sequence = await updateMailInSequence({ - teamId, + teamId: context.teamId, sequenceId, emailId, ...patch, - }); - if (!sequence) return NOT_FOUND; - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); - - server.registerTool( - "send_sequence_email_test", - { - description: - "Sends a test of one broadcast/sequence email, rendered exactly like a real send (merge tags, footer, tracking) with a [Test] subject prefix; its \"view in browser\" link shows the email as tested. Defaults to the current user's own address (OAuth sessions only; API-key sessions must supply `to`). Suppressed addresses are skipped; opens, clicks and the unsubscribe link in a test change nothing. Every recipient counts as one test: by default 100 per team in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours (errors `test_send_limit_reached`, `test_send_email_limit_reached`). Tests through an organization's shared mailbox also use the team's shared-delivery quota.", - inputSchema: { - sequenceId: z.string(), - emailId: z.string(), - to: z.array(z.string().email()).max(5).optional().describe("Up to 5 addresses"), - }, - outputSchema: sequenceEmailTestResultSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: true, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - return jsonResult( - await sendSequenceEmailTest({ - teamId, - sequenceId: args.sequenceId, - emailId: args.emailId, - to: args.to, - fallbackRecipient: getAuthUser(extra)?.email, - }), - ); - } catch (err: any) { - if (isPlanGateError(err)) return planGateResult(err); - if (err.message === "sequence_not_found" || err.message === "email_not_found") { - return NOT_FOUND; - } - return errorResult(err.message); + } as Parameters[0]); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); } }, - ); + }), - server.registerTool( - "delete_sequence_email", - { - description: - "Removes an email from a sequence. Broadcasts cannot have their only email removed. Images only this email used are deleted from the media library, unless it was already sent.", - inputSchema: { sequenceId: z.string(), emailId: z.string() }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "send_sequence_email_test", + operationId: "sequences.sendEmailTest", + risk: "write", + description: + "Sends a test of one broadcast/sequence email, rendered exactly like a real send (merge tags, footer, tracking) with a [Test] subject prefix; its \"view in browser\" link shows the email as tested. Defaults to the current user's own address (OAuth sessions only; API-key sessions must supply `to`). Suppressed addresses are skipped; opens, clicks and the unsubscribe link in a test change nothing. Every recipient counts as one test: by default 100 per team in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours (errors `test_send_limit_reached`, `test_send_email_limit_reached`). Tests through an organization's shared mailbox also use the team's shared-delivery quota.", + inputSchema: z.object({ + sequenceId: z.string(), + emailId: z.string(), + to: z.array(z.string().email()).max(5).optional().describe("Up to 5 addresses"), + }), + outputSchema: sequenceEmailTestResultSchema, + annotations: { openWorldHint: true }, + async handler({ context, args }) { try { - const sequence = await deleteMailFromSequence({ - teamId, + return await sendSequenceEmailTest({ + teamId: context.teamId, sequenceId: args.sequenceId, emailId: args.emailId, + to: args.to, + // OAuth sessions default to the signed-in person; a team API key + // has no single owner, so it must pass `to`. + fallbackRecipient: + context.authKind === "oauth" ? context.user?.email : undefined, }); - if (!sequence) return NOT_FOUND; - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); - - server.registerTool( - "preview_sequence_deletion", - { - description: - "Shows what delete_sequence would remove without changing anything: how many contacts are enrolled, how many emails it has sent, and whether it can be deleted now (blocker sequence_active means pause it first; broadcast_sent means archive the broadcast with archive_broadcast instead).", - inputSchema: { sequenceId: z.string() }, - outputSchema: sequenceDeletionPreviewSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const preview = await getSequenceDeletionImpact(teamId, args.sequenceId); - if (!preview) return NOT_FOUND; - return jsonResult(preview); - }, - ); - - server.registerTool( - "delete_sequence", - { - description: - "Permanently deletes a broadcast or sequence with its emails and triggers. An active sequence or sending broadcast must be paused first (pause_sequence). A draft or scheduled broadcast can be deleted; a broadcast that has started sending can't (broadcast_sent): use archive_broadcast. Contacts still enrolled leave it, and its delivery, open and click history (its stats) is deleted and can't be recovered; call preview_sequence_deletion first and confirm with the user. Images only it used are deleted; images in sent mail are kept.", - inputSchema: { sequenceId: z.string() }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const deleted = await deleteSequence({ teamId, sequenceId: args.sequenceId }); - if (!deleted) return NOT_FOUND; - return jsonResult({ message: "Sequence deleted." }); - } catch (err: any) { - return errorResult(sequenceDeleteBlockerMessage(err.message) ?? err.message); + } catch (err) { + return sequenceError(err); } }, - ); - - for (const archived of [true, false]) { - server.registerTool( - archived ? "archive_broadcast" : "unarchive_broadcast", - { - description: archived - ? "Archives a sent broadcast: it leaves the broadcasts list (list_sequences with archived=only shows it) and the public web archive, feed and sitemap. Its report, delivery history and images are kept. Drafts and scheduled broadcasts can't be archived; delete them with delete_sequence." - : "Restores an archived broadcast to the broadcasts list, and to the web archive when it is published there.", - inputSchema: { sequenceId: z.string() }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const sequence = await setBroadcastArchived({ - teamId, - sequenceId: args.sequenceId, - archived, - }); - if (!sequence) return NOT_FOUND; - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - const message = - broadcastArchiveErrors[err.message as keyof typeof broadcastArchiveErrors]; - return errorResult(message ?? err.message); - } - }, - ); - } + }), - server.registerTool( - "start_sequence", - { - description: - "Starts a broadcast (schedules it for delivery) or activates a sequence (begins enrolling contacts on its trigger). Requires a mailing address and at least one published email. On SendLit Cloud, a new organization's first broadcast to 1,000 or more contacts through its own ESP is reviewed once first (error `broadcast_held_for_review`); start it again once approved.", - inputSchema: { sequenceId: z.string() }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "start_sequence", + operationId: "sequences.start", + risk: "write", + description: + "Starts a broadcast (schedules it for delivery) or activates a sequence (begins enrolling contacts on its trigger). Requires a mailing address and at least one published email. On SendLit Cloud, a new organization's first broadcast to 1,000 or more contacts through its own ESP is reviewed once first (error `broadcast_held_for_review`); start it again once approved.", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { const sequence = await startSequence({ - teamId, + teamId: context.teamId, sequenceId: args.sequenceId, }); - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - if (isPlanGateError(err)) return planGateResult(err); - return errorResult(err.message); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); } }, - ); + }), - server.registerTool( - "pause_sequence", - { - description: - "Pauses an active sequence, or an active broadcast that hasn't sent yet.", - inputSchema: { sequenceId: z.string() }, - outputSchema: sequenceSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "pause_sequence", + operationId: "sequences.pause", + risk: "write", + description: + "Pauses an active sequence, or an active broadcast that hasn't sent yet.", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { const sequence = await pauseSequence({ - teamId, + teamId: context.teamId, sequenceId: args.sequenceId, }); - return jsonResult(toPublicSequence(sequence)); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); - - server.registerTool( - "get_sequence_stats", - { - description: - "Returns delivery stats for a broadcast/sequence: emails sent, recipient count, open rate and click-through rate.", - inputSchema: { sequenceId: z.string() }, - outputSchema: sequenceStatsSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const sequence = await getSequenceBySequenceId(teamId, args.sequenceId); - if (!sequence) return NOT_FOUND; - const [sent, openRate, clickThroughRate, subscribersCount, rates] = - await Promise.all([ - getEmailSentCount(args.sequenceId), - getSequenceOpenRate(args.sequenceId), - getSequenceClickThroughRate(args.sequenceId), - getSubscribersCount(args.sequenceId), - sequenceDeliveryRates(sequence.id), - ]); - return jsonResult({ - sent, - openRate, - clickThroughRate, - subscribersCount, - bounceRate: rates.bounceRate, - complaintRate: rates.complaintRate, - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "get_sequence_schedule", - { - description: - "Returns, for each email of a broadcast/sequence in send order, how many contacts will get it next and when the first of them is due (epoch millis), plus the total waiting. Use it to check that delays work as intended: a sequence that has sent nothing yet can still have contacts waiting.", - inputSchema: { sequenceId: z.string() }, - outputSchema: sequenceScheduleSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const sequence = await getSequenceBySequenceId(teamId, args.sequenceId); - if (!sequence) return NOT_FOUND; - return jsonResult(await getSequenceSchedule(sequence)); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "list_sequence_email_waiting_contacts", - { - description: - "Lists the contacts whose next email in a sequence is this one, soonest due first, each with their own due time (epoch millis): each contact's delay counts from their own entry. q matches email or name. Returns up to limit (default 10, at most 100) with total and nextCursor; pass nextCursor as after for the next page.", - inputSchema: { - sequenceId: z.string(), - emailId: z.string(), - after: z.string().optional().describe("The previous page's nextCursor"), - limit: z.number().int().min(1).max(100).optional(), - q: z.string().optional().describe("Matches the contact's email or name"), - }, - outputSchema: waitingContactsSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const sequence = await getSequenceBySequenceId(teamId, args.sequenceId); - if (!sequence?.emailsOrder.includes(args.emailId)) return NOT_FOUND; - return jsonResult( - await getWaitingContacts(sequence, args.emailId, { - after: args.after, - limit: args.limit, - q: args.q, - }), - ); + return toPublicSequence(sequence); } catch (err) { - if (err instanceof InvalidCursorError) return errorResult("invalid_cursor"); - return INTERNAL_ERROR; + return sequenceError(err); } }, - ); + }), - server.registerTool( - "get_sequence_subscribers", - { - description: - "Returns a paginated list of contact IDs that have received at least one email in this sequence or broadcast.", - inputSchema: { - sequenceId: z.string().describe("Sequence or broadcast ID"), - page: z.number().int().min(1).optional().describe("Page number (default: 1)"), - limit: z - .number() - .int() - .min(1) - .max(200) - .optional() - .describe("Results per page (default: 50)"), - }, - outputSchema: z.object({ - items: z.array(z.string()), - total: z.number(), - }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const sequence = await getSequenceBySequenceId(teamId, args.sequenceId); - if (!sequence) return NOT_FOUND; - const [items, total] = await Promise.all([ - getSubscribers({ - sequenceId: args.sequenceId, - page: args.page, - limit: args.limit, - }), + dedicatedTool({ + name: "get_sequence_stats", + operationId: "sequences.stats", + risk: "read", + description: + "Returns delivery stats for a broadcast/sequence: emails sent, recipient count, open rate and click-through rate.", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: sequenceStatsSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const sequence = await getSequenceBySequenceId(context.teamId, args.sequenceId); + if (!sequence) return notFound(); + const [sent, openRate, clickThroughRate, subscribersCount, rates] = + await Promise.all([ + getEmailSentCount(args.sequenceId), + getSequenceOpenRate(args.sequenceId), + getSequenceClickThroughRate(args.sequenceId), getSubscribersCount(args.sequenceId), + sequenceDeliveryRates(sequence.id), ]); - return jsonResult({ items, total }); - } catch { - return INTERNAL_ERROR; - } - }, - ); -} + return { + sent, + openRate, + clickThroughRate, + subscribersCount, + bounceRate: rates.bounceRate, + complaintRate: rates.complaintRate, + }; + }, + }), +]; diff --git a/apps/api/src/mcp/tools/tags.ts b/apps/api/src/mcp/tools/tags.ts deleted file mode 100644 index e0e1e240..00000000 --- a/apps/api/src/mcp/tools/tags.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - deleteTagResultSchema, - renameTagResultSchema, - tagReferencesSchema, - tagSummarySchema, -} from "@sendlit/api-contract"; -import { z } from "zod"; -import { - deleteTag, - getTagReferences, - listTags, - renameTag, -} from "../../contacts/tags-queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { - AUTH_ERROR, - errorResult, - INTERNAL_ERROR, - jsonResult, - NOT_FOUND, -} from "./responses"; - -const tagInput = { tag: z.string().min(1).describe("The tag, exactly as stored") }; - -export function registerTagTools(server: McpToolRegistrar): void { - server.registerTool( - "list_tags", - { - description: - "Lists every tag carried by at least one of the team's contacts, sorted by name, with how many contacts carry it.", - outputSchema: z.object({ items: z.array(tagSummarySchema) }), - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - return jsonResult({ items: await listTags(teamId) }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "get_tag_references", - { - description: - "Shows how many contacts carry a tag and which segments, broadcasts, sequences (audience filters, tag triggers, email tag actions) and signup forms name it. Check it before deleting or renaming a tag.", - inputSchema: tagInput, - outputSchema: tagReferencesSchema, - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - return jsonResult(await getTagReferences(teamId, args.tag)); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "rename_tag", - { - description: - "Renames a tag on every contact and in the segments, broadcast and sequence filters and tag triggers, sequence email tag actions and signup forms that name it. Renaming onto an existing tag merges the two. Sent broadcasts keep their original filters. Doesn't trigger tag-added or tag-removed sequences.", - inputSchema: { - ...tagInput, - name: z.string().trim().min(1).describe("The tag's new name"), - }, - outputSchema: renameTagResultSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const result = await renameTag(teamId, args.tag, args.name); - if (result === "tag_not_found") return NOT_FOUND; - if (result === "tag_unchanged") return errorResult("tag_unchanged"); - return jsonResult(result); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "delete_tag", - { - description: - "Removes a tag from every contact of the team. This can't be undone. Segments, filters, triggers, email actions and signup forms that name the tag are left unchanged and returned in `references`: a filter on a deleted tag matches nobody, and a trigger or form can add the tag again. Call get_tag_references first and confirm with the user. Doesn't trigger tag-removed sequences.", - inputSchema: tagInput, - outputSchema: deleteTagResultSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const result = await deleteTag(teamId, args.tag); - if (!result) return NOT_FOUND; - return jsonResult(result); - } catch { - return INTERNAL_ERROR; - } - }, - ); -} diff --git a/apps/api/src/mcp/tools/teams.test.ts b/apps/api/src/mcp/tools/teams.test.ts new file mode 100644 index 00000000..4282853b --- /dev/null +++ b/apps/api/src/mcp/tools/teams.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const getTeam = vi.fn(); +const usageForOrganization = vi.fn(); +const getOrganizationEntitlements = vi.fn(); +mock.module("../../team/queries", () => ({ getTeam })); +mock.module("../../billing/usage.js", () => ({ usageForOrganization })); +mock.module("../../billing/entitlements.js", () => ({ getOrganizationEntitlements })); + +const { teamTools } = await import("./teams"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-internal-1", + user: null, + scopes: [], + requestId: "", + clientIp: "", + replayHeaders: {}, +}; +const [tool] = teamTools(); + +beforeEach(() => { + for (const fn of [getTeam, usageForOrganization, getOrganizationEntitlements]) { + fn.mockReset(); + } +}); + +describe("MCP get_plan_usage", () => { + it("is the only dedicated team tool", () => { + expect(teamTools().map((t) => t.name)).toEqual(["get_plan_usage"]); + }); + + it("reports usage and limits for the team's organization", async () => { + getTeam.mockResolvedValue({ id: "team-internal-1", organizationId: "org-1" }); + usageForOrganization.mockResolvedValue({ + teams: 1, + subscribedContacts: 10, + monthlySends: 5, + monthlySendsReserved: 0, + bucketStartsAt: "2026-11-01T00:00:00.000Z", + bucketEndsAt: "2026-12-01T00:00:00.000Z", + }); + getOrganizationEntitlements.mockResolvedValue({ + plan: "starter", + paymentStatus: "active", + cancelAtPeriodEnd: false, + paidThroughAt: new Date("2026-12-01T00:00:00.000Z"), + teamsLimit: 1, + subscribedContactsLimit: 1000, + monthlySendsLimit: 10000, + }); + const result = await tool!.handler({ context, args: {}, signal }); + // The context carries the internal team id. + expect(getTeam).toHaveBeenCalledWith("team-internal-1"); + expect(usageForOrganization).toHaveBeenCalledWith("org-1"); + expect(result).toMatchObject({ + plan: "starter", + paidThroughAt: "2026-12-01T00:00:00.000Z", + monthlySendsLimit: 10000, + }); + expect(tool!.outputSchema!.safeParse(result).success).toBe(true); + }); + + it("returns not_found when the team is gone", async () => { + getTeam.mockResolvedValue(null); + await expect(tool!.handler({ context, args: {}, signal })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); +}); diff --git a/apps/api/src/mcp/tools/teams.ts b/apps/api/src/mcp/tools/teams.ts index be8f1057..1d0b4927 100644 --- a/apps/api/src/mcp/tools/teams.ts +++ b/apps/api/src/mcp/tools/teams.ts @@ -1,332 +1,57 @@ import { billingPlanSchema } from "@sendlit/api-contract"; import { z } from "zod"; -import { createApiKey, deleteApiKey, getApiKeysByTeamId } from "../../apikey/queries"; -import { getOrganizationMembership } from "../../organization/queries"; -import { - archiveTeam, - createTeam, - getTeamByTeamId, - getTeamMembership, - listTeamViewsForUser, - renameTeam, -} from "../../team/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getAuthUser, getTeamId } from "./auth"; -import { - AUTH_ERROR, - INTERNAL_ERROR, - isPlanGateError, - jsonResult, - NOT_FOUND, - planGateResult, -} from "./responses"; -import { - apiKeySchema, - createdApiKeySchema, - successMessageSchema, - teamSchema, -} from "./schemas"; - -export function registerTeamTools(server: McpToolRegistrar): void { - server.registerTool( - "list_teams", - { - description: "Returns all teams the authenticated user belongs to.", - outputSchema: z.object({ items: z.array(teamSchema) }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (extra: any) => { - const user = getAuthUser(extra); - if (!user) return AUTH_ERROR; - try { - const teams = await listTeamViewsForUser(user.id); - return jsonResult({ - items: teams.map((t) => ({ - teamId: t.teamId, - name: t.name, - organizationId: t.organizationPublicId, - organizationName: t.organizationName, - })), - }); - } catch (error) { - if (isPlanGateError(error)) return planGateResult(error); - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "get_plan_usage", - { - description: - "Returns plan usage and limits for the current team's organization. When cancelAtPeriodEnd is true the paid plan ends at paidThroughAt.", - outputSchema: z.object({ - plan: billingPlanSchema, - paymentStatus: z.string(), - cancelAtPeriodEnd: z.boolean(), - paidThroughAt: z.string().nullable(), - teams: z.number().int().nonnegative(), - subscribedContacts: z.number().int().nonnegative(), - monthlySends: z.number().int().nonnegative(), - monthlySendsReserved: z.number().int().nonnegative(), - bucketStartsAt: z.string(), - bucketEndsAt: z.string(), - teamsLimit: z.number().int().positive().nullable(), - subscribedContactsLimit: z.number().int().positive().nullable(), - monthlySendsLimit: z.number().int().positive().nullable(), - }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (_args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const team = await getTeamByTeamId(teamId); - if (!team) return NOT_FOUND; - const { usageForOrganization } = await import("../../billing/usage.js"); - const { getOrganizationEntitlements } = await import( - "../../billing/entitlements.js" - ); - const [usage, entitlements] = await Promise.all([ - usageForOrganization(team.organizationId), - getOrganizationEntitlements(team.organizationId), - ]); - return jsonResult({ - ...usage, - plan: entitlements.plan, - paymentStatus: entitlements.paymentStatus, - cancelAtPeriodEnd: entitlements.cancelAtPeriodEnd, - paidThroughAt: entitlements.paidThroughAt?.toISOString() ?? null, - teamsLimit: entitlements.teamsLimit, - subscribedContactsLimit: entitlements.subscribedContactsLimit, - monthlySendsLimit: entitlements.monthlySendsLimit, - }); - } catch (error) { - if (isPlanGateError(error)) return planGateResult(error); - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "create_team", - { - description: - "Creates a new team in the authenticated user's default organization. Subject to the parent organization's plan team limit.", - inputSchema: { - name: z.string().min(1).describe("Team name"), - }, - outputSchema: teamSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const user = getAuthUser(extra); - if (!user?.defaultOrganizationId) return AUTH_ERROR; - try { - const membership = await getOrganizationMembership( - user.defaultOrganizationId, - user.id, - ); - if (!membership || !["owner", "admin"].includes(membership.role)) - return AUTH_ERROR; - const team = await createTeam({ - organizationId: user.defaultOrganizationId, - creatorUserId: user.id, - name: args.name, - }); - return jsonResult({ - teamId: team.teamId, - name: team.name, - }); - } catch (error) { - if (isPlanGateError(error)) return planGateResult(error); - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "rename_team", - { - description: "Renames the current team.", - inputSchema: { - name: z.string().min(1).describe("New team name"), - }, - outputSchema: teamSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const updated = await renameTeam(teamId, args.name); - if (!updated) return NOT_FOUND; - return jsonResult({ - teamId: updated.teamId, - name: updated.name, - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "delete_team", - { - description: "Archives a team. Organization administration is required.", - inputSchema: { - teamId: z.string().describe("Team ID"), - }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const user = getAuthUser(extra); - if (!user) return AUTH_ERROR; - try { - const team = await getTeamByTeamId(args.teamId); - if (!team) return NOT_FOUND; - const membership = await getTeamMembership(team.id, user.id); - if (!membership) return NOT_FOUND; - const organizationMembership = await getOrganizationMembership( - team.organizationId, - user.id, - ); - if ( - !organizationMembership || - !["owner", "admin"].includes(organizationMembership.role) - ) { - return { - content: [ - { - type: "text" as const, - text: "Organization administration is required.", - }, - ], - isError: true, - }; - } - await archiveTeam(team.id); - return jsonResult({ message: "Team archived." }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "list_api_keys", - { - description: - "Returns all API keys for the current team. Secrets are stored hashed, so only each key's display prefix is included.", - outputSchema: z.object({ items: z.array(apiKeySchema) }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const keys = await getApiKeysByTeamId(teamId); - return jsonResult({ - items: keys.map((k) => ({ - id: k.teamApiKeyId, - keyPrefix: k.keyPrefix, - name: k.name, - createdAt: k.createdAt, - })), - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "create_api_key", - { - description: - "Creates a new API key for the current team. The full key value is only returned once — store it securely.", - inputSchema: { - name: z.string().min(1).describe("Human-readable label for this key"), - }, - outputSchema: createdApiKeySchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const user = getAuthUser(extra); - const { apiKey, secret } = await createApiKey(teamId, args.name, { - createdByType: user ? "user" : "system", - createdById: user?.id, - }); - return jsonResult({ - id: apiKey.teamApiKeyId, - key: secret, - keyPrefix: apiKey.keyPrefix, - name: apiKey.name, - createdAt: apiKey.createdAt, - }); - } catch { - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "delete_api_key", - { - description: - "Permanently deletes an API key. Any integrations using it will stop working immediately.", - inputSchema: { - keyId: z.string().describe("Id of the API key to delete (from list_api_keys)"), - }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - await deleteApiKey(teamId, args.keyId); - return jsonResult({ message: "API key deleted." }); - } catch { - return INTERNAL_ERROR; - } - }, - ); -} +import { getTeam } from "../../team/queries"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound } from "./responses"; + +/** Teams and API keys are reachable through the generic `api.*` tools. */ +export const teamTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "get_plan_usage", + operationId: "billing.organizationPlanUsage", + risk: "read", + description: + "Returns plan usage and limits for the current team's organization. When cancelAtPeriodEnd is true the paid plan ends at paidThroughAt.", + inputSchema: z.object({}), + outputSchema: z.object({ + plan: billingPlanSchema, + paymentStatus: z.string(), + cancelAtPeriodEnd: z.boolean(), + paidThroughAt: z.string().nullable(), + teams: z.number().int().nonnegative(), + subscribedContacts: z.number().int().nonnegative(), + monthlySends: z.number().int().nonnegative(), + monthlySendsReserved: z.number().int().nonnegative(), + bucketStartsAt: z.string(), + bucketEndsAt: z.string(), + teamsLimit: z.number().int().positive().nullable(), + subscribedContactsLimit: z.number().int().positive().nullable(), + monthlySendsLimit: z.number().int().positive().nullable(), + }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context }) { + // `context.teamId` is the internal id. + const team = await getTeam(context.teamId); + if (!team) return notFound(); + const { usageForOrganization } = await import("../../billing/usage.js"); + const { getOrganizationEntitlements } = await import( + "../../billing/entitlements.js" + ); + const [usage, entitlements] = await Promise.all([ + usageForOrganization(team.organizationId), + getOrganizationEntitlements(team.organizationId), + ]); + return { + ...usage, + plan: entitlements.plan, + paymentStatus: entitlements.paymentStatus, + cancelAtPeriodEnd: entitlements.cancelAtPeriodEnd, + paidThroughAt: entitlements.paidThroughAt?.toISOString() ?? null, + teamsLimit: entitlements.teamsLimit, + subscribedContactsLimit: entitlements.subscribedContactsLimit, + monthlySendsLimit: entitlements.monthlySendsLimit, + }; + }, + }), +]; diff --git a/apps/api/src/mcp/tools/templates.test.ts b/apps/api/src/mcp/tools/templates.test.ts new file mode 100644 index 00000000..8544b57c --- /dev/null +++ b/apps/api/src/mcp/tools/templates.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + createTemplate: vi.fn(), + getTemplate: vi.fn(), + listTemplates: vi.fn(), + updateTemplate: vi.fn(), +}; + +mock.module("../../templates/queries", () => ({ + createTemplate: mocks.createTemplate, + getTemplate: mocks.getTemplate, + listTemplates: mocks.listTemplates, + updateTemplate: mocks.updateTemplate, +})); + +const { templateTools } = await import("./templates"); +const { TemplateValidationError } = await import("../../templates/validation"); + +const teamKey = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", + clientIp: "", + replayHeaders: {}, +}; + +const tools = new Map(templateTools().map((tool) => [tool.name, tool])); + +function call(name: string, args: Record, context: any = teamKey) { + const tool = tools.get(name); + if (!tool) throw new Error(`unknown tool ${name}`); + return tool.handler({ context, args, signal: new AbortController().signal }); +} + +const template = { + id: "internal-template", + teamId: "team-1", + templateId: "tpl_1", + title: "Receipt", + purpose: "transactional", + content: { style: {}, meta: {}, content: [] }, + requiredVariables: ["order.id"], + createdAt: new Date("2026-10-28T00:00:00.000Z"), + updatedAt: null, +}; + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP template tools", () => { + it("keeps only the dedicated template tools", () => { + expect([...tools.keys()]).toEqual([ + "list_templates", + "get_template", + "create_template", + "update_template", + ]); + }); + + it("keeps template ID inputs non-empty", () => { + for (const name of ["get_template", "update_template"]) { + expect(tools.get(name)!.inputSchema.safeParse({ templateId: "" }).success).toBe( + false, + ); + } + }); + + it("refuses an OAuth token without the templates scope", async () => { + await expect( + call( + "create_template", + { title: "Receipt", purpose: "transactional", content: template.content }, + { ...teamKey, authKind: "oauth", scopes: ["templates:read"] }, + ), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "templates:write" }, + }, + }); + expect(mocks.createTemplate).not.toHaveBeenCalled(); + }); + + it("filters and creates templates without changing purpose", async () => { + mocks.listTemplates.mockResolvedValue([template]); + const listed: any = await call("list_templates", { purpose: "transactional" }); + expect(listed).toMatchObject({ + items: [ + { + templateId: "tpl_1", + purpose: "transactional", + requiredVariables: ["order.id"], + createdAt: "2026-10-28T00:00:00.000Z", + }, + ], + }); + expect(listed.items[0]).not.toHaveProperty("id"); + expect(listed.items[0]).not.toHaveProperty("teamId"); + expect(mocks.listTemplates).toHaveBeenCalledWith("team-1", "transactional"); + + mocks.createTemplate.mockResolvedValue(template); + await call("create_template", { + title: "Receipt", + purpose: "transactional", + content: template.content, + }); + expect(mocks.createTemplate).toHaveBeenCalledWith({ + teamId: "team-1", + title: "Receipt", + purpose: "transactional", + content: template.content, + }); + }); + + it("hides another team's template", async () => { + mocks.getTemplate.mockResolvedValue({ ...template, teamId: "team-2" }); + await expect(call("get_template", { templateId: "tpl_1" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("maps validation and duplicate-title failures", async () => { + mocks.createTemplate.mockRejectedValueOnce( + new TemplateValidationError("marketing_variables_not_allowed", ["contact.email"]), + ); + await expect( + call("create_template", { + title: "Receipt", + purpose: "transactional", + content: template.content, + }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { + reason: "marketing_variables_not_allowed", + variables: "contact.email", + }, + }, + }); + + mocks.updateTemplate.mockRejectedValueOnce(new Error("duplicate_title")); + await expect( + call("update_template", { templateId: "tpl_1", title: "Taken" }), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "duplicate_title" } }, + }); + + mocks.updateTemplate.mockResolvedValueOnce(null); + await expect( + call("update_template", { templateId: "tpl_x", title: "New" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + + mocks.updateTemplate.mockRejectedValueOnce(new Error("db down")); + await expect(call("update_template", { templateId: "tpl_1" })).rejects.toThrow( + "db down", + ); + }); +}); diff --git a/apps/api/src/mcp/tools/templates.ts b/apps/api/src/mcp/tools/templates.ts index 42b1029a..47e78e6a 100644 --- a/apps/api/src/mcp/tools/templates.ts +++ b/apps/api/src/mcp/tools/templates.ts @@ -2,279 +2,124 @@ import { emailContentInputSchema, templatePurposeSchema } from "@sendlit/api-con import { z } from "zod"; import { createTemplate, - deleteTemplate, - duplicateTemplate, getTemplate, listTemplates, updateTemplate, } from "../../templates/queries"; -import { SYSTEM_TEMPLATES } from "../../templates/system-templates"; import { TemplateValidationError } from "../../templates/validation"; import { omitInternal } from "../../utils/public"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { AUTH_ERROR, INTERNAL_ERROR, jsonResult, NOT_FOUND } from "./responses"; -import { successMessageSchema, systemTemplateSchema, templateSchema } from "./schemas"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound, type ToolError, toolError } from "./responses"; +import { templateSchema } from "./schemas"; -export function registerTemplateTools(server: McpToolRegistrar): void { - server.registerTool( - "list_system_templates", - { - description: - "Returns built-in marketing and transactional starting templates. System templates are starters and must be copied into a team-owned template before transactional sending.", - inputSchema: { - purpose: templatePurposeSchema.optional(), - }, - outputSchema: z.object({ items: z.array(systemTemplateSchema) }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return jsonResult({ - items: args.purpose - ? SYSTEM_TEMPLATES.filter((template) => template.purpose === args.purpose) - : SYSTEM_TEMPLATES, - }); - }, - ); +/** Template content the purpose's rules reject, with the variables at fault + * (comma-separated, as safe details are flat). */ +function templateValidationError(error: TemplateValidationError): ToolError { + return toolError("validation_failed", { + reason: error.message, + ...(error.variables?.length ? { variables: error.variables.join(", ") } : {}), + }); +} - server.registerTool( - "list_templates", - { - description: - "Returns reusable templates for the team, optionally filtered by marketing or transactional purpose.", - inputSchema: { - purpose: templatePurposeSchema.optional(), - }, - outputSchema: z.object({ items: z.array(templateSchema) }), - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const items = await listTemplates(teamId, args.purpose); - return jsonResult({ - items: items.map((item) => omitInternal(item)), - }); - } catch { - return INTERNAL_ERROR; - } +export const templateTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_templates", + operationId: "templates.list", + risk: "read", + description: + "Returns reusable templates for the team, optionally filtered by marketing or transactional purpose.", + inputSchema: z.object({ purpose: templatePurposeSchema.optional() }), + outputSchema: z.object({ items: z.array(templateSchema) }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const items = await listTemplates(context.teamId, args.purpose); + return { items: items.map((item) => omitInternal(item)) }; }, - ); + }), - server.registerTool( - "get_template", - { - description: "Returns a single email template by its template ID.", - inputSchema: { templateId: z.string().min(1) }, - outputSchema: templateSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, + dedicatedTool({ + name: "get_template", + operationId: "templates.get", + risk: "read", + description: "Returns a single email template by its template ID.", + inputSchema: z.object({ templateId: z.string().min(1) }), + outputSchema: templateSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const template = await getTemplate(args.templateId); + if (!template || template.teamId !== context.teamId) return notFound(); + return omitInternal(template); }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const template = await getTemplate(args.templateId); - if (!template || template.teamId !== teamId) return NOT_FOUND; - return jsonResult(omitInternal(template)); - } catch { - return INTERNAL_ERROR; - } - }, - ); + }), - server.registerTool( - "create_template", - { - description: - "Creates a reusable marketing or transactional email template. Marketing content requires one final managed footer; transactional content rejects that footer and marketing-only variables.", - inputSchema: { - title: z.string().min(1), - purpose: templatePurposeSchema, - content: emailContentInputSchema.describe( - "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, or footer blocks. Use list_system_templates and duplicate_template as the safest valid starting point.", - ), - }, - outputSchema: templateSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "create_template", + operationId: "templates.create", + risk: "write", + description: + "Creates a reusable marketing or transactional email template. Marketing content requires one final managed footer; transactional content rejects that footer and marketing-only variables.", + inputSchema: z.object({ + title: z.string().min(1), + purpose: templatePurposeSchema, + content: emailContentInputSchema.describe( + "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, or footer blocks. The safest valid starting point is a system template: list them with api.read (operation templates.listSystem) and copy one with api.write (templates.duplicate).", + ), + }), + outputSchema: templateSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { const template = await createTemplate({ - teamId, + teamId: context.teamId, title: args.title, purpose: args.purpose, - content: args.content, + content: args.content as Parameters[0]["content"], }); - return jsonResult(omitInternal(template)); + return omitInternal(template); } catch (error) { if (error instanceof TemplateValidationError) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ - error: error.message, - variables: error.variables, - }), - }, - ], - isError: true, - }; + return templateValidationError(error); } - return INTERNAL_ERROR; + throw error; } }, - ); + }), - server.registerTool( - "update_template", - { - description: "Updates a template's title and/or content.", - inputSchema: { - templateId: z.string().min(1), - title: z.string().min(1).optional(), - content: emailContentInputSchema.optional(), - }, - outputSchema: templateSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "update_template", + operationId: "templates.update", + risk: "write", + description: "Updates a template's title and/or content.", + inputSchema: z.object({ + templateId: z.string().min(1), + title: z.string().min(1).optional(), + content: emailContentInputSchema.optional(), + }), + outputSchema: templateSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { try { const template = await updateTemplate({ - teamId, + teamId: context.teamId, templateId: args.templateId, title: args.title, - content: args.content, + content: args.content as Parameters[0]["content"], }); - if (!template) return NOT_FOUND; - return jsonResult(omitInternal(template)); - } catch (err: any) { - if (err.message === "duplicate_title") { - return { - content: [ - { - type: "text" as const, - text: "A template with this title already exists.", - }, - ], - isError: true, - }; - } - if (err instanceof TemplateValidationError) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ - error: err.message, - variables: err.variables, - }), - }, - ], - isError: true, - }; - } - return INTERNAL_ERROR; - } - }, - ); - - server.registerTool( - "duplicate_template", - { - description: - "Copies a system or team template into a new team-owned template with the same purpose as the source.", - inputSchema: { - templateId: z.string().min(1), - title: z.string().min(1).optional(), - }, - outputSchema: templateSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - const template = await duplicateTemplate({ - teamId, - templateId: args.templateId, - title: args.title, - }); - if (!template) return NOT_FOUND; - return jsonResult(omitInternal(template)); + if (!template) return notFound(); + return omitInternal(template); } catch (error) { + if (error instanceof Error && error.message === "duplicate_title") { + return toolError("conflict", { + reason: "duplicate_title", + message: "A template with this title already exists.", + }); + } if (error instanceof TemplateValidationError) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ - error: error.message, - variables: error.variables, - }), - }, - ], - isError: true, - }; + return templateValidationError(error); } - return INTERNAL_ERROR; + throw error; } }, - ); - - server.registerTool( - "delete_template", - { - description: - "Permanently deletes an email template. Its images that no other template or email uses are deleted from the media library, unless a transactional email was sent with it.", - inputSchema: { templateId: z.string().min(1) }, - outputSchema: successMessageSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - await deleteTemplate(teamId, args.templateId); - return jsonResult({ message: "Template deleted." }); - } catch { - return INTERNAL_ERROR; - } - }, - ); -} + }), +]; diff --git a/apps/api/src/mcp/tools/tools.test.ts b/apps/api/src/mcp/tools/tools.test.ts deleted file mode 100644 index e34efb8d..00000000 --- a/apps/api/src/mcp/tools/tools.test.ts +++ /dev/null @@ -1,1612 +0,0 @@ -import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; - -const mocks = { - sendSequenceEmailTest: vi.fn(), - countContacts: vi.fn(), - createContact: vi.fn(), - getContactByContactId: vi.fn(), - getDeliveriesByContact: vi.fn(), - listContacts: vi.fn(), - updateContact: vi.fn(), - addTagToContact: vi.fn(), - removeTagFromContact: vi.fn(), - deleteContact: vi.fn(), - startContactDeletion: vi.fn(), - getContactDeletion: vi.fn(), - selectionCondition: vi.fn(), - countSelection: vi.fn(), - - createSequence: vi.fn(), - deleteSequence: vi.fn(), - setBroadcastArchived: vi.fn(), - getSequenceDeletionImpact: vi.fn(), - getSequenceBySequenceId: vi.fn(), - getEmailSentCount: vi.fn(), - getSequenceOpenRate: vi.fn(), - getSequenceClickThroughRate: vi.fn(), - getSequenceSchedule: vi.fn(), - getWaitingContacts: vi.fn(), - getSubscribersCount: vi.fn(), - getSubscribers: vi.fn(), - listSequences: vi.fn(), - countSequences: vi.fn(), - updateSequence: vi.fn(), - addMailToSequence: vi.fn(), - updateMailInSequence: vi.fn(), - deleteMailFromSequence: vi.fn(), - startSequence: vi.fn(), - pauseSequence: vi.fn(), - - getEspConfig: vi.fn(), - upsertEspConfig: vi.fn(), - deleteEspConfig: vi.fn(), - recordEspTestResult: vi.fn(), - listEspConfigs: vi.fn(), - createEspConfig: vi.fn(), - getEspConfigByEspId: vi.fn(), - transitionEspConfig: vi.fn(), - updateEspConfig: vi.fn(), - invalidateTeamTransport: vi.fn(), - invalidateEspTransport: vi.fn(), - sendTestMail: vi.fn(), - - getTeam: vi.fn(), - getTeamByTeamId: vi.fn(), - getTeamMembership: vi.fn(), - listTeamViewsForUser: vi.fn(), - createTeam: vi.fn(), - archiveTeam: vi.fn(), - renameTeam: vi.fn(), - getOrganizationMembership: vi.fn(), - createApiKey: vi.fn(), - deleteApiKey: vi.fn(), - getApiKeysByTeamId: vi.fn(), - - createTemplate: vi.fn(), - deleteTemplate: vi.fn(), - duplicateTemplate: vi.fn(), - getTemplate: vi.fn(), - listTemplates: vi.fn(), - updateTemplate: vi.fn(), - - countMedia: vi.fn(), - deleteUnusedMedia: vi.fn(), - getMediaByMediaId: vi.fn(), - listMedia: vi.fn(), - listMediaReferences: vi.fn(), - updateMediaMetadata: vi.fn(), - - createTransactionalEmail: vi.fn(), - getTransactionalEmailByTxeId: vi.fn(), - listTransactionalEmails: vi.fn(), - countTransactionalEmails: vi.fn(), - toPublicTransactionalEmail: vi.fn(), - - trackEvent: vi.fn(), - listEventDefinitions: vi.fn(), - getEventDefinition: vi.fn(), - createEventDefinition: vi.fn(), - updateEventDefinition: vi.fn(), - deleteEventDefinition: vi.fn(), - listContactEvents: vi.fn(), -}; - -class FakeEventsError extends Error { - constructor(readonly code: string) { - super(code); - } -} -mock.module("../../events/queries", () => ({ - EventsError: FakeEventsError, - trackEvent: mocks.trackEvent, - listEventDefinitions: mocks.listEventDefinitions, - getEventDefinition: mocks.getEventDefinition, - createEventDefinition: mocks.createEventDefinition, - updateEventDefinition: mocks.updateEventDefinition, - deleteEventDefinition: mocks.deleteEventDefinition, - listContactEvents: mocks.listContactEvents, -})); - -mock.module("../../contacts/queries", () => ({ - countContacts: mocks.countContacts, - createContact: mocks.createContact, - getContactByContactId: mocks.getContactByContactId, - getDeliveriesByContact: mocks.getDeliveriesByContact, - listContacts: mocks.listContacts, - updateContact: mocks.updateContact, - addTagToContact: mocks.addTagToContact, - removeTagFromContact: mocks.removeTagFromContact, - deleteContact: mocks.deleteContact, -})); - -mock.module("../../contacts/bulk-delete", () => ({ - selectionCondition: mocks.selectionCondition, - countSelection: mocks.countSelection, -})); -mock.module("../../contacts/deletion-queue", () => ({ - startContactDeletion: mocks.startContactDeletion, - getContactDeletion: mocks.getContactDeletion, -})); -mock.module("../../contacts/import", () => ({ importContacts: vi.fn() })); -mock.module("../../contacts/export", () => ({ exportContactsCsv: vi.fn() })); - -mock.module("../../contacts/segments-queries", () => ({ - createSegment: vi.fn(), - deleteSegment: vi.fn(), - getSegment: vi.fn(), - listSegments: vi.fn(), - updateSegment: vi.fn(), -})); - -mock.module("../../sequences/queries", () => ({ - createSequence: mocks.createSequence, - getSequenceBySequenceId: mocks.getSequenceBySequenceId, - getEmailSentCount: mocks.getEmailSentCount, - getSequenceOpenRate: mocks.getSequenceOpenRate, - getSequenceClickThroughRate: mocks.getSequenceClickThroughRate, - getSubscribersCount: mocks.getSubscribersCount, - getSequenceSchedule: mocks.getSequenceSchedule, - getWaitingContacts: mocks.getWaitingContacts, - InvalidCursorError: class InvalidCursorError extends Error {}, - getSubscribers: mocks.getSubscribers, - listSequences: mocks.listSequences, - countSequences: mocks.countSequences, - updateSequence: mocks.updateSequence, - addMailToSequence: mocks.addMailToSequence, - updateMailInSequence: mocks.updateMailInSequence, - deleteMailFromSequence: mocks.deleteMailFromSequence, - deleteSequence: mocks.deleteSequence, - setBroadcastArchived: mocks.setBroadcastArchived, - getSequenceDeletionImpact: mocks.getSequenceDeletionImpact, - startSequence: mocks.startSequence, - pauseSequence: mocks.pauseSequence, -})); - -mock.module("../../abuse/pacing-status", () => ({ broadcastPacing: async () => null })); -mock.module("../../automation/sending-health", () => ({ - sequenceDeliveryRates: async () => ({ bounceRate: 0, complaintRate: 0 }), -})); -mock.module("../../sequences/test-send", () => ({ - sendSequenceEmailTest: mocks.sendSequenceEmailTest, -})); - -mock.module("../../settings/esp/queries", () => ({ - checkAndRememberDomainAuthentication: vi.fn(), - decryptEspCredentials: vi.fn(), - getEspConfig: mocks.getEspConfig, - upsertEspConfig: mocks.upsertEspConfig, - deleteEspConfig: mocks.deleteEspConfig, - recordEspTestResult: mocks.recordEspTestResult, - listEspConfigs: mocks.listEspConfigs, - createEspConfig: mocks.createEspConfig, - getEspConfigByEspId: mocks.getEspConfigByEspId, - transitionEspConfig: mocks.transitionEspConfig, - updateEspConfig: mocks.updateEspConfig, -})); - -mock.module("../../mail/transport", () => ({ - invalidateTeamTransport: mocks.invalidateTeamTransport, - invalidateEspTransport: mocks.invalidateEspTransport, -})); - -mock.module("../../mail/send", () => ({ - sendTestMail: mocks.sendTestMail, -})); - -mock.module("../../team/queries", () => ({ - getTeam: mocks.getTeam, - getTeamByTeamId: mocks.getTeamByTeamId, - getTeamMembership: mocks.getTeamMembership, - listTeamViewsForUser: mocks.listTeamViewsForUser, - createTeam: mocks.createTeam, - archiveTeam: mocks.archiveTeam, - renameTeam: mocks.renameTeam, -})); - -mock.module("../../organization/queries", () => ({ - getOrganizationMembership: mocks.getOrganizationMembership, -})); - -mock.module("../../apikey/queries", () => ({ - createApiKey: mocks.createApiKey, - deleteApiKey: mocks.deleteApiKey, - getApiKeysByTeamId: mocks.getApiKeysByTeamId, -})); - -mock.module("../../templates/queries", () => ({ - createTemplate: mocks.createTemplate, - deleteTemplate: mocks.deleteTemplate, - duplicateTemplate: mocks.duplicateTemplate, - getTemplate: mocks.getTemplate, - listTemplates: mocks.listTemplates, - updateTemplate: mocks.updateTemplate, -})); - -mock.module("../../media/queries", () => ({ - countMedia: mocks.countMedia, - deleteUnusedMedia: mocks.deleteUnusedMedia, - getMediaByMediaId: mocks.getMediaByMediaId, - listMedia: mocks.listMedia, - listMediaReferences: mocks.listMediaReferences, - updateMediaMetadata: mocks.updateMediaMetadata, -})); - -mock.module("../../transactional/queries", () => ({ - createTransactionalEmail: mocks.createTransactionalEmail, - getTransactionalEmailByTxeId: mocks.getTransactionalEmailByTxeId, - listTransactionalEmails: mocks.listTransactionalEmails, - countTransactionalEmails: mocks.countTransactionalEmails, - toPublicTransactionalEmail: mocks.toPublicTransactionalEmail, -})); - -const { getAuthUser, getTeamId } = await import("./auth"); -const { registerContactTools } = await import("./contacts"); -const { registerEspTools } = await import("./esp"); -const { registerMediaTools } = await import("./media"); -const { AUTH_ERROR, jsonResult, NOT_FOUND } = await import("./responses"); -const { registerSequenceTools } = await import("./sequences"); -const { registerTeamTools } = await import("./teams"); -const { registerTemplateTools } = await import("./templates"); -const { registerTransactionalTools } = await import("./transactional"); -const { registerEventTools } = await import("./events"); -const { sequenceSchema: mcpSequenceSchema } = await import("./schemas"); - -type Tool = { - config: any; - handler: (args: any, extra?: any) => Promise; -}; - -function makeToolRegistry(register: (server: any) => void) { - const tools = new Map(); - register({ - registerTool: (name: string, config: any, handler: Tool["handler"]) => { - tools.set(name, { config, handler }); - }, - }); - return tools; -} - -const auth: any = { - http: { - authInfo: { - token: "test-token", - clientId: "test-client", - scopes: [], - extra: { - authKind: "oauth", - teamId: "team-1", - user: { - id: "user-1", - email: "owner@example.com", - name: "Owner", - }, - }, - }, - }, -}; - -const teamKeyAuth: any = { - http: { - authInfo: { - token: "tak_test", - clientId: "team-key:tak_test", - scopes: [], - extra: { - authKind: "team_key", - teamId: "team-1", - user: null, - }, - }, - }, -}; - -beforeEach(() => { - for (const mock of Object.values(mocks)) { - mock.mockReset(); - } -}); - -describe("MCP tool auth helpers and response helpers", () => { - it("extracts team/account auth context and emits structured JSON results", () => { - expect(getTeamId(auth)).toBe("team-1"); - expect(getTeamId({ http: { authInfo: {} } } as any)).toBeNull(); - expect(getAuthUser(auth)).toEqual(expect.objectContaining({ id: "user-1" })); - expect(getAuthUser({ http: { authInfo: {} } } as any)).toBeNull(); - - expect(jsonResult({ ok: true })).toEqual({ - content: [{ type: "text", text: JSON.stringify({ ok: true }) }], - structuredContent: { ok: true }, - }); - }); - - it("serializes dates consistently in text and structured results", () => { - const createdAt = new Date("2026-08-09T10:00:00.000Z"); - - expect(jsonResult({ data: { createdAt } })).toEqual({ - content: [ - { - type: "text", - text: JSON.stringify({ - data: { createdAt: "2026-08-09T10:00:00.000Z" }, - }), - }, - ], - structuredContent: { - data: { createdAt: "2026-08-09T10:00:00.000Z" }, - }, - }); - }); -}); - -describe("MCP contact tools", () => { - it("requires auth and scopes list/create calls to the resolved team", async () => { - const tools = makeToolRegistry(registerContactTools); - const firstContactId = crypto.randomUUID(); - const secondContactId = crypto.randomUUID(); - mocks.listContacts.mockResolvedValue([{ contactId: firstContactId }]); - mocks.countContacts.mockResolvedValue(1); - - await expect( - tools.get("list_contacts")!.handler({ q: "ada", offset: 2 }, {}), - ).resolves.toEqual(AUTH_ERROR); - expect(mocks.listContacts).not.toHaveBeenCalled(); - - await expect( - tools.get("list_contacts")!.handler({ q: "ada", offset: 2 }, auth), - ).resolves.toMatchObject({ - structuredContent: { - items: [{ contactId: firstContactId }], - total: 1, - }, - }); - expect(mocks.listContacts).toHaveBeenCalledWith({ - teamId: "team-1", - searchText: "ada", - offset: 2, - }); - - mocks.createContact.mockResolvedValue({ - contactId: secondContactId, - email: "reader@example.com", - subscribed: true, - customFields: { plan: "pro" }, - tags: [], - unsubscribeToken: "unsubscribe-token", - createdAt: new Date("2026-08-09T10:00:00.000Z"), - updatedAt: new Date("2026-08-09T10:00:00.000Z"), - }); - const createResult = await tools - .get("create_contact")! - .handler({ email: "reader@example.com", customFields: { plan: "pro" } }, auth); - expect(mocks.createContact).toHaveBeenCalledWith({ - teamId: "team-1", - email: "reader@example.com", - customFields: { plan: "pro" }, - }); - expect(createResult.structuredContent).toMatchObject({ - createdAt: "2026-08-09T10:00:00.000Z", - updatedAt: "2026-08-09T10:00:00.000Z", - }); - expect( - tools - .get("create_contact")! - .config.outputSchema.safeParse(createResult.structuredContent).success, - ).toBe(true); - }); - - it("bulk deletes only with a valid selection and passes the acting team", async () => { - const tools = makeToolRegistry(registerContactTools); - const deleteContacts = tools.get("delete_contacts")!; - expect(deleteContacts.config.annotations.destructiveHint).toBe(true); - - const missingCount = await deleteContacts.handler({ match: {} }, auth); - expect(missingCount.isError).toBe(true); - expect(mocks.startContactDeletion).not.toHaveBeenCalled(); - - mocks.startContactDeletion.mockResolvedValue({ - status: 409, - body: { error: "count_changed", count: 7 }, - }); - const changed = await deleteContacts.handler( - { match: { q: "old" }, expectedCount: 5 }, - auth, - ); - expect(changed).toMatchObject({ - isError: true, - structuredContent: { error: "count_changed", count: 7 }, - }); - expect(mocks.startContactDeletion).toHaveBeenCalledWith( - expect.objectContaining({ - teamId: "team-1", - selection: { match: { q: "old" } }, - expectedCount: 5, - }), - ); - - const queued = { - deletionId: "cdel_1", - status: "queued", - count: 5000, - deleted: 0, - suppressed: 0, - }; - mocks.startContactDeletion.mockResolvedValue({ status: 202, body: queued }); - await expect( - deleteContacts.handler({ match: {}, expectedCount: 5000 }, auth), - ).resolves.toMatchObject({ structuredContent: queued }); - - mocks.getContactDeletion.mockResolvedValue(null); - await expect( - tools.get("get_contact_deletion")!.handler({ deletionId: "cdel_1" }, auth), - ).resolves.toEqual(NOT_FOUND); - }); - - it("previews a bulk delete", async () => { - const tools = makeToolRegistry(registerContactTools); - mocks.selectionCondition.mockResolvedValue({ condition: "sql" }); - mocks.countSelection.mockResolvedValue({ count: 4, unsubscribed: 1 }); - - await expect( - tools.get("preview_delete_contacts")!.handler({ contactIds: ["cnt_a"] }, auth), - ).resolves.toMatchObject({ structuredContent: { count: 4, unsubscribed: 1 } }); - expect(mocks.selectionCondition).toHaveBeenCalledWith("team-1", { - contactIds: ["cnt_a"], - }); - }); - - it("does not leak contacts from another team", async () => { - const tools = makeToolRegistry(registerContactTools); - const contactId = crypto.randomUUID(); - mocks.getContactByContactId.mockResolvedValue({ - contactId, - teamId: "team-2", - }); - - await expect( - tools.get("get_contact")!.handler({ contactId }, auth), - ).resolves.toEqual(NOT_FOUND); - }); - - it("returns contact deliveries after checking contact ownership", async () => { - const tools = makeToolRegistry(registerContactTools); - const contactId = crypto.randomUUID(); - const internalContactId = crypto.randomUUID(); - mocks.getContactByContactId.mockResolvedValue({ - id: internalContactId, - contactId, - teamId: "team-1", - }); - mocks.getDeliveriesByContact.mockResolvedValue([ - { - sequenceId: "seq-1", - sequenceTitle: "Welcome", - sequenceType: "sequence", - emailId: "email-1", - createdAt: new Date("2026-01-01T00:00:00.000Z"), - }, - ]); - - await expect( - tools.get("get_contact_deliveries")!.handler({ contactId }, auth), - ).resolves.toMatchObject({ - structuredContent: { - items: [ - { - sequenceId: "seq-1", - sequenceTitle: "Welcome", - emailId: "email-1", - }, - ], - }, - }); - expect(mocks.getDeliveriesByContact).toHaveBeenCalledWith( - "team-1", - internalContactId, - ); - }); -}); - -describe("MCP sequence tools", () => { - it("explains why an active sequence can't be deleted", async () => { - const tools = makeToolRegistry(registerSequenceTools); - mocks.deleteSequence.mockRejectedValueOnce(new Error("sequence_active")); - - const result = await tools - .get("delete_sequence")! - .handler({ sequenceId: "seq-1" }, auth); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("Pause it before deleting it"); - expect(tools.get("delete_sequence")!.config.annotations.destructiveHint).toBe(true); - }); - - it("deletes a sequence of the caller's team", async () => { - const tools = makeToolRegistry(registerSequenceTools); - mocks.deleteSequence.mockResolvedValueOnce(true).mockResolvedValueOnce(false); - - const deleted = await tools - .get("delete_sequence")! - .handler({ sequenceId: "seq-1" }, auth); - expect(deleted.structuredContent).toEqual({ message: "Sequence deleted." }); - expect(mocks.deleteSequence).toHaveBeenCalledWith({ - teamId: "team-1", - sequenceId: "seq-1", - }); - await expect( - tools.get("delete_sequence")!.handler({ sequenceId: "seq-2" }, auth), - ).resolves.toEqual(NOT_FOUND); - }); - - it("explains why a sent broadcast can't be deleted", async () => { - const tools = makeToolRegistry(registerSequenceTools); - mocks.deleteSequence.mockRejectedValueOnce(new Error("broadcast_sent")); - - const result = await tools - .get("delete_sequence")! - .handler({ sequenceId: "seq-1" }, auth); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("Archive it instead"); - }); - - it("archives and restores a broadcast of the caller's team", async () => { - const tools = makeToolRegistry(registerSequenceTools); - const archivedAt = new Date("2026-10-29T00:00:00Z"); - mocks.setBroadcastArchived - .mockResolvedValueOnce({ sequenceId: "seq-1", archivedAt, emails: [] }) - .mockResolvedValueOnce({ sequenceId: "seq-1", archivedAt: null, emails: [] }) - .mockResolvedValueOnce(null) - .mockRejectedValueOnce(new Error("broadcast_not_sent")); - - const archived = await tools - .get("archive_broadcast")! - .handler({ sequenceId: "seq-1" }, auth); - expect(archived.structuredContent).toMatchObject({ - archivedAt: archivedAt.toISOString(), - }); - expect(mocks.setBroadcastArchived).toHaveBeenLastCalledWith({ - teamId: "team-1", - sequenceId: "seq-1", - archived: true, - }); - const restored = await tools - .get("unarchive_broadcast")! - .handler({ sequenceId: "seq-1" }, auth); - expect(restored.structuredContent).toMatchObject({ archivedAt: null }); - expect(mocks.setBroadcastArchived).toHaveBeenLastCalledWith({ - teamId: "team-1", - sequenceId: "seq-1", - archived: false, - }); - await expect( - tools.get("archive_broadcast")!.handler({ sequenceId: "seq-2" }, auth), - ).resolves.toEqual(NOT_FOUND); - const draft = await tools - .get("archive_broadcast")! - .handler({ sequenceId: "seq-3" }, auth); - expect(draft.isError).toBe(true); - expect(draft.content[0].text).toContain("Only a sent broadcast can be archived"); - }); - - it("previews a sequence deletion", async () => { - const tools = makeToolRegistry(registerSequenceTools); - const preview = { - status: "paused", - blocker: null, - enrolledCount: 2, - emailsSentCount: 5, - }; - mocks.getSequenceDeletionImpact.mockResolvedValueOnce(preview); - - const result = await tools - .get("preview_sequence_deletion")! - .handler({ sequenceId: "seq-1" }, auth); - - expect(result.structuredContent).toEqual(preview); - expect(mocks.getSequenceDeletionImpact).toHaveBeenCalledWith("team-1", "seq-1"); - }); - - it("validates non-empty template IDs on sequence creation/add-email inputs", () => { - const tools = makeToolRegistry(registerSequenceTools); - - expect( - tools.get("create_sequence")!.config.inputSchema.templateId.safeParse("").success, - ).toBe(false); - expect( - tools.get("add_sequence_email")!.config.inputSchema.templateId.safeParse("") - .success, - ).toBe(false); - }); - - it("checks sequence ownership before returning stats", async () => { - const tools = makeToolRegistry(registerSequenceTools); - mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); - - await expect( - tools.get("get_sequence_stats")!.handler({ sequenceId: "seq-1" }, auth), - ).resolves.toEqual(NOT_FOUND); - expect(mocks.getSequenceBySequenceId).toHaveBeenCalledWith("team-1", "seq-1"); - expect(mocks.getEmailSentCount).not.toHaveBeenCalled(); - - mocks.getSequenceBySequenceId.mockResolvedValueOnce({ - sequenceId: "seq-1", - }); - mocks.getEmailSentCount.mockResolvedValue(5); - mocks.getSequenceOpenRate.mockResolvedValue(0.4); - mocks.getSequenceClickThroughRate.mockResolvedValue(0.2); - mocks.getSubscribersCount.mockResolvedValue(3); - - await expect( - tools.get("get_sequence_stats")!.handler({ sequenceId: "seq-1" }, auth), - ).resolves.toMatchObject({ - structuredContent: { - sent: 5, - openRate: 0.4, - clickThroughRate: 0.2, - subscribersCount: 3, - }, - }); - }); - - it("checks sequence ownership before returning the schedule", async () => { - const tools = makeToolRegistry(registerSequenceTools); - mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); - - await expect( - tools.get("get_sequence_schedule")!.handler({ sequenceId: "seq-1" }, auth), - ).resolves.toEqual(NOT_FOUND); - expect(mocks.getSequenceSchedule).not.toHaveBeenCalled(); - - const sequence = { sequenceId: "seq-1" }; - const schedule = { - waiting: 3, - emails: [{ emailId: "email_1", waiting: 3, nextSendAt: 1_800_000_000_000 }], - }; - mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); - mocks.getSequenceSchedule.mockResolvedValueOnce(schedule); - - await expect( - tools.get("get_sequence_schedule")!.handler({ sequenceId: "seq-1" }, auth), - ).resolves.toMatchObject({ structuredContent: schedule }); - expect(mocks.getSequenceSchedule).toHaveBeenCalledWith(sequence); - }); - - it("lists waiting contacts only for an email of the caller's sequence", async () => { - const tools = makeToolRegistry(registerSequenceTools); - const tool = tools.get("list_sequence_email_waiting_contacts")!; - const sequence = { sequenceId: "seq-1", emailsOrder: ["email_1"] }; - - mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); - await expect( - tool.handler({ sequenceId: "seq-1", emailId: "email_1" }, auth), - ).resolves.toEqual(NOT_FOUND); - mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); - await expect( - tool.handler({ sequenceId: "seq-1", emailId: "email_other" }, auth), - ).resolves.toEqual(NOT_FOUND); - expect(mocks.getWaitingContacts).not.toHaveBeenCalled(); - - const page = { - items: [ - { contactId: "cnt_1", email: "a@example.com", name: null, nextSendAt: 1_000 }, - ], - total: 1, - nextCursor: null, - }; - mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); - mocks.getWaitingContacts.mockResolvedValueOnce(page); - await expect( - tool.handler( - { sequenceId: "seq-1", emailId: "email_1", after: "c1", q: "ana" }, - auth, - ), - ).resolves.toMatchObject({ structuredContent: page }); - expect(mocks.getWaitingContacts).toHaveBeenCalledWith(sequence, "email_1", { - after: "c1", - limit: undefined, - q: "ana", - }); - }); -}); - -describe("MCP ESP tools", () => { - it("returns public ESP shape and invalidates transport on update", async () => { - const tools = makeToolRegistry(registerEspTools); - const config = { - provider: "smtp", - host: "smtp.example.com", - port: 587, - secure: false, - username: "user", - encryptedSecret: "secret", - fromName: "Sender", - fromEmail: "sender@example.com", - lastTestedAt: null, - lastTestStatus: null, - lastTestError: null, - }; - mocks.upsertEspConfig.mockResolvedValue(config); - - const result = await tools.get("update_esp_config")!.handler( - { - provider: "smtp", - host: "smtp.example.com", - port: 587, - secure: false, - }, - auth, - ); - - expect(mocks.upsertEspConfig).toHaveBeenCalledWith("team-1", { - provider: "smtp", - host: "smtp.example.com", - port: 587, - secure: false, - }); - expect(mocks.invalidateTeamTransport).toHaveBeenCalledWith("team-1"); - expect(result.structuredContent).toMatchObject({ - provider: "smtp", - hasPassword: true, - }); - expect(result.structuredContent).not.toHaveProperty("encryptedSecret"); - }); - - it("sends a sequence email test to the signed-in user by default", async () => { - const tools = makeToolRegistry(registerSequenceTools); - mocks.sendSequenceEmailTest.mockResolvedValue({ - sent: ["owner@example.com"], - skipped: [], - lastTestSentAt: "2026-10-19T00:00:00.000Z", - }); - - await expect( - tools - .get("send_sequence_email_test")! - .handler({ sequenceId: "seq_1", emailId: "email_1" }, auth), - ).resolves.toMatchObject({ - structuredContent: { sent: ["owner@example.com"] }, - }); - expect(mocks.sendSequenceEmailTest).toHaveBeenCalledWith({ - teamId: "team-1", - sequenceId: "seq_1", - emailId: "email_1", - to: undefined, - fallbackRecipient: "owner@example.com", - }); - - mocks.sendSequenceEmailTest.mockRejectedValue(new Error("email_not_found")); - await expect( - tools - .get("send_sequence_email_test")! - .handler({ sequenceId: "seq_1", emailId: "email_x" }, teamKeyAuth), - ).resolves.toMatchObject({ isError: true }); - }); - - it("requires an explicit destination for API-key test sends", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.getEspConfig.mockResolvedValue({ - provider: "smtp", - host: "smtp.example.com", - port: 587, - secure: false, - }); - - await expect( - tools.get("send_test_email")!.handler({}, teamKeyAuth), - ).resolves.toMatchObject({ - structuredContent: { - success: false, - error: "No destination email address available.", - }, - }); - expect(mocks.sendTestMail).not.toHaveBeenCalled(); - }); - - it("lists user-managed ESP configurations", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.listEspConfigs.mockResolvedValue([ - { espId: "esp_1", name: "Primary" }, - { espId: "esp_2", name: "Backup" }, - ]); - - const result = await tools.get("list_esps")!.handler(auth); - - expect(mocks.listEspConfigs).toHaveBeenCalledWith("team-1"); - expect(result.structuredContent).toMatchObject({ - items: [ - { espId: "esp_1", name: "Primary" }, - { espId: "esp_2", name: "Backup" }, - ], - }); - }); - - it("creates a named user-managed ESP configuration", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.createEspConfig.mockResolvedValue({ - espId: "esp_2", - name: "Backup", - provider: "smtp", - host: "smtp.example.com", - port: 587, - secure: false, - }); - - const result = await tools.get("create_esp")!.handler( - { - name: "Backup", - provider: "smtp", - host: "smtp.example.com", - port: 587, - secure: false, - }, - auth, - ); - - expect(mocks.createEspConfig).toHaveBeenCalledWith("team-1", { - name: "Backup", - provider: "smtp", - host: "smtp.example.com", - port: 587, - secure: false, - }); - expect(mocks.invalidateTeamTransport).toHaveBeenCalledWith("team-1"); - expect(result.structuredContent).toMatchObject({ espId: "esp_2" }); - }); - - it("returns NOT_FOUND when getting an unknown espId", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.getEspConfigByEspId.mockResolvedValue(null); - - const result = await tools.get("get_esp")!.handler({ espId: "esp_missing" }, auth); - - expect(result).toBe(NOT_FOUND); - }); - - it("updates a user-managed ESP and invalidates its transport", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.updateEspConfig.mockResolvedValue({ - id: "internal-2", - espId: "esp_2", - name: "Backup", - provider: "smtp", - }); - - const result = await tools - .get("update_esp")! - .handler({ espId: "esp_2", provider: "smtp" }, auth); - - expect(mocks.updateEspConfig).toHaveBeenCalledWith("team-1", "esp_2", { - provider: "smtp", - }); - expect(mocks.invalidateEspTransport).toHaveBeenCalledWith("team-1", "internal-2"); - expect(result.structuredContent).toMatchObject({ espId: "esp_2" }); - }); - - it("surfaces esp_in_use as a friendly error when deleting", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.getEspConfigByEspId.mockResolvedValue({ - id: "internal-2", - espId: "esp_2", - isDefault: false, - }); - mocks.deleteEspConfig.mockRejectedValue(new Error("delivery_source_in_use")); - - const result = await tools.get("delete_esp")!.handler({ espId: "esp_2" }, auth); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("queued email"); - }); - - it("explains a blocked delete of the team's default ESP", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.getEspConfigByEspId.mockResolvedValue({ id: "internal-2", espId: "esp_2" }); - mocks.deleteEspConfig.mockRejectedValue(new Error("esp_is_default")); - - const result = await tools.get("delete_esp")!.handler({ espId: "esp_2" }, auth); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("default delivery source"); - expect(mocks.invalidateEspTransport).not.toHaveBeenCalled(); - }); - - it("retires a team ESP so it stops sending", async () => { - const tools = makeToolRegistry(registerEspTools); - const config = { id: "internal-2", espId: "esp_2", name: "Old", status: "active" }; - mocks.getEspConfigByEspId.mockResolvedValue(config); - mocks.transitionEspConfig.mockResolvedValue({ ...config, status: "retired" }); - - const result = await tools.get("retire_esp")!.handler({ espId: "esp_2" }, auth); - - expect(mocks.transitionEspConfig).toHaveBeenCalledWith(config, "retire", { - cancel: true, - }); - expect(mocks.invalidateEspTransport).toHaveBeenCalledWith("team-1", "internal-2"); - expect(result.structuredContent).toMatchObject({ - espId: "esp_2", - status: "retired", - }); - }); - - it("tests a specific ESP by espId", async () => { - const tools = makeToolRegistry(registerEspTools); - mocks.getEspConfigByEspId.mockResolvedValue({ - id: "internal-2", - teamId: "team-1", - espId: "esp_2", - provider: "smtp", - fromName: null, - fromEmail: null, - }); - mocks.sendTestMail.mockResolvedValue(undefined); - - const result = await tools - .get("test_esp")! - .handler({ espId: "esp_2", to: "dest@example.com" }, auth); - - expect(mocks.getEspConfigByEspId).toHaveBeenCalledWith("team-1", "esp_2"); - expect(mocks.sendTestMail).toHaveBeenCalled(); - expect(result.structuredContent).toMatchObject({ success: true }); - }); - - it("activates a verified user-managed ESP", async () => { - const tools = makeToolRegistry(registerEspTools); - const config = { - id: "internal-2", - espId: "esp_2", - status: "draft", - provider: "smtp", - }; - mocks.getEspConfigByEspId.mockResolvedValue(config); - mocks.transitionEspConfig.mockResolvedValue({ - ...config, - status: "active", - }); - - const result = await tools.get("activate_esp")!.handler({ espId: "esp_2" }, auth); - - expect(mocks.transitionEspConfig).toHaveBeenCalledWith(config, "activate"); - expect(result.structuredContent).toMatchObject({ - espId: "esp_2", - status: "active", - }); - }); -}); - -describe("MCP team and template tools", () => { - it("requires OAuth account context to list teams", async () => { - const tools = makeToolRegistry(registerTeamTools); - - await expect( - tools.get("list_teams")!.handler({ authInfo: { clientId: "team-1" } }), - ).resolves.toEqual(AUTH_ERROR); - - mocks.listTeamViewsForUser.mockResolvedValue([ - { - id: "team-1", - teamId: "team-1", - name: "Main", - organizationPublicId: "org-1", - organizationName: "Acme", - ignored: true, - }, - ]); - await expect(tools.get("list_teams")!.handler(auth)).resolves.toMatchObject({ - structuredContent: { - items: [ - { - teamId: "team-1", - name: "Main", - organizationId: "org-1", - organizationName: "Acme", - }, - ], - }, - }); - expect(mocks.listTeamViewsForUser).toHaveBeenCalledWith("user-1"); - }); - - it("creates teams for the authenticated account", async () => { - const tools = makeToolRegistry(registerTeamTools); - mocks.getOrganizationMembership.mockResolvedValue({ role: "owner" }); - mocks.createTeam.mockResolvedValue({ - id: "internal-team-2", - teamId: "team-2", - name: "Second Team", - }); - - await expect( - tools.get("create_team")!.handler( - { name: "Second Team" }, - { - http: { - authInfo: { - token: "test-token", - clientId: "test-client", - scopes: [], - extra: { - authKind: "oauth", - teamId: "team-1", - user: { - id: "user-1", - email: "owner@example.com", - name: "Owner", - defaultOrganizationId: "org-1", - }, - }, - }, - }, - }, - ), - ).resolves.toMatchObject({ - structuredContent: { - teamId: "team-2", - name: "Second Team", - }, - }); - expect(mocks.createTeam).toHaveBeenCalledWith({ - organizationId: "org-1", - creatorUserId: "user-1", - name: "Second Team", - }); - }); - - it("only lets team owners delete teams", async () => { - const tools = makeToolRegistry(registerTeamTools); - mocks.getTeamByTeamId.mockResolvedValue({ - id: "internal-team-2", - teamId: "team-2", - name: "Second Team", - organizationId: "org-1", - }); - mocks.getTeamMembership.mockResolvedValueOnce({ role: "member" }); - mocks.getOrganizationMembership.mockResolvedValueOnce({ - role: "member", - }); - - await expect( - tools.get("delete_team")!.handler({ teamId: "team-2" }, auth), - ).resolves.toMatchObject({ - isError: true, - }); - expect(mocks.archiveTeam).not.toHaveBeenCalled(); - - mocks.getTeamMembership.mockResolvedValueOnce({ role: "admin" }); - mocks.getOrganizationMembership.mockResolvedValueOnce({ - role: "owner", - }); - await expect( - tools.get("delete_team")!.handler({ teamId: "team-2" }, auth), - ).resolves.toMatchObject({ - structuredContent: { message: "Team archived." }, - }); - expect(mocks.archiveTeam).toHaveBeenCalledWith("internal-team-2"); - }); - - it("keeps template ID inputs non-empty across template tools", () => { - const tools = makeToolRegistry(registerTemplateTools); - - for (const toolName of [ - "get_template", - "update_template", - "duplicate_template", - "delete_template", - ]) { - expect( - tools.get(toolName)!.config.inputSchema.templateId.safeParse("").success, - ).toBe(false); - } - }); - - it("filters, creates, and duplicates templates without changing purpose", async () => { - const tools = makeToolRegistry(registerTemplateTools); - const template = { - id: "internal-template", - teamId: "team-1", - templateId: "tpl_1", - title: "Receipt", - purpose: "transactional", - content: { style: {}, meta: {}, content: [] }, - requiredVariables: ["order.id"], - createdAt: null, - updatedAt: null, - }; - - mocks.listTemplates.mockResolvedValue([template]); - await expect( - tools.get("list_templates")!.handler({ purpose: "transactional" }, auth), - ).resolves.toMatchObject({ - structuredContent: { - items: [ - { - templateId: "tpl_1", - purpose: "transactional", - requiredVariables: ["order.id"], - }, - ], - }, - }); - expect(mocks.listTemplates).toHaveBeenCalledWith("team-1", "transactional"); - - mocks.createTemplate.mockResolvedValue(template); - await tools.get("create_template")!.handler( - { - title: "Receipt", - purpose: "transactional", - content: template.content, - }, - auth, - ); - expect(mocks.createTemplate).toHaveBeenCalledWith({ - teamId: "team-1", - title: "Receipt", - purpose: "transactional", - content: template.content, - }); - - mocks.duplicateTemplate.mockResolvedValue({ - ...template, - templateId: "tpl_2", - }); - await tools.get("duplicate_template")!.handler( - { - templateId: "tpl_1", - }, - auth, - ); - expect(mocks.duplicateTemplate).toHaveBeenCalledWith({ - teamId: "team-1", - templateId: "tpl_1", - title: undefined, - }); - }); -}); - -describe("MCP media tools", () => { - it("lists and updates media within the resolved team", async () => { - const tools = makeToolRegistry(registerMediaTools); - mocks.listMedia.mockResolvedValue([ - { - id: "internal-media-1", - teamId: "team-1", - mediaId: "med_1", - url: "https://cdn.test/p/media-lit/main.webp", - mediaLitId: "media-lit", - }, - ]); - mocks.countMedia.mockResolvedValue(1); - - await expect( - tools.get("list_media")!.handler({ query: "hero", page: 2 }, auth), - ).resolves.toMatchObject({ - structuredContent: { - items: [ - { - mediaId: "med_1", - url: "https://cdn.test/p/media-lit/main.webp", - }, - ], - total: 1, - }, - }); - expect(mocks.listMedia).toHaveBeenCalledWith({ - teamId: "team-1", - query: "hero", - page: 2, - pageSize: undefined, - }); - expect(mocks.countMedia).toHaveBeenCalledWith({ - teamId: "team-1", - query: "hero", - }); - - mocks.updateMediaMetadata.mockResolvedValue({ - id: "internal-media-1", - teamId: "team-1", - mediaId: "med_1", - url: "https://cdn.test/p/media-lit/main.webp", - mediaLitId: "media-lit", - alt: "Hero", - }); - await tools.get("update_media")!.handler({ mediaId: "med_1", alt: "Hero" }, auth); - expect(mocks.updateMediaMetadata).toHaveBeenCalledWith({ - teamId: "team-1", - mediaId: "med_1", - alt: "Hero", - caption: undefined, - }); - }); - - it("blocks deleting in-use media and lists references", async () => { - const tools = makeToolRegistry(registerMediaTools); - mocks.deleteUnusedMedia.mockResolvedValue("in_use"); - - await expect( - tools.get("delete_media")!.handler({ mediaId: "med_1" }, auth), - ).resolves.toMatchObject({ - isError: true, - }); - expect(mocks.deleteUnusedMedia).toHaveBeenCalledWith("team-1", "med_1"); - - mocks.listMediaReferences.mockResolvedValue([ - { - resourceType: "SEQUENCE_EMAIL", - resourcePublicId: "email_1", - parentResourcePublicId: "seq_1", - }, - ]); - await expect( - tools.get("list_media_references")!.handler({ mediaId: "med_1" }, auth), - ).resolves.toMatchObject({ - structuredContent: { - items: [ - { - resourceType: "SEQUENCE_EMAIL", - resourcePublicId: "email_1", - parentResourcePublicId: "seq_1", - }, - ], - }, - }); - }); -}); - -describe("MCP transactional tools", () => { - it("requires auth for send_email/get_email/list_emails", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - - await expect( - tools - .get("send_email")! - .handler({ to: "a@example.com", subject: "Hi", html: "

hi

" }, {}), - ).resolves.toEqual(AUTH_ERROR); - expect(mocks.createTransactionalEmail).not.toHaveBeenCalled(); - - await expect( - tools.get("get_email")!.handler({ txeId: "txe_1" }, {}), - ).resolves.toEqual(AUTH_ERROR); - expect(mocks.getTransactionalEmailByTxeId).not.toHaveBeenCalled(); - - await expect(tools.get("list_emails")!.handler({}, {})).resolves.toEqual( - AUTH_ERROR, - ); - expect(mocks.listTransactionalEmails).not.toHaveBeenCalled(); - }); - - it("sends an email and forwards the resolved team/args", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - mocks.createTransactionalEmail.mockResolvedValue({ - txeId: "txe_1", - status: "queued", - }); - - await expect( - tools.get("send_email")!.handler( - { - to: "reader@example.com", - subject: "Receipt", - html: "

Thanks!

", - idempotencyKey: "key-1", - }, - auth, - ), - ).resolves.toMatchObject({ - structuredContent: { txeId: "txe_1", status: "queued" }, - }); - expect(mocks.createTransactionalEmail).toHaveBeenCalledWith( - expect.objectContaining({ - teamId: "team-1", - to: "reader@example.com", - subject: "Receipt", - html: "

Thanks!

", - idempotencyKey: "key-1", - }), - ); - }); - - it("maps each createTransactionalEmail error to the matching tool message", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - const cases: Array<[string, string]> = [ - [ - "invalid_content", - "Provide exactly one of templateId or html; variables requires templateId", - ], - ["template_not_found", "Template not found"], - ["template_not_transactional", "template_not_transactional"], - ["marketing_variables_not_allowed", "marketing_variables_not_allowed"], - ["esp_not_configured", "Team ESP is not configured."], - ["esp_not_found", "ESP not found"], - ]; - - for (const [errorMessage, expectedText] of cases) { - mocks.createTransactionalEmail.mockRejectedValueOnce(new Error(errorMessage)); - await expect( - tools.get("send_email")!.handler( - { - to: "a@example.com", - subject: "Hi", - html: "

hi

", - }, - auth, - ), - ).resolves.toEqual({ - content: [{ type: "text", text: expectedText }], - isError: true, - }); - } - }); - - it("reports missing transactional template variables", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - mocks.createTransactionalEmail.mockRejectedValueOnce( - Object.assign(new Error("missing_template_variables"), { - missingVariables: ["customer.name", "otp"], - }), - ); - - await expect( - tools.get("send_email")!.handler( - { - to: "a@example.com", - subject: "Hi", - templateId: "tpl_1", - }, - auth, - ), - ).resolves.toEqual({ - content: [ - { - type: "text", - text: "Missing required template variables: customer.name, otp", - }, - ], - isError: true, - }); - }); - - it("rethrows unmapped errors from send_email", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - mocks.createTransactionalEmail.mockRejectedValueOnce(new Error("boom")); - - await expect( - tools - .get("send_email")! - .handler({ to: "a@example.com", subject: "Hi", html: "

hi

" }, auth), - ).rejects.toThrow("boom"); - }); - - it("returns NOT_FOUND for get_email when the row belongs to another team", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - mocks.getTransactionalEmailByTxeId.mockResolvedValue({ - txeId: "txe_1", - teamId: "team-2", - }); - - await expect( - tools.get("get_email")!.handler({ txeId: "txe_1" }, auth), - ).resolves.toEqual(NOT_FOUND); - expect(mocks.toPublicTransactionalEmail).not.toHaveBeenCalled(); - }); - - it("returns the public shape (with html) for an owned email", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - const row = { txeId: "txe_1", teamId: "team-1", html: "

hi

" }; - mocks.getTransactionalEmailByTxeId.mockResolvedValue(row); - mocks.toPublicTransactionalEmail.mockReturnValue({ - txeId: "txe_1", - html: "

hi

", - }); - - await expect( - tools.get("get_email")!.handler({ txeId: "txe_1" }, auth), - ).resolves.toMatchObject({ - structuredContent: { txeId: "txe_1", html: "

hi

" }, - }); - expect(mocks.toPublicTransactionalEmail).toHaveBeenCalledWith(row, { - includeHtml: true, - }); - }); - - it("scopes list_emails to the resolved team and returns items/total", async () => { - const tools = makeToolRegistry(registerTransactionalTools); - const row = { txeId: "txe_1", teamId: "team-1" }; - mocks.listTransactionalEmails.mockResolvedValue([row]); - mocks.countTransactionalEmails.mockResolvedValue(1); - mocks.toPublicTransactionalEmail.mockReturnValue({ txeId: "txe_1" }); - - await expect( - tools.get("list_emails")!.handler({ status: "sent", offset: 2 }, auth), - ).resolves.toMatchObject({ - structuredContent: { items: [{ txeId: "txe_1" }], total: 1 }, - }); - expect(mocks.listTransactionalEmails).toHaveBeenCalledWith({ - teamId: "team-1", - status: "sent", - createdAfter: undefined, - createdBefore: undefined, - offset: 2, - rowsPerPage: undefined, - }); - expect(mocks.countTransactionalEmails).toHaveBeenCalledWith("team-1", { - status: "sent", - createdAfter: undefined, - createdBefore: undefined, - }); - expect(mocks.toPublicTransactionalEmail).toHaveBeenCalledWith(row, { - includeHtml: false, - }); - }); -}); - -describe("MCP event tools", () => { - const eventTools = [ - "track_event", - "list_event_definitions", - "get_event_definition", - "create_event_definition", - "update_event_definition", - "delete_event_definition", - "get_contact_events", - ]; - - it("requires auth for every event tool", async () => { - const tools = makeToolRegistry(registerEventTools); - expect([...tools.keys()].sort()).toEqual([...eventTools].sort()); - for (const name of eventTools) { - await expect( - tools.get(name)!.handler({ name: "x", contactId: "cnt_1" }, {}), - ).resolves.toEqual(AUTH_ERROR); - } - expect(mocks.trackEvent).not.toHaveBeenCalled(); - }); - - it("tracks an event for the resolved team with source mcp", async () => { - const tools = makeToolRegistry(registerEventTools); - mocks.trackEvent.mockResolvedValue({ - eventId: "evt_1", - contactId: "cnt_1", - duplicate: false, - contactCreated: true, - }); - await expect( - tools.get("track_event")!.handler( - { - email: "a@example.com", - eventName: " course purchased ", - properties: { price: 49 }, - idempotencyKey: "order_1", - }, - teamKeyAuth, - ), - ).resolves.toMatchObject({ structuredContent: { eventId: "evt_1" } }); - expect(mocks.trackEvent).toHaveBeenCalledWith({ - teamId: "team-1", - source: "mcp", - body: expect.objectContaining({ - email: "a@example.com", - eventName: "course purchased", - idempotencyKey: "order_1", - }), - }); - }); - - it("validates track_event with the REST rules and maps expected errors", async () => { - const tools = makeToolRegistry(registerEventTools); - const invalid = await tools - .get("track_event")! - .handler({ eventName: "course purchased" }, auth); - expect(invalid.isError).toBe(true); - expect(invalid.content[0].text).toContain("exactly one of email or contactId"); - expect(mocks.trackEvent).not.toHaveBeenCalled(); - - mocks.trackEvent.mockRejectedValue(new FakeEventsError("contact_not_found")); - const missing = await tools - .get("track_event")! - .handler({ email: "a@example.com", eventName: "x", createContact: false }, auth); - expect(missing).toMatchObject({ - isError: true, - content: [{ text: "contact_not_found" }], - }); - }); - - it("scopes definitions to the team and maps conflicts", async () => { - const tools = makeToolRegistry(registerEventTools); - mocks.listEventDefinitions.mockResolvedValue([]); - await tools.get("list_event_definitions")!.handler({}, auth); - expect(mocks.listEventDefinitions).toHaveBeenCalledWith("team-1"); - - mocks.getEventDefinition.mockResolvedValue(null); - await expect( - tools.get("get_event_definition")!.handler({ name: "nope" }, auth), - ).resolves.toEqual(NOT_FOUND); - - mocks.createEventDefinition.mockRejectedValue( - new FakeEventsError("event_definition_exists"), - ); - await expect( - tools.get("create_event_definition")!.handler({ name: "x" }, auth), - ).resolves.toMatchObject({ - isError: true, - content: [{ text: "event_definition_exists" }], - }); - - mocks.deleteEventDefinition.mockRejectedValue( - new FakeEventsError("event_definition_in_use"), - ); - await expect( - tools.get("delete_event_definition")!.handler({ name: "x" }, auth), - ).resolves.toMatchObject({ - isError: true, - content: [{ text: "event_definition_in_use" }], - }); - - mocks.updateEventDefinition.mockResolvedValue(null); - await expect( - tools - .get("update_event_definition")! - .handler({ name: "x", description: "d" }, auth), - ).resolves.toEqual(NOT_FOUND); - expect(mocks.updateEventDefinition).toHaveBeenCalledWith("team-1", "x", { - description: "d", - }); - }); - - it("only lists events of a contact in the team", async () => { - const tools = makeToolRegistry(registerEventTools); - mocks.getContactByContactId.mockResolvedValue({ id: "c-1", teamId: "team-2" }); - await expect( - tools.get("get_contact_events")!.handler({ contactId: "cnt_1" }, auth), - ).resolves.toEqual(NOT_FOUND); - expect(mocks.listContactEvents).not.toHaveBeenCalled(); - - mocks.getContactByContactId.mockResolvedValue({ id: "c-1", teamId: "team-1" }); - mocks.listContactEvents.mockResolvedValue({ items: [], total: 0 }); - await tools - .get("get_contact_events")! - .handler({ contactId: "cnt_1", limit: 5 }, auth); - expect(mocks.listContactEvents).toHaveBeenCalledWith("c-1", { - page: undefined, - limit: 5, - name: undefined, - }); - }); -}); - -describe("MCP sequence output schema", () => { - it("accepts what the sequence tools return", () => { - // Regression: the schema required an `espId` the tools never return, - // so every sequence tool failed MCP output validation. - const parsed = mcpSequenceSchema.safeParse({ - sequenceId: "seq_1", - type: "sequence", - title: "Follow-up", - status: "active", - triggerType: "event:custom", - triggerData: "course purchased", - triggerFilter: { aggregator: "and", filters: [] }, - reentry: "once", - reentryPeriodDays: null, - filter: null, - excludeFilter: null, - emailsOrder: ["email_1"], - entrants: [], - report: {}, - viewInBrowser: false, - webPublished: false, - createdAt: "2026-10-28T00:00:00.000Z", - updatedAt: "2026-10-28T00:00:00.000Z", - emails: [ - { - emailId: "email_1", - subject: "Hi", - content: { style: {}, meta: {}, content: [] }, - delayInMillis: 0, - published: true, - contentScanVerdict: null, - createdAt: null, - updatedAt: null, - }, - ], - }); - expect(parsed.success).toBe(true); - }); -}); diff --git a/apps/api/src/mcp/tools/tracking-domain.ts b/apps/api/src/mcp/tools/tracking-domain.ts deleted file mode 100644 index 2a4dc71f..00000000 --- a/apps/api/src/mcp/tools/tracking-domain.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { z } from "zod"; -import { - getTrackingDomain, - setTrackingDomain, - toPublicTrackingDomain, - verifyTrackingDomain, -} from "../../settings/tracking-domain/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { AUTH_ERROR, errorResult, jsonResult } from "./responses"; -import { trackingDomainSchema } from "./schemas"; - -export function registerTrackingDomainTools(server: McpToolRegistrar): void { - server.registerTool( - "get_tracking_domain", - { - description: - "Returns the team's custom tracking domain: the domain, its status (`pending`, `verified` or `failed`), the CNAME target, and why the last check failed. Open, click, unsubscribe and \"view in browser\" links use the domain once it is verified; otherwise SendLit's own host.", - outputSchema: trackingDomainSchema, - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return jsonResult(toPublicTrackingDomain(await getTrackingDomain(teamId))); - }, - ); - - server.registerTool( - "set_tracking_domain", - { - description: - "Sets or removes the team's custom tracking domain. Use a subdomain such as `track.example.com` and add a CNAME record pointing to the returned `cnameTarget`; the domain is `pending` until verified (SendLit rechecks hourly, or call verify_tracking_domain). `null` removes it. Links already sent on a previous domain keep working. Errors: `tracking_domain_invalid`, `tracking_domain_taken`.", - inputSchema: { - domain: z - .string() - .max(253) - .nullable() - .describe("e.g. track.example.com; null removes it"), - }, - outputSchema: trackingDomainSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - return jsonResult( - toPublicTrackingDomain(await setTrackingDomain(teamId, args.domain)), - ); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); - - server.registerTool( - "verify_tracking_domain", - { - description: - "Checks the team's custom tracking domain now: its CNAME record and that SendLit answers over HTTPS on it. Returns the updated status. Error: `tracking_domain_not_set`.", - outputSchema: trackingDomainSchema, - annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true }, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - try { - return jsonResult(toPublicTrackingDomain(await verifyTrackingDomain(teamId))); - } catch (err: any) { - return errorResult(err.message); - } - }, - ); -} diff --git a/apps/api/src/mcp/tools/transactional.test.ts b/apps/api/src/mcp/tools/transactional.test.ts new file mode 100644 index 00000000..c29be265 --- /dev/null +++ b/apps/api/src/mcp/tools/transactional.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const createTransactionalEmail = vi.fn(); +const getTransactionalEmailByTxeId = vi.fn(); +const listTransactionalEmails = vi.fn(); +const countTransactionalEmails = vi.fn(); +const toPublicTransactionalEmail = vi.fn((row: any, options: any) => ({ + txeId: row.txeId, + ...(options.includeHtml ? { html: row.html } : {}), +})); +mock.module("../../transactional/queries", () => ({ + createTransactionalEmail, + getTransactionalEmailByTxeId, + listTransactionalEmails, + countTransactionalEmails, + toPublicTransactionalEmail, +})); + +const { transactionalTools } = await import("./transactional"); +const { PlanGateError } = await import("../../billing/errors"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [] as string[], + requestId: "", + clientIp: "", + replayHeaders: {}, +}; +const tools = new Map(transactionalTools().map((tool) => [tool.name, tool])); +const call = (name: string, args: Record, ctx: any = context) => + tools.get(name)!.handler({ context: ctx, args, signal }) as Promise; + +beforeEach(() => { + for (const fn of [ + createTransactionalEmail, + getTransactionalEmailByTxeId, + listTransactionalEmails, + countTransactionalEmails, + ]) { + fn.mockReset(); + } +}); + +describe("MCP transactional tools", () => { + it("sends an email for the context's team", async () => { + createTransactionalEmail.mockResolvedValue({ txeId: "txe_1", status: "queued" }); + await expect( + call("send_email", { + to: "reader@example.com", + subject: "Receipt", + html: "

Thanks!

", + idempotencyKey: "key-1", + }), + ).resolves.toEqual({ txeId: "txe_1", status: "queued" }); + expect(createTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: "team-1", + to: "reader@example.com", + html: "

Thanks!

", + idempotencyKey: "key-1", + }), + ); + }); + + it("needs the emails:send scope over OAuth", async () => { + const oauth = { ...context, authKind: "oauth", scopes: ["emails:read"] }; + await expect( + call( + "send_email", + { to: "a@example.com", subject: "Hi", html: "

hi

" }, + oauth, + ), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "emails:send" }, + }, + }); + expect(createTransactionalEmail).not.toHaveBeenCalled(); + }); + + it("maps each send failure to a platform code, keeping the REST reason", async () => { + const cases: Array<[string, string]> = [ + ["invalid_content", "validation_failed"], + ["template_not_found", "not_found"], + ["template_not_transactional", "validation_failed"], + ["marketing_variables_not_allowed", "validation_failed"], + ["esp_not_configured", "conflict"], + ["esp_not_found", "not_found"], + ["recipient_suppressed", "conflict"], + ]; + for (const [reason, code] of cases) { + createTransactionalEmail.mockRejectedValueOnce(new Error(reason)); + const result = await call("send_email", { + to: "a@example.com", + subject: "Hi", + html: "

hi

", + }); + expect(result.error).toMatchObject({ code, safeDetails: { reason } }); + expect(typeof result.error.safeDetails.message).toBe("string"); + } + }); + + it("lists the missing template variables", async () => { + createTransactionalEmail.mockRejectedValueOnce( + Object.assign(new Error("missing_template_variables"), { + missingVariables: ["customer.name", "otp"], + }), + ); + await expect( + call("send_email", { to: "a@example.com", subject: "Hi", templateId: "tpl_1" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { + reason: "missing_template_variables", + message: "Missing required template variables: customer.name, otp", + }, + }, + }); + }); + + it("keeps plan limits, and rethrows anything unexpected", async () => { + createTransactionalEmail.mockRejectedValueOnce( + new PlanGateError("plan_limit_reached", { limit: 100, nested: { a: 1 } }), + ); + await expect( + call("send_email", { to: "a@example.com", subject: "Hi", html: "

hi

" }), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "plan_limit_reached", limit: 100 }, + }, + }); + + createTransactionalEmail.mockRejectedValueOnce(new Error("boom")); + await expect( + call("send_email", { to: "a@example.com", subject: "Hi", html: "

hi

" }), + ).rejects.toThrow("boom"); + }); + + it("returns another team's email as not found", async () => { + getTransactionalEmailByTxeId.mockResolvedValue({ + txeId: "txe_1", + teamId: "team-2", + }); + await expect(call("get_email", { txeId: "txe_1" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + + getTransactionalEmailByTxeId.mockResolvedValue({ + txeId: "txe_1", + teamId: "team-1", + html: "

x

", + }); + await expect(call("get_email", { txeId: "txe_1" })).resolves.toEqual({ + txeId: "txe_1", + html: "

x

", + }); + }); + + it("lists the team's emails without their HTML", async () => { + listTransactionalEmails.mockResolvedValue([{ txeId: "txe_1", html: "

x

" }]); + countTransactionalEmails.mockResolvedValue(1); + await expect(call("list_emails", { status: "sent", offset: 2 })).resolves.toEqual({ + items: [{ txeId: "txe_1" }], + total: 1, + }); + expect(listTransactionalEmails).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "team-1", status: "sent", offset: 2 }), + ); + expect(countTransactionalEmails).toHaveBeenCalledWith( + "team-1", + expect.objectContaining({ status: "sent" }), + ); + }); +}); diff --git a/apps/api/src/mcp/tools/transactional.ts b/apps/api/src/mcp/tools/transactional.ts index 6dd3e394..a9c9833d 100644 --- a/apps/api/src/mcp/tools/transactional.ts +++ b/apps/api/src/mcp/tools/transactional.ts @@ -16,120 +16,113 @@ import { listTransactionalEmails, toPublicTransactionalEmail, } from "../../transactional/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { - AUTH_ERROR, - errorResult, - isPlanGateError, - jsonResult, - NOT_FOUND, - planGateResult, -} from "./responses"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { isPlanGateError, notFound, planGateError, toolError } from "./responses"; const transactionalEmailListSchema = z.object({ items: z.array(transactionalEmailSchema), total: z.number(), }); -/** - * The REST route's `202`/`400`/`422`/`429` split (see - * `transactional/routes.ts`) doesn't map onto MCP's single tool-result shape, - * so every `createTransactionalEmail` failure just becomes an `isError` - * result with the same message a REST caller would have gotten in the body. - */ -export function registerTransactionalTools(server: McpToolRegistrar): void { - server.registerTool( - "send_email", - { - description: - "Sends a single transactional email (e.g. a receipt or password reset) immediately—no audience filter or marketing footer. `templateId` must identify a team-owned transactional template and every requiredVariables path must be supplied; marketing templates are rejected as template_not_transactional. Alternatively provide `html`, which is sent verbatim without Liquid rendering. Fire-and-forget: poll `get_email` with the returned `txeId` for delivery status.", - inputSchema: { - to: z.string().email(), - subject: z.string().min(1), - templateId: z - .string() - .min(1) - .optional() - .describe("Mutually exclusive with `html`"), - html: z - .string() - .min(1) - .optional() - .describe( - "Sent verbatim (no Liquid pass); mutually exclusive with `templateId`", - ), - text: z - .string() - .min(1) - .optional() - .describe( - "Plain-text part, sent verbatim; generated from the HTML when omitted", - ), - variables: z - .record(z.any()) - .optional() - .describe( - "Liquid merge variables; requires `templateId`. Provide every unguarded merge tag; use `{% if %}` or `default` in the template for optional values.", - ), - replyTo: z.string().email().optional(), - headers: emailHeadersSchema - .optional() - .describe( - "Extra SMTP headers; no CR/LF, and From/To/Subject/Content-Type are reserved", - ), - idempotencyKey: z - .string() - .min(1) - .max(256) - .optional() - .describe( - "Replaying the same key returns the original send instead of sending again", - ), - trackOpens: z.boolean().optional(), - trackClicks: z.boolean().optional(), - deliverySource: z - .discriminatedUnion("type", [ - z.object({ type: z.literal("organization") }), - z.object({ - type: z.literal("team"), - espId: z.string().min(1).optional(), - }), - ]) - .optional(), - }, - outputSchema: transactionalEmailSchema.pick({ - txeId: true, - status: true, - }), - annotations: { - readOnlyHint: false, - destructiveHint: false, - openWorldHint: true, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; +/** `createTransactionalEmail` failures: the platform code, the REST error + * code as `reason`, and the message a REST caller gets in the body. */ +const SEND_ERRORS: Record[0], string]> = { + invalid_content: [ + "validation_failed", + "Provide exactly one of templateId or html; variables requires templateId", + ], + invalid_headers: [ + "validation_failed", + "Header names/values must not contain CR/LF; From, To, Subject and Content-Type are set by the send pipeline", + ], + template_not_transactional: [ + "validation_failed", + "The template is a marketing template; send_email needs a transactional one.", + ], + marketing_variables_not_allowed: [ + "validation_failed", + "Transactional templates can't use marketing variables.", + ], + template_not_found: ["not_found", "Template not found"], + render_failed: ["validation_failed", "Template rendering failed"], + esp_not_configured: ["conflict", "Team ESP is not configured."], + esp_not_found: ["not_found", "ESP not found"], + recipient_suppressed: [ + "conflict", + "This address is suppressed after a prior hard bounce or complaint and cannot receive mail from this team.", + ], +}; + +export const transactionalTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "send_email", + operationId: "transactional.send", + risk: "write", + description: + "Sends a single transactional email (e.g. a receipt or password reset) immediately—no audience filter or marketing footer. `templateId` must identify a team-owned transactional template and every requiredVariables path must be supplied; marketing templates are rejected as template_not_transactional. Alternatively provide `html`, which is sent verbatim without Liquid rendering. Fire-and-forget: poll `get_email` with the returned `txeId` for delivery status.", + inputSchema: z.object({ + to: z.string().email(), + subject: z.string().min(1), + templateId: z + .string() + .min(1) + .optional() + .describe("Mutually exclusive with `html`"), + html: z + .string() + .min(1) + .optional() + .describe( + "Sent verbatim (no Liquid pass); mutually exclusive with `templateId`", + ), + text: z + .string() + .min(1) + .optional() + .describe( + "Plain-text part, sent verbatim; generated from the HTML when omitted", + ), + variables: z + .record(z.any()) + .optional() + .describe( + "Liquid merge variables; requires `templateId`. Provide every unguarded merge tag; use `{% if %}` or `default` in the template for optional values.", + ), + replyTo: z.string().email().optional(), + headers: emailHeadersSchema + .optional() + .describe( + "Extra SMTP headers; no CR/LF, and From/To/Subject/Content-Type are reserved", + ), + idempotencyKey: z + .string() + .min(1) + .max(256) + .optional() + .describe( + "Replaying the same key returns the original send instead of sending again", + ), + trackOpens: z.boolean().optional(), + trackClicks: z.boolean().optional(), + deliverySource: z + .discriminatedUnion("type", [ + z.object({ type: z.literal("organization") }), + z.object({ + type: z.literal("team"), + espId: z.string().min(1).optional(), + }), + ]) + .optional(), + }), + outputSchema: transactionalEmailSchema.pick({ txeId: true, status: true }), + annotations: { openWorldHint: true }, + async handler({ context, args }) { try { - const row = await createTransactionalEmail({ - teamId, - to: args.to, - subject: args.subject, - templateId: args.templateId, - html: args.html, - text: args.text, - variables: args.variables, - replyTo: args.replyTo, - headers: args.headers, - idempotencyKey: args.idempotencyKey, - trackOpens: args.trackOpens, - trackClicks: args.trackClicks, - deliverySource: args.deliverySource, - }); - return jsonResult({ txeId: row.txeId, status: row.status }); + const row = await createTransactionalEmail({ teamId: context.teamId, ...args }); + return { txeId: row.txeId, status: row.status }; } catch (err: any) { - if (isPlanGateError(err)) return planGateResult(err); + if (isPlanGateError(err)) return planGateError(err); if ( err instanceof MissingTemplateVariablesError || err?.message === MISSING_TEMPLATE_VARIABLES @@ -137,96 +130,60 @@ export function registerTransactionalTools(server: McpToolRegistrar): void { const missingVariables = Array.isArray(err.missingVariables) ? err.missingVariables.join(", ") : null; - return errorResult( - missingVariables + return toolError("validation_failed", { + reason: MISSING_TEMPLATE_VARIABLES, + message: missingVariables ? `Missing required template variables: ${missingVariables}` : "Missing required template variables", - ); - } - switch (err.message) { - case "invalid_content": - return errorResult( - "Provide exactly one of templateId or html; variables requires templateId", - ); - case "invalid_headers": - return errorResult( - "Header names/values must not contain CR/LF; From, To, Subject and Content-Type are set by the send pipeline", - ); - case "template_not_transactional": - return errorResult("template_not_transactional"); - case "marketing_variables_not_allowed": - return errorResult("marketing_variables_not_allowed"); - case "template_not_found": - return errorResult("Template not found"); - case "render_failed": - return errorResult("Template rendering failed"); - case "esp_not_configured": - return errorResult("Team ESP is not configured."); - case "esp_not_found": - return errorResult("ESP not found"); - case "recipient_suppressed": - return errorResult( - "This address is suppressed after a prior hard bounce or complaint and cannot receive mail from this team.", - ); - default: - throw err; + }); } + const known = SEND_ERRORS[err?.message]; + if (!known) throw err; + return toolError(known[0], { reason: err.message, message: known[1] }); } }, - ); + }), - server.registerTool( - "get_email", - { - description: - "Returns a single transactional email by its txe ID, including its rendered HTML snapshot and (when tracking was enabled) open/click counts.", - inputSchema: { txeId: z.string() }, - outputSchema: transactionalEmailDetailSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "get_email", + operationId: "transactional.get", + risk: "read", + description: + "Returns a single transactional email by its txe ID, including its rendered HTML snapshot and (when tracking was enabled) open/click counts.", + inputSchema: z.object({ txeId: z.string() }), + outputSchema: transactionalEmailDetailSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { const row = await getTransactionalEmailByTxeId(args.txeId); - if (!row || row.teamId !== teamId) return NOT_FOUND; - return jsonResult(toPublicTransactionalEmail(row, { includeHtml: true })); + if (!row || row.teamId !== context.teamId) return notFound(); + return toPublicTransactionalEmail(row, { includeHtml: true }); }, - ); + }), - server.registerTool( - "list_emails", - { - description: - "Returns a paginated list of transactional emails for the team, most recent first. Omits the HTML snapshot (use get_email for that).", - inputSchema: { - status: z.enum(transactionalEmailStatus).optional(), - createdAfter: z - .number() - .int() - .optional() - .describe("Millisecond timestamp lower bound (inclusive)"), - createdBefore: z - .number() - .int() - .optional() - .describe("Millisecond timestamp upper bound (exclusive)"), - offset: z.number().int().min(1).optional().describe("Page number (default: 1)"), - itemsPerPage: z.number().int().min(1).max(50).optional(), - }, - outputSchema: transactionalEmailListSchema, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; + dedicatedTool({ + name: "list_emails", + operationId: "transactional.list", + risk: "read", + description: + "Returns a paginated list of transactional emails for the team, most recent first. Omits the HTML snapshot (use get_email for that).", + inputSchema: z.object({ + status: z.enum(transactionalEmailStatus).optional(), + createdAfter: z + .number() + .int() + .optional() + .describe("Millisecond timestamp lower bound (inclusive)"), + createdBefore: z + .number() + .int() + .optional() + .describe("Millisecond timestamp upper bound (exclusive)"), + offset: z.number().int().min(1).optional().describe("Page number (default: 1)"), + itemsPerPage: z.number().int().min(1).max(50).optional(), + }), + outputSchema: transactionalEmailListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { const filters = { status: args.status, createdAfter: args.createdAfter, @@ -234,19 +191,19 @@ export function registerTransactionalTools(server: McpToolRegistrar): void { }; const [items, total] = await Promise.all([ listTransactionalEmails({ - teamId, + teamId: context.teamId, ...filters, offset: args.offset, rowsPerPage: args.itemsPerPage, }), - countTransactionalEmails(teamId, filters), + countTransactionalEmails(context.teamId, filters), ]); - return jsonResult({ + return { items: items.map((row) => toPublicTransactionalEmail(row, { includeHtml: false }), ), total, - }); + }; }, - ); -} + }), +]; diff --git a/apps/api/src/mcp/tools/web.ts b/apps/api/src/mcp/tools/web.ts deleted file mode 100644 index 16208bdd..00000000 --- a/apps/api/src/mcp/tools/web.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - listWebIssuesQuerySchema, - updateWebPublishingBodySchema, - updateWebSettingsBodySchema, - webIssueSchema, - webIssueSummarySchema, - webPublishingSchema, - webSettingsSchema, -} from "@sendlit/api-contract"; -import { z } from "zod"; -import { getSequenceBySequenceId } from "../../sequences/queries"; -import { - getIssue, - getWebSettings, - listIssues, - toWebPublishing, - updateWebPublishing, - updateWebSettings, - WebPublishingError, -} from "../../web/queries"; -import type { McpToolRegistrar } from "../tool-registry"; -import { getTeamId } from "./auth"; -import { AUTH_ERROR, errorResult, jsonResult, NOT_FOUND } from "./responses"; - -const readOnly = { readOnlyHint: true, idempotentHint: true, openWorldHint: false }; -const write = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }; - -async function run(fn: () => Promise) { - try { - return jsonResult(await fn()); - } catch (error) { - if (error instanceof WebPublishingError) return errorResult(error.message); - throw error; - } -} - -export function registerWebTools(server: McpToolRegistrar): void { - server.registerTool( - "get_broadcast_web_publishing", - { - description: - "Returns whether a broadcast is published as a public web page, its slug, SEO fields and public URL. `live` is true once it's published and sent.", - inputSchema: { sequenceId: z.string().min(1) }, - outputSchema: webPublishingSchema, - annotations: readOnly, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const sequence = await getSequenceBySequenceId(teamId, args.sequenceId); - if (!sequence) return NOT_FOUND; - return jsonResult(await toWebPublishing(sequence)); - }, - ); - - server.registerTool( - "update_broadcast_web_publishing", - { - description: - "Publishes a broadcast as a public web page (it goes live once sent) or unpublishes it, and sets its slug, SEO title/description, social image, noindex and canonical URL. Only broadcasts can be published. The page has no personal data, tracking or unsubscribe links.", - inputSchema: { - sequenceId: z.string().min(1), - ...updateWebPublishingBodySchema.shape, - }, - outputSchema: webPublishingSchema, - annotations: write, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const { sequenceId, ...input } = args; - const sequence = await getSequenceBySequenceId(teamId, sequenceId); - if (!sequence) return NOT_FOUND; - return run(() => updateWebPublishing(sequence, input)); - }, - ); - - server.registerTool( - "get_web_settings", - { - description: - "Returns the team's web archive settings: whether SendLit hosts the archive and issue pages, the handle, title, description, signup form and the archive and RSS URLs.", - outputSchema: webSettingsSchema, - annotations: readOnly, - }, - async (extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return jsonResult(await getWebSettings(teamId)); - }, - ); - - server.registerTool( - "update_web_settings", - { - description: - "Updates the team's web archive. Turning `enabled` on hosts the archive at /w/ and a page per published issue; a handle and a signup form are created when missing. Turn it off when a platform renders the issues itself through list_web_issues/get_web_issue.", - inputSchema: updateWebSettingsBodySchema.shape, - outputSchema: webSettingsSchema, - annotations: write, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return run(() => updateWebSettings(teamId, args)); - }, - ); - - server.registerTool( - "list_web_issues", - { - description: - "Lists the team's published issues (broadcasts published to the web and sent), newest first, with slug, title, date, excerpt and SEO fields.", - inputSchema: listWebIssuesQuerySchema.shape, - outputSchema: z.object({ - items: z.array(webIssueSummarySchema), - total: z.number(), - }), - annotations: readOnly, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - return jsonResult(await listIssues(teamId, args)); - }, - ); - - server.registerTool( - "get_web_issue", - { - description: - "Returns one published issue by slug with its HTML (no personal data, tracking or unsubscribe links), for rendering on another site.", - inputSchema: { slug: z.string().min(1) }, - outputSchema: webIssueSchema, - annotations: readOnly, - }, - async (args: any, extra: any) => { - const teamId = getTeamId(extra); - if (!teamId) return AUTH_ERROR; - const issue = await getIssue(teamId, args.slug); - return issue ? jsonResult(issue) : NOT_FOUND; - }, - ); -} diff --git a/apps/docs/content/docs/activity/suppressions.mdx b/apps/docs/content/docs/activity/suppressions.mdx index 1c987bd4..10c5e757 100644 --- a/apps/docs/content/docs/activity/suppressions.mdx +++ b/apps/docs/content/docs/activity/suppressions.mdx @@ -21,5 +21,5 @@ address later creates an unsubscribed contact. Use **Suppressions → Add suppression** to block an address, for example a role account, a known spam trap, or someone who asked by email not to be contacted. Over the API, `POST /suppressions` with `{ "email": "...", "note": "..." }`; the -MCP tool is `add_suppression`. A manual suppression can be released later; a +MCP clients run `suppressions.create` with `api.write`. A manual suppression can be released later; a stronger suppression already on the address (such as a complaint) is kept. diff --git a/apps/docs/content/docs/developers/authentication.mdx b/apps/docs/content/docs/developers/authentication.mdx index 66b63191..49eaadcb 100644 --- a/apps/docs/content/docs/developers/authentication.mdx +++ b/apps/docs/content/docs/developers/authentication.mdx @@ -39,7 +39,7 @@ API keys look like `sl_live_...` followed by a random secret, and are sent via t Keys are stored hashed (SHA-256), which means: -- The full key is shown **exactly once**, in the response that creates it (`POST /teams/:teamId/keys`, the `create_api_key` MCP tool, or the provisioning call that first creates a team). Store it securely at that moment — it cannot be retrieved later. +- The full key is shown **exactly once**, in the response that creates it (`POST /teams/:teamId/keys`, the `teams.createKey` operation through the MCP `api.write` tool, or the provisioning call that first creates a team). Store it securely at that moment — it cannot be retrieved later. - Key listings only include each key's `keyPrefix` (the first few characters, e.g. `sl_live_a1b2`) so you can tell keys apart. - Keys are revoked by their `id` (`DELETE /teams/:teamId/keys/:keyId`), not by the secret. diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index e6e7f536..89b0d4ae 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -25,7 +25,7 @@ Authenticate with one of these headers: Authorization: Bearer ``` -or: +or a team API key in `x-sendlit-apikey` (or the standard `X-API-Key`; send only one): ```http x-sendlit-apikey: sl_live_... @@ -44,9 +44,9 @@ OAuth authorization, consent, token, introspection, revocation, and userinfo end Use Authorization Code with S256 PKCE, request only the scopes your client needs, and keep any refresh token in the client's secure credential store. The authorization request must include an explicit, non-empty `scope`; SendLit does not turn a missing value into a full-access grant. Every authorization requires a SendLit user to sign in, select a team when necessary, and approve consent. -Team API keys always select one fixed team and currently grant the complete -MCP tool set for that team, except the API key management tools -(`list_api_keys`, `create_api_key`, `delete_api_key`), which require OAuth. +Team API keys always select one fixed team and can run every MCP tool and +operation for that team, except API key management (`teams.listKeys`, +`teams.createKey`, `teams.removeKey`), which requires OAuth. Organization API keys, dashboard session cookies, and `X-Sendlit-Team-Id` are not accepted as MCP authentication. Use the REST API for organization discovery, shared-delivery setup, and team provisioning; see @@ -58,7 +58,7 @@ OAuth clients (including MCP clients like Claude) have no way to send a custom h ### OAuth scopes -OAuth access is default-deny per tool. Read and write permissions are separate: +OAuth access is default-deny per REST operation: a dedicated tool and an `api.*` call to the same operation need the same scope, and `api.list_operations` shows which scope each operation needs and whether your token has it. Read and write permissions are separate: - `contacts:read`, `contacts:write` - `templates:read`, `templates:write` @@ -75,100 +75,40 @@ OAuth access is default-deny per tool. Read and write permissions are separate: ## Available tools -### Contacts and segments +SendLit follows the CodeLit pattern: dedicated tools for the everyday flows, and five generic tools that reach every other team operation of the [REST API](/developers/api). A client loads a short catalog (34 tools), and nothing the REST API offers a team is out of reach. -- `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `delete_contact` -- `preview_delete_contacts`, `delete_contacts` (bulk, by ids or by search/segment/filter; requires the previewed `expectedCount` for a match) and `get_contact_deletion` (progress of a large background deletion). See [Delete contacts](/email-marketing/contacts#delete-contacts). -- `add_contact_tag`, `remove_contact_tag`, `get_contact_deliveries` -- `import_contacts` (up to 1000 rows per call, statuses only get stricter) and `export_contacts` (CSV) -- `list_segments`, `get_segment`, `create_segment`, `update_segment`, `delete_segment` -- `list_tags` (with contact counts), `get_tag_references`, `rename_tag`, `delete_tag` (team-wide; contacts scopes) -- `list_signup_forms`, `get_signup_form`, `create_signup_form`, `update_signup_form`, `delete_signup_form` (public [signup forms](/email-marketing/signup-forms); contacts scopes) +### Dedicated tools -Segments save reusable filters over contact fields, tags, subscription state, signup date, and custom fields. +- **Contacts:** `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `add_contact_tag`, `remove_contact_tag`, `import_contacts` (up to 1000 rows per call; statuses only get stricter) +- **Segments:** `list_segments`, `create_segment`. Segments save reusable filters over contact fields, tags, subscription state, signup date and custom fields. +- **Templates:** `list_templates`, `get_template`, `create_template`, `update_template` +- **Broadcasts and sequences:** `list_sequences`, `get_sequence`, `create_sequence`, `update_sequence`, `add_sequence_email`, `update_sequence_email`, `send_sequence_email_test`, `start_sequence`, `pause_sequence`, `get_sequence_stats` +- **Transactional email:** `send_email`, `get_email`, `list_emails` +- **Events and delivery:** `track_event`, `list_delivery_events`, `get_plan_usage` -### Custom events +Broadcasts use the sequence resource with one email and an audience filter. `update_sequence` sets a sequence's trigger, including `event:custom` with a `triggerFilter`, who can enter (`reentry`, `reentryPeriodDays`), and an `excludeFilter` whose contacts are never sent the broadcast or enrolled; see [Sequences](/email-marketing/sequences#custom-events). Send a test with `send_sequence_email_test` (a `[Test]` copy to you, or up to five addresses) and use `start_sequence` only after the audience, trigger, content, delays and tag actions are correct. -- `track_event` records a [custom event](/developers/events) for a contact and starts the sequences it triggers. It takes the `POST /events` body, including `createContact`, `contact`, `idempotencyKey` and `backfill`. -- `list_event_definitions`, `get_event_definition`, `create_event_definition`, `update_event_definition`, `delete_event_definition` -- `get_contact_events` lists a contact's events, newest first, with the sequences each one started. +Template and sequence-email content is validated as a complete SendLit email document on every write. The `style` must include `colors`, `typography.header`, `typography.text`, `typography.link`, `interactives` and `structure`; blocks must be one of text, link, image, separator or footer. Metadata supports only optional `previewText` and an optional `utm` object with `source`, `medium` and `campaign` strings. For AI-created content, start from a system template (`templates.listSystem`, then `templates.duplicate` through the `api.*` tools) rather than constructing a document from scratch. -Event tools use the contacts scopes: `contacts:read` for reads and `contacts:write` for `track_event` and definition changes. `track_event` is for agents and people trying things out; send production events through the REST API, which allows far more requests per minute. +`send_email` sends one API-triggered email, accepts only team-owned transactional templates, and reports `template_not_transactional` or the missing variable paths without creating a send. Broadcast and sequence tools accept only marketing templates. `track_event` records a [custom event](/developers/events) and starts the sequences it triggers; send production events through the REST API, which allows far more requests per minute. -### Templates and media +### Generic API tools -- `list_system_templates`, `list_templates`, `get_template`, `create_template`, `update_template`, `duplicate_template`, `delete_template` -- `list_media`, `get_media`, `update_media`, `delete_media`, `list_media_references` +Everything else a team can do over REST runs through these tools: sending providers (ESPs) and their delivery feedback, suppressions, [sender reputation](/email-marketing/reputation), the [tracking domain](/email-marketing/tracking-domain), general and [web archive](/email-marketing/web-archive) settings, [signup forms](/email-marketing/signup-forms), media, tags, segment and template management, sequence schedules, subscribers and archiving, event definitions, contact exports and deliveries, teams and API keys, and every delete. -Templates and media are scoped to the selected team. Template list tools accept -an optional `purpose` filter; `create_template` requires a purpose and returns -the computed `requiredVariables`. System templates expose transactional -variable descriptions and examples, but are starters rather than team-owned -sendable templates. Use `duplicate_template` to copy or safely convert one. +1. `api.list_operations` lists the operations, filtered by `search` or `group`. Each item has its `operationId`, method and path, the tool that runs it, the OAuth scope it needs, and whether this connection may run it. +2. `api.describe_operation` returns an operation's path and query parameters, the JSON Schema of its request body, and the schema of a successful response (from [`/openapi.json`](/developers/api)). +3. `api.read` runs a read operation, `api.write` an operation that creates or changes data or has an external effect (such as testing an ESP), and `api.delete` one that deletes or permanently disables something. Each takes `operationId`, `pathParams`, `query` and, for writes, `body`. `api.delete` also needs `confirm: true`: confirm the target with the person first. -Template and sequence-email content is validated as a complete SendLit email -document on every write. The `style` must include `colors`, -`typography.header`, `typography.text`, `typography.link`, `interactives`, and -`structure`; blocks must be one of text, link, image, separator, or footer. -Metadata supports only optional `previewText` and an optional `utm` object with -`source`, `medium`, and `campaign` strings. -For AI-created content, start with `list_system_templates` and -`duplicate_template` rather than constructing a document shape from scratch. +A call runs exactly as the REST request would, for the connected team, with the same validation, plan limits and errors. For example, to retire an ESP: find `settings.esps.retire` with `api.list_operations`, check its parameters with `api.describe_operation`, then call `api.delete` with `{ "operationId": "settings.esps.retire", "pathParams": { "espId": "esp_…" }, "confirm": true }`. -### Broadcasts and sequences +Billing, account deletion, organization administration and team provisioning are not reachable over MCP; use the REST API with a dashboard session or an organization key. -Broadcasts use the sequence resource with one email and an audience filter. For sequences, `update_sequence` sets the trigger, including `event:custom` with a `triggerFilter`, and who can enter (`reentry`, `reentryPeriodDays`); see [Sequences](/email-marketing/sequences#custom-events). It also takes an `excludeFilter`; contacts matching it are never sent the broadcast or enrolled in the sequence. Set `viewInBrowser: false` to send without the "View in browser" link and take down the web versions already linked. Sequence tools include: +### Errors -- `list_sequences`, `get_sequence`, `create_sequence`, `update_sequence` -- `add_sequence_email`, `update_sequence_email`, `delete_sequence_email` -- `preview_sequence_deletion`, `delete_sequence` (destructive: pause an active sequence first; its enrollments and stats are deleted). Draft and scheduled broadcasts can be deleted; sent ones can't (`broadcast_sent`) -- `archive_broadcast`, `unarchive_broadcast` hide a sent broadcast from the list and the web archive, keeping its report. `list_sequences` takes `archived` (`exclude` by default, `only`, `include`) -- `send_sequence_email_test` sends a `[Test]` copy of one email to you (or up to five addresses) -- `start_sequence`, `pause_sequence`, `get_sequence_stats`, `get_sequence_schedule`, `list_sequence_email_waiting_contacts`, `get_sequence_subscribers` -- `get_broadcast_web_publishing`, `update_broadcast_web_publishing`, `list_web_issues`, `get_web_issue` ([web archive](/email-marketing/web-archive)) +A failed tool call is a tool result with `isError: true` and `structuredContent` of `{ code, message, details }`. `code` is one of `validation_failed`, `not_found`, `conflict`, `forbidden`, `rate_limited`, `unauthenticated` or `internal_error`. `details.reason` carries SendLit's own error code (for example `plan_limit_reached`, `sending_paused`, `template_not_transactional`, `insufficient_scope` or `broadcast_sent`), `details.message` explains it, and plan limits keep the limit, usage and upgrade link REST returns. -Send a test with `send_sequence_email_test` and use `start_sequence` only after the audience, trigger, email content, delays, and optional tag actions are correct. Use `pause_sequence` to stop future sequence processing. - -### Transactional email - -- `send_email` sends one API-triggered email. -- `get_email` retrieves one delivery record. -- `list_emails` lists transactional delivery records. - -Transactional sends are separate from broadcasts and sequences; they do not enroll a contact or use a campaign audience. -`send_email` accepts only team-owned transactional templates and reports -`template_not_transactional` or the missing variable paths without creating a -send. Broadcast and sequence tools accept only marketing templates and report -`template_not_marketing`. - -### Sending configuration - -- `get_general_settings`, `update_general_settings`, `get_web_settings`, `update_web_settings` -- `get_tracking_domain`, `set_tracking_domain`, `verify_tracking_domain` ([tracking domain](/email-marketing/tracking-domain)) -- `get_reputation`, `sync_reputation`, `connect_google_postmaster` (returns a sign-in URL for a person to open), `disconnect_google_postmaster`, `connect_snds`, `disconnect_snds` ([sender reputation](/email-marketing/reputation)) -- `get_esp_config`, `update_esp_config`, `delete_esp_config`, `send_test_email` -- `list_esps`, `create_esp`, `get_esp`, `update_esp`, `delete_esp`, `test_esp` -- `check_esp_domain_authentication` (live SPF, DKIM and DMARC check of the From domain, with the records to add) - -The ESP tools manage the team’s sending providers and default provider. Secrets are accepted for writes but are not returned in read responses. - -### Teams and API keys - -- `list_teams`, `create_team`, `rename_team`, `delete_team` -- `get_plan_usage` returns the current team's parent-organization plan, usage, and limits. It never returns provider IDs or portal links. -- `list_api_keys`, `create_api_key`, `delete_api_key` - -Mutating tools such as `create_team`, `create_contact`, `update_contact`, `send_email`, and `start_sequence` return the same plan-gate error object as REST (`plan_limit_reached`, `plan_feature_unavailable`, `sending_paused`) in `structuredContent`. Checkout, portal, and invoice tools are not exposed through MCP. - -API key management tools are refused when the caller authenticates with an API key; connect with OAuth to use them. The full API key secret is returned only by `create_api_key`. Store it immediately; it cannot be recovered from a later listing. - -### Delivery feedback and suppressions - -- `get_esp_feedback_connection`, `upsert_esp_feedback_connection`, `test_esp_feedback_connection`, `delete_esp_feedback_connection` -- `list_delivery_events`, `get_delivery_event` -- `list_suppressions`, `get_suppression`, `add_suppression`, `release_suppression` - -Delivery-feedback connections are supported only for providers with a reviewed asynchronous feedback adapter: Resend, Postmark, SendGrid, Mailgun, and Amazon SES. For SES, pass `expectedTopicArn` (the SNS topic subscribed to the webhook URL) to `upsert_esp_feedback_connection` instead of `credential`; see [Amazon SES bounces and complaints](/self-hosting/amazon-ses-feedback). Releasing a SendLit suppression does not remove a provider-native suppression, and complaint suppressions are not releasable through the owner API. +API key management is refused when the caller authenticates with an API key; connect with OAuth to use it. The full API key secret is returned only when the key is created. Store it immediately; it cannot be recovered from a later listing. Delivery-feedback connections are supported for Resend, Postmark, SendGrid, Mailgun and Amazon SES; see [Amazon SES bounces and complaints](/self-hosting/amazon-ses-feedback). Releasing a SendLit suppression does not remove a provider-native suppression. ## Example client configuration @@ -191,4 +131,4 @@ Prefer the client’s secure secret-store or environment-variable mechanism. Do ## Operational limits -The MCP endpoint is rate limited to 60 requests per minute. High-impact tools such as sending email, tracking events (`track_event`), activating sequences, testing ESPs, managing API keys, deleting teams, deleting contacts in bulk (`delete_contacts`), and releasing suppressions are limited to 20 requests per minute. A missing or invalid credential returns HTTP 401, a missing OAuth scope returns an `insufficient_scope` tool error, an unsupported protocol revision is rejected, and a non-JSON request returns HTTP 415. Because requests are stateless, retry only operations that are safe to repeat. +The MCP endpoint is rate limited to 60 requests per minute. High-impact tools (`send_email`, `send_sequence_email_test`, `track_event`, `start_sequence`, `api.write` and `api.delete`) are limited to 20 requests per minute, and the REST API's own limits apply to `api.*` calls as well. A missing or invalid credential returns HTTP 401 with a `WWW-Authenticate` challenge, a missing OAuth scope returns a `forbidden` tool error with reason `insufficient_scope`, an unsupported protocol revision is rejected, and a non-JSON request returns HTTP 415. Because requests are stateless, retry only operations that are safe to repeat. diff --git a/apps/docs/content/docs/email-marketing/broadcasts.mdx b/apps/docs/content/docs/email-marketing/broadcasts.mdx index f3f403fa..d52c4430 100644 --- a/apps/docs/content/docs/email-marketing/broadcasts.mdx +++ b/apps/docs/content/docs/email-marketing/broadcasts.mdx @@ -138,5 +138,5 @@ page. With the API, `DELETE /sequences/{sequenceId}` deletes, `POST /sequences/{sequenceId}/archive` and `/unarchive` archive and restore, and `GET /sequences?type=broadcast&archived=only` lists archived broadcasts. -MCP clients use `delete_sequence`, `archive_broadcast` and -`unarchive_broadcast`. +MCP clients run these operations (`sequences.remove`, `sequences.archive`, +`sequences.unarchive`) through the generic `api.*` tools. diff --git a/apps/docs/content/docs/email-marketing/contacts.mdx b/apps/docs/content/docs/email-marketing/contacts.mdx index d56010dd..817825a8 100644 --- a/apps/docs/content/docs/email-marketing/contacts.mdx +++ b/apps/docs/content/docs/email-marketing/contacts.mdx @@ -57,7 +57,7 @@ each row's source `status` as it appears in the export; the MCP tool is CSV: email, name, status (subscribed, unsubscribed, bounced or complained), tags, created_at, suppression_reason and one column per custom field. Over the API use `GET /contacts/export` (optionally with `segmentId` or `filter`); -the MCP tool is `export_contacts`. The file can be imported again as is. +MCP clients run `contacts.export` with `api.read`. The file can be imported again as is. ## Manage tags @@ -76,8 +76,7 @@ contacts have each one. From there you can: Renaming or deleting a tag doesn't start tag-added or tag-removed sequences. The API endpoints are under `/tags` (the Tags section in the API reference); -the MCP tools are `list_tags`, `get_tag_references`, `rename_tag` and -`delete_tag`. +MCP clients run the `tags.*` operations through the generic `api.*` tools. ## Delete contacts @@ -117,8 +116,9 @@ Over the API, `POST /contacts/bulk-delete/preview` returns the count and contact that `match`es a search (`q`), `segmentId` and `filter`. With `match`, pass the preview's count as `expectedCount`; a different live count returns 409 `count_changed`. A large deletion answers 202 with a `deletionId` to poll -at `GET /contacts/bulk-delete/{deletionId}`. The MCP tools are -`preview_delete_contacts`, `delete_contacts` and `get_contact_deletion`. +at `GET /contacts/bulk-delete/{deletionId}`. MCP clients run +`contacts.previewBulkDelete`, `contacts.bulkDelete` and `contacts.bulkDeleteStatus` +through the generic `api.*` tools. ## Segments diff --git a/apps/docs/content/docs/email-marketing/domain-authentication.mdx b/apps/docs/content/docs/email-marketing/domain-authentication.mdx index 0456b92e..a33e93d4 100644 --- a/apps/docs/content/docs/email-marketing/domain-authentication.mdx +++ b/apps/docs/content/docs/email-marketing/domain-authentication.mdx @@ -35,8 +35,7 @@ show up. If your provider signs with its own DKIM selector (the part before provider's selectors and common ones. The same check is available as `GET /settings/esps/:espId/domain-authentication` -in the [API](/api-reference) and as the `check_esp_domain_authentication` MCP -tool. +in the [API](/api-reference) and over MCP through `api.read`. ## What each provider needs diff --git a/apps/docs/content/docs/email-marketing/sequences.mdx b/apps/docs/content/docs/email-marketing/sequences.mdx index ff45140c..1a20e90e 100644 --- a/apps/docs/content/docs/email-marketing/sequences.mdx +++ b/apps/docs/content/docs/email-marketing/sequences.mdx @@ -109,9 +109,9 @@ sequence and haven't had their next email yet. Each email in the list shows how many contacts it goes to next, so a delayed email shows its contacts waiting rather than sent. Click the count to see who they are and when each one is due, soonest first, and search them by email or name: a delay counts from each -contact's own entry (or previous email), so their times differ. `GET /sequences/{sequenceId}/schedule` (MCP `get_sequence_schedule`) +contact's own entry (or previous email), so their times differ. `GET /sequences/{sequenceId}/schedule` (`sequences.schedule` over MCP `api.read`) returns the counts, and `GET /sequences/{sequenceId}/emails/{emailId}/waiting` -(MCP `list_sequence_email_waiting_contacts`) lists the contacts, with `q` to +(`sequences.waitingContacts` over MCP `api.read`) lists the contacts, with `q` to search and `nextCursor` to page. Sequences are lifecycle mail, such as onboarding or end-of-trial emails, so a @@ -157,4 +157,4 @@ To stop a sequence but keep its history, pause it instead. With the API, `GET /sequences/{sequenceId}/deletion-preview` returns the enrolled-contact and sent-email counts, then `DELETE /sequences/{sequenceId}` -deletes it. MCP clients use `preview_sequence_deletion` and `delete_sequence`. +deletes it. MCP clients run these operations through the generic `api.*` tools. diff --git a/apps/docs/content/docs/email-marketing/tracking-domain.mdx b/apps/docs/content/docs/email-marketing/tracking-domain.mdx index f8ffeb4f..35ede8da 100644 --- a/apps/docs/content/docs/email-marketing/tracking-domain.mdx +++ b/apps/docs/content/docs/email-marketing/tracking-domain.mdx @@ -29,8 +29,8 @@ Once the domain shows **Verified**, new emails use it. Emails already sent keep their links. The API has the same settings: `GET`, `PUT /settings/tracking-domain` and -`POST /settings/tracking-domain/verify`, and the MCP tools -`get_tracking_domain`, `set_tracking_domain` and `verify_tracking_domain`. +`POST /settings/tracking-domain/verify`, and the same operations +over MCP through the generic `api.*` tools. ## Keep the record in place diff --git a/apps/docs/content/docs/email-marketing/warm-up.mdx b/apps/docs/content/docs/email-marketing/warm-up.mdx index d1f15795..2090e42c 100644 --- a/apps/docs/content/docs/email-marketing/warm-up.mdx +++ b/apps/docs/content/docs/email-marketing/warm-up.mdx @@ -70,8 +70,8 @@ Transactional email is never held back. If you also set a daily send limit, the lower of the two applies. Changing the target keeps the warm-up's progress; turning it off removes the cap. Over -the API, set `warmupTargetDailyVolume` with `PATCH /settings/esps/{espId}` or -the MCP `update_esp` tool (`null` turns it off); the ESP's `warmup` field +the API, set `warmupTargetDailyVolume` with `PATCH /settings/esps/{espId}` (over MCP, `settings.esps.update` +through `api.write`; `null` turns it off); the ESP's `warmup` field shows the day and today's limit. ## Set a daily send limit @@ -85,8 +85,8 @@ never held back. The ESP list shows today's count against the limit. Raise the limit every day or two as long as bounces and complaints stay low, and clear it once you're sending your full volume. You can also set it with -`PATCH /settings/esps/{espId}` (`dailySendLimit`, or `null` to remove it) or -the MCP `update_esp` tool. +`PATCH /settings/esps/{espId}` (`dailySendLimit`, or `null` to remove it) (over MCP, +`settings.esps.update` through `api.write`). ## A sample manual ramp diff --git a/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx b/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx index 69f4a813..ff973e08 100644 --- a/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx +++ b/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx @@ -56,7 +56,7 @@ Do this **before** subscribing. SendLit rejects messages from topics it doesn't 2. Paste the topic ARN into **SNS topic ARN** and save. 3. Copy the **Webhook URL** the dialog shows. It looks like `https://mail.example.com/webhooks/esp/ses/whc_…`. -You can also do this through the API (`PUT /settings/esps/{espId}/feedback` with `{"expectedTopicArn": "arn:aws:sns:…"}`) or the `upsert_esp_feedback_connection` MCP tool. +You can also do this through the API (`PUT /settings/esps/{espId}/feedback` with `{"expectedTopicArn": "arn:aws:sns:…"}`) or, over MCP, the `feedback.upsert` operation through `api.write`. ## 3. Subscribe the webhook URL to the topic diff --git a/docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md b/docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md new file mode 100644 index 00000000..5bd681be --- /dev/null +++ b/docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md @@ -0,0 +1,62 @@ +# ADR 0010: MCP on the Platform kit, with dedicated and generic tools + +Status: accepted +Date: 2026-11-01 + +## Context + +SendLit served MCP with its own server on the MCP SDK 2: one dedicated tool +per REST operation, 118 tools in all. A client loads every tool's name, +description and schemas into the model's context before the first call, so +the catalog was large and most of it was rarely used (ESP lifecycle, +reputation sources, tracking domain, web settings, API keys). + +`@codelitdev/mcp-server-kit` (ADR 0001) now serves MCP 2026-07-28 and 2025-era +Streamable HTTP statelessly, with platform tool errors, risk annotations and +`confirm: true` for destructive tools. CourseLit runs on it with a few +dedicated tools for its main flows and generic `api.*` tools that run any +other REST operation through its REST dispatcher. + +## Decision + +- `/mcp` runs on `createMcpServerKit`. SendLit keeps its own credential and + team resolution (`resolveAuth`, `resolveUserTeam`, shared with REST), the + OAuth discovery routes, the `WWW-Authenticate` challenge on 401, per-tool + rate limits and request logs. `x-sendlit-apikey` keeps working beside the + kit's `X-API-Key`. +- 29 dedicated tools cover the everyday flows: contacts and their tags and + imports, segments, templates, sequences and broadcasts (including test + sends, start, pause and stats), transactional email, event tracking, + delivery events and plan usage. Their names and schemas are unchanged. +- Every other team-scoped REST operation is reached through + `api.list_operations`, `api.describe_operation`, `api.read`, `api.write` and + `api.delete`, the names CourseLit uses. A call is injected in-process into + SendLit's Express app with the caller's own credential, IP and request id, + so validation, plan limits, errors and REST's own refusals are exactly the + REST API's. Operation schemas come from `/openapi.json`. +- The parity manifest decides what MCP can reach: an operation maps either to + its dedicated tool or to the `api.*` tool for its risk. REST-only + operations (billing, account deletion, organization administration, + provisioning) stay unreachable. +- Authorization is per operation: `apps/api/src/mcp/policy.ts` maps each + MCP-reachable operation to its OAuth scope, and both dedicated and generic + tools check it, so an operation needs the same scope however it is reached. + Team API keys still can't manage API keys. +- Tool failures are platform errors (`not_found`, `conflict`, + `validation_failed`, `forbidden`, …) whose `details.reason` keeps SendLit's + error code (`plan_limit_reached`, `template_not_transactional`, …) and + `details.message` the explanation. + +## Consequences + +- The catalog drops from 118 tools to 34. Prompts or agents that call a + removed tool by name (for example `get_esp_config`, `delete_contact` or + `retire_esp`) must switch to the `api.*` tools; deletes now need + `confirm: true`. This is a breaking change for MCP clients, so the server + version moves to 3.0.0. +- MCP-only aliases over the team's default ESP (`get_esp_config`, + `update_esp_config`, `delete_esp_config`, `send_test_email`) are gone; the + per-ESP operations cover them. +- A new REST operation is reachable over MCP as soon as the parity manifest + routes it to an `api.*` tool and `policy.ts` gives it a scope; a dedicated + tool is only worth adding for a common flow. diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 93561198..8ef7728a 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -1,6 +1,7 @@ export * from "./contact-import"; export { type Contract, contract } from "./contract"; export { + MCP_API_TOOLS, type McpParityExemption, type McpParityManifestEntry, type McpParityRisk, diff --git a/packages/api-contract/src/mcp-parity.ts b/packages/api-contract/src/mcp-parity.ts index 0c8115cd..3652a57a 100644 --- a/packages/api-contract/src/mcp-parity.ts +++ b/packages/api-contract/src/mcp-parity.ts @@ -85,10 +85,24 @@ const ESP_LIFECYCLE = "ESP suspend and resume are operational lifecycle controls kept REST/dashboard-only; MCP exposes create, activate, test, retire and delete."; const DELIVERY_SELECTION = "Team delivery-source selection (shared organization delivery vs user ESPs) is REST/dashboard-only; MCP manages user ESP configurations directly."; -const DEFAULT_ESP_ALIAS = - "MCP-only convenience alias over the team's default user ESP; REST exposes the same capability through the per-ESP settings.esps.* operations."; +const API_DISCOVERY = + "MCP-only discovery for the generic api.* tools: it lists and describes the REST operations themselves, so it has no REST operation of its own."; + +/** + * The generic MCP tools (ADR 0010). Common flows have dedicated tools; every + * other MCP-reachable REST operation runs through the api.* tool for its risk, + * after api.list_operations and api.describe_operation. + */ +export const MCP_API_TOOLS = { + read: "api.read", + write: "api.write", + destructive: "api.delete", +} as const; export const mcpParityManifest: readonly McpParityManifestEntry[] = [ + mcpOnly("api.list_operations", API_DISCOVERY), + mcpOnly("api.describe_operation", API_DISCOVERY), + // Contacts required("contacts.create", "create_contact", "write"), required("contacts.list", "list_contacts", "read"), @@ -96,87 +110,83 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ required("contacts.update", "update_contact", "write"), required("contacts.addTag", "add_contact_tag", "write"), required("contacts.removeTag", "remove_contact_tag", "write"), - required("contacts.deliveries", "get_contact_deliveries", "read"), - required("contacts.events", "get_contact_events", "read"), - required("contacts.remove", "delete_contact", "destructive"), - required("contacts.previewBulkDelete", "preview_delete_contacts", "read"), - required("contacts.bulkDelete", "delete_contacts", "destructive"), - required("contacts.bulkDeleteStatus", "get_contact_deletion", "read"), + required("contacts.deliveries", MCP_API_TOOLS.read, "read"), + required("contacts.events", MCP_API_TOOLS.read, "read"), + required("contacts.remove", MCP_API_TOOLS.destructive, "destructive"), + required("contacts.previewBulkDelete", MCP_API_TOOLS.read, "read"), + required("contacts.bulkDelete", MCP_API_TOOLS.destructive, "destructive"), + required("contacts.bulkDeleteStatus", MCP_API_TOOLS.read, "read"), // Tags - required("tags.list", "list_tags", "read"), - required("tags.references", "get_tag_references", "read"), - required("tags.rename", "rename_tag", "write"), - required("tags.remove", "delete_tag", "destructive"), + required("tags.list", MCP_API_TOOLS.read, "read"), + required("tags.references", MCP_API_TOOLS.read, "read"), + required("tags.rename", MCP_API_TOOLS.write, "write"), + required("tags.remove", MCP_API_TOOLS.destructive, "destructive"), // Segments required("segments.create", "create_segment", "write"), required("segments.list", "list_segments", "read"), - required("segments.get", "get_segment", "read"), - required("segments.update", "update_segment", "write"), - required("segments.remove", "delete_segment", "destructive"), + required("segments.get", MCP_API_TOOLS.read, "read"), + required("segments.update", MCP_API_TOOLS.write, "write"), + required("segments.remove", MCP_API_TOOLS.destructive, "destructive"), // Media - required("media.list", "list_media", "read"), + required("media.list", MCP_API_TOOLS.read, "read"), restOnly( "media.presigned", "REST-only upload helper: issues a MediaLit browser upload signature for the dashboard's direct file upload, which has no MCP transport.", ), - required("media.get", "get_media", "read"), - required("media.update", "update_media", "write"), - required("media.remove", "delete_media", "destructive"), - required("media.references", "list_media_references", "read"), + required("media.get", MCP_API_TOOLS.read, "read"), + required("media.update", MCP_API_TOOLS.write, "write"), + required("media.remove", MCP_API_TOOLS.destructive, "destructive"), + required("media.references", MCP_API_TOOLS.read, "read"), // Templates - required("templates.listSystem", "list_system_templates", "read"), + required("templates.listSystem", MCP_API_TOOLS.read, "read"), required("templates.create", "create_template", "write"), required("templates.list", "list_templates", "read"), required("templates.get", "get_template", "read"), required("templates.update", "update_template", "write"), - required("templates.duplicate", "duplicate_template", "write"), - required("templates.remove", "delete_template", "destructive"), + required("templates.duplicate", MCP_API_TOOLS.write, "write"), + required("templates.remove", MCP_API_TOOLS.destructive, "destructive"), // Broadcasts and sequences required("sequences.create", "create_sequence", "write"), required("sequences.list", "list_sequences", "read"), required("sequences.get", "get_sequence", "read"), required("sequences.update", "update_sequence", "write"), - required("sequences.remove", "delete_sequence", "destructive"), - required("sequences.deletionPreview", "preview_sequence_deletion", "read"), - required("sequences.archive", "archive_broadcast", "write"), - required("sequences.unarchive", "unarchive_broadcast", "write"), + required("sequences.remove", MCP_API_TOOLS.destructive, "destructive"), + required("sequences.deletionPreview", MCP_API_TOOLS.read, "read"), + required("sequences.archive", MCP_API_TOOLS.write, "write"), + required("sequences.unarchive", MCP_API_TOOLS.write, "write"), required("sequences.addEmail", "add_sequence_email", "write"), required("sequences.updateEmail", "update_sequence_email", "write"), required("contacts.import", "import_contacts", "write"), - required("suppressions.create", "add_suppression", "write"), - required("forms.list", "list_signup_forms", "read"), - required("forms.create", "create_signup_form", "write"), - required("forms.get", "get_signup_form", "read"), - required("forms.update", "update_signup_form", "write"), - required("forms.remove", "delete_signup_form", "destructive"), - required("contacts.export", "export_contacts", "read"), + required("suppressions.create", MCP_API_TOOLS.write, "write"), + required("forms.list", MCP_API_TOOLS.read, "read"), + required("forms.create", MCP_API_TOOLS.write, "write"), + required("forms.get", MCP_API_TOOLS.read, "read"), + required("forms.update", MCP_API_TOOLS.write, "write"), + required("forms.remove", MCP_API_TOOLS.destructive, "destructive"), + required("contacts.export", MCP_API_TOOLS.read, "read"), required("sequences.sendEmailTest", "send_sequence_email_test", "write"), - required("sequences.removeEmail", "delete_sequence_email", "destructive"), + required("sequences.removeEmail", MCP_API_TOOLS.destructive, "destructive"), required("sequences.start", "start_sequence", "write"), required("sequences.pause", "pause_sequence", "write"), required("sequences.stats", "get_sequence_stats", "read"), - required("sequences.schedule", "get_sequence_schedule", "read"), - required("sequences.waitingContacts", "list_sequence_email_waiting_contacts", "read"), - required("sequences.getWeb", "get_broadcast_web_publishing", "read"), - required("sequences.updateWeb", "update_broadcast_web_publishing", "write"), - required("web.list", "list_web_issues", "read"), - required("web.get", "get_web_issue", "read"), - required("reputation.get", "get_reputation", "read"), - required("reputation.connectGoogle", "connect_google_postmaster", "write"), - required( - "reputation.disconnectGoogle", - "disconnect_google_postmaster", - "destructive", - ), - required("reputation.connectSnds", "connect_snds", "write"), - required("reputation.disconnectSnds", "disconnect_snds", "destructive"), - required("reputation.sync", "sync_reputation", "write"), - required("sequences.subscribers", "get_sequence_subscribers", "read"), + required("sequences.schedule", MCP_API_TOOLS.read, "read"), + required("sequences.waitingContacts", MCP_API_TOOLS.read, "read"), + required("sequences.getWeb", MCP_API_TOOLS.read, "read"), + required("sequences.updateWeb", MCP_API_TOOLS.write, "write"), + required("web.list", MCP_API_TOOLS.read, "read"), + required("web.get", MCP_API_TOOLS.read, "read"), + required("reputation.get", MCP_API_TOOLS.read, "read"), + required("reputation.connectGoogle", MCP_API_TOOLS.write, "write"), + required("reputation.disconnectGoogle", MCP_API_TOOLS.destructive, "destructive"), + required("reputation.connectSnds", MCP_API_TOOLS.write, "write"), + required("reputation.disconnectSnds", MCP_API_TOOLS.destructive, "destructive"), + required("reputation.sync", MCP_API_TOOLS.write, "write"), + required("sequences.subscribers", MCP_API_TOOLS.read, "read"), // Transactional email required("transactional.send", "send_email", "write"), @@ -185,50 +195,42 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ // Custom events required("events.track", "track_event", "write"), - required("events.definitions", "list_event_definitions", "read"), - required("events.definition", "get_event_definition", "read"), - required("events.createDefinition", "create_event_definition", "write"), - required("events.updateDefinition", "update_event_definition", "write"), - required("events.deleteDefinition", "delete_event_definition", "destructive"), + required("events.definitions", MCP_API_TOOLS.read, "read"), + required("events.definition", MCP_API_TOOLS.read, "read"), + required("events.createDefinition", MCP_API_TOOLS.write, "write"), + required("events.updateDefinition", MCP_API_TOOLS.write, "write"), + required("events.deleteDefinition", MCP_API_TOOLS.destructive, "destructive"), // User-managed ESPs - required("settings.esps.list", "list_esps", "read"), - required("settings.esps.create", "create_esp", "write"), - required("settings.esps.get", "get_esp", "read"), - required("settings.esps.update", "update_esp", "write"), - required("settings.esps.remove", "delete_esp", "destructive"), - required("settings.esps.test", "test_esp", "write"), - required( - "settings.esps.domainAuthentication", - "check_esp_domain_authentication", - "read", - ), - required("settings.esps.activate", "activate_esp", "write"), + required("settings.esps.list", MCP_API_TOOLS.read, "read"), + required("settings.esps.create", MCP_API_TOOLS.write, "write"), + required("settings.esps.get", MCP_API_TOOLS.read, "read"), + required("settings.esps.update", MCP_API_TOOLS.write, "write"), + required("settings.esps.remove", MCP_API_TOOLS.destructive, "destructive"), + required("settings.esps.test", MCP_API_TOOLS.write, "write"), + required("settings.esps.domainAuthentication", MCP_API_TOOLS.read, "read"), + required("settings.esps.activate", MCP_API_TOOLS.write, "write"), restOnly("settings.esps.suspend", ESP_LIFECYCLE), restOnly("settings.esps.resume", ESP_LIFECYCLE), - required("settings.esps.retire", "retire_esp", "destructive"), - mcpOnly("get_esp_config", DEFAULT_ESP_ALIAS), - mcpOnly("update_esp_config", DEFAULT_ESP_ALIAS), - mcpOnly("delete_esp_config", DEFAULT_ESP_ALIAS), - mcpOnly("send_test_email", DEFAULT_ESP_ALIAS), + required("settings.esps.retire", MCP_API_TOOLS.destructive, "destructive"), // General settings - required("settings.general.get", "get_general_settings", "read"), - required("settings.general.update", "update_general_settings", "write"), - required("settings.trackingDomain.get", "get_tracking_domain", "read"), - required("settings.trackingDomain.update", "set_tracking_domain", "write"), - required("settings.trackingDomain.verify", "verify_tracking_domain", "write"), - required("settings.web.get", "get_web_settings", "read"), - required("settings.web.update", "update_web_settings", "write"), + required("settings.general.get", MCP_API_TOOLS.read, "read"), + required("settings.general.update", MCP_API_TOOLS.write, "write"), + required("settings.trackingDomain.get", MCP_API_TOOLS.read, "read"), + required("settings.trackingDomain.update", MCP_API_TOOLS.write, "write"), + required("settings.trackingDomain.verify", MCP_API_TOOLS.write, "write"), + required("settings.web.get", MCP_API_TOOLS.read, "read"), + required("settings.web.update", MCP_API_TOOLS.write, "write"), // Teams and team API keys - required("teams.list", "list_teams", "read"), - required("teams.create", "create_team", "write"), - required("teams.rename", "rename_team", "write"), - required("teams.remove", "delete_team", "destructive"), - required("teams.listKeys", "list_api_keys", "read"), - required("teams.createKey", "create_api_key", "write"), - required("teams.removeKey", "delete_api_key", "destructive"), + required("teams.list", MCP_API_TOOLS.read, "read"), + required("teams.create", MCP_API_TOOLS.write, "write"), + required("teams.rename", MCP_API_TOOLS.write, "write"), + required("teams.remove", MCP_API_TOOLS.destructive, "destructive"), + required("teams.listKeys", MCP_API_TOOLS.read, "read"), + required("teams.createKey", MCP_API_TOOLS.write, "write"), + required("teams.removeKey", MCP_API_TOOLS.destructive, "destructive"), // Organization administration restOnly("account.getDeletion", ACCOUNT_DELETION), @@ -304,21 +306,21 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ ), // User ESP delivery feedback - required("feedback.get", "get_esp_feedback_connection", "read"), - required("feedback.upsert", "upsert_esp_feedback_connection", "write"), + required("feedback.get", MCP_API_TOOLS.read, "read"), + required("feedback.upsert", MCP_API_TOOLS.write, "write"), restOnly( "feedback.rotate", "REST-only rotate endpoint; over MCP, calling upsert_esp_feedback_connection on an existing connection rotates its credential.", ), - required("feedback.test", "test_esp_feedback_connection", "write"), - required("feedback.remove", "delete_esp_feedback_connection", "destructive"), + required("feedback.test", MCP_API_TOOLS.write, "write"), + required("feedback.remove", MCP_API_TOOLS.destructive, "destructive"), // Delivery events and suppressions required("deliveryEvents.list", "list_delivery_events", "read"), - required("deliveryEvents.get", "get_delivery_event", "read"), - required("suppressions.list", "list_suppressions", "read"), - required("suppressions.get", "get_suppression", "read"), - required("suppressions.release", "release_suppression", "write"), + required("deliveryEvents.get", MCP_API_TOOLS.read, "read"), + required("suppressions.list", MCP_API_TOOLS.read, "read"), + required("suppressions.get", MCP_API_TOOLS.read, "read"), + required("suppressions.release", MCP_API_TOOLS.write, "write"), // Billing required("billing.organizationPlanUsage", "get_plan_usage", "read"), From be1236eb276f0484bd8fcd8f0c2a5d662294ab65 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 16:21:41 +0530 Subject: [PATCH 2/6] chore(deps): bump @codelitdev packages to 0.7.0 0.7.0's mcp-server-kit carries tool titles, output schemas and the idempotent/open-world hints the MCP tools use. --- apps/api/package.json | 12 ++++++------ apps/docs/package.json | 2 +- apps/web/package.json | 4 ++-- bun.lock | 42 +++++++++++++++++++----------------------- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index ea28df50..3e339243 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,11 +30,11 @@ "@anthropic-ai/sdk": "^0.135.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/billing": "0.6.0", - "@codelitdev/mcp-server-kit": "0.6.0", - "@codelitdev/oauth-server-kit": "0.6.0", - "@codelitdev/observability": "0.6.0", - "@codelitdev/platform": "0.6.0", + "@codelitdev/billing": "0.7.0", + "@codelitdev/mcp-server-kit": "0.7.0", + "@codelitdev/oauth-server-kit": "0.7.0", + "@codelitdev/observability": "0.7.0", + "@codelitdev/platform": "0.7.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@sendlit/api-contract": "workspace:*", @@ -64,7 +64,7 @@ }, "devDependencies": { "@better-auth/core": "1.7.7", - "@codelitdev/platform-conformance": "0.6.0", + "@codelitdev/platform-conformance": "0.7.0", "@electric-sql/pglite": "0.5.8", "@modelcontextprotocol/client": "^2.0.0", "@sendlit/test-utils": "workspace:*", diff --git a/apps/docs/package.json b/apps/docs/package.json index a474156d..4f9d26ba 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,7 +11,7 @@ "postinstall": "fumadocs-mdx" }, "dependencies": { - "@codelitdev/design-system": "0.6.0", + "@codelitdev/design-system": "0.7.0", "@sendlit/api-contract": "workspace:*", "@sendlit/email-blocks": "workspace:*", "@sendlit/email-editor": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index 34671873..fd430137 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@codelitdev/design-system": "0.6.0", - "@codelitdev/observability": "0.6.0", + "@codelitdev/design-system": "0.7.0", + "@codelitdev/observability": "0.7.0", "@radix-ui/react-alert-dialog": "^1.1.18", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-label": "^2.1.11", diff --git a/bun.lock b/bun.lock index ab60331f..05bbcf4c 100644 --- a/bun.lock +++ b/bun.lock @@ -19,11 +19,11 @@ "@anthropic-ai/sdk": "^0.135.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/billing": "0.6.0", - "@codelitdev/mcp-server-kit": "0.6.0", - "@codelitdev/oauth-server-kit": "0.6.0", - "@codelitdev/observability": "0.6.0", - "@codelitdev/platform": "0.6.0", + "@codelitdev/billing": "0.7.0", + "@codelitdev/mcp-server-kit": "0.7.0", + "@codelitdev/oauth-server-kit": "0.7.0", + "@codelitdev/observability": "0.7.0", + "@codelitdev/platform": "0.7.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@sendlit/api-contract": "workspace:*", @@ -53,7 +53,7 @@ }, "devDependencies": { "@better-auth/core": "1.7.7", - "@codelitdev/platform-conformance": "0.6.0", + "@codelitdev/platform-conformance": "0.7.0", "@electric-sql/pglite": "0.5.8", "@modelcontextprotocol/client": "^2.0.0", "@sendlit/test-utils": "workspace:*", @@ -73,7 +73,7 @@ "name": "@sendlit/docs", "version": "0.1.0", "dependencies": { - "@codelitdev/design-system": "0.6.0", + "@codelitdev/design-system": "0.7.0", "@sendlit/api-contract": "workspace:*", "@sendlit/email-blocks": "workspace:*", "@sendlit/email-editor": "workspace:*", @@ -102,8 +102,8 @@ "name": "@sendlit/web", "version": "0.1.0", "dependencies": { - "@codelitdev/design-system": "0.6.0", - "@codelitdev/observability": "0.6.0", + "@codelitdev/design-system": "0.7.0", + "@codelitdev/observability": "0.7.0", "@radix-ui/react-alert-dialog": "^1.1.18", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-label": "^2.1.11", @@ -416,19 +416,19 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@codelitdev/billing": ["@codelitdev/billing@0.6.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-Fsr1H6ubCYu/tN1rpyAGCEcH8Jb0h5V1zm36H9SqfdnkS+QSOjR9F2BSe1Ij/jeH+5gTGr13lX61lL45/pHwAQ=="], + "@codelitdev/billing": ["@codelitdev/billing@0.7.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-6HzcGDUdvlofS8AVmDRX+qQ83aGQ3m5nzMn9nOPxj0BYx00p/Jpa/6mi0pcDIoaxVpqh8C+eFEev63hYuRKKcg=="], - "@codelitdev/design-system": ["@codelitdev/design-system@0.6.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-7A+Qmckj8FUii1Ly3s9zrZpnvcY5GLZdo5mZABn6VTfu+EgQWMAbuy41+st6h/Y5J55A2W5FU77YY28eHXI+eA=="], + "@codelitdev/design-system": ["@codelitdev/design-system@0.7.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Xc02p/FBqd/3+XWlf6vVKaXiqq1+Bn6UX5UF8TNGuSI3pxaAsrx3b55zUWAZ6fW+vfEnAHzB5lBU+WIJimo5qg=="], - "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.6.0", "", { "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "@codelitdev/platform": ">=0.6.0" } }, "sha512-AOjVsVkmEvIGkjrsEIAfijB4PIuwlXW2p+YdA2KVelE/SGh8DsJuWtwqNzfdp6419T30+IVXSlodcdIqQY8eMA=="], + "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.7.0", "", { "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "@codelitdev/platform": ">=0.7.0" } }, "sha512-fvUVfq5e0iNQUPsUKk2WKa6xj6KV1UtETEa+MK2SlPCKYWcDBGEkm3rVJRQzEHXqGCLV12VWVoXQTbT72gexUw=="], - "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.6.0", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-AjgSkdzWhrgykntLxosKADZeI7JauBz82TCrxRnVs7XmwFJp0PPl4ncSl1KSYFflLzZ33wQvmlsEHc+0tjymNw=="], + "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.7.0", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-aAQJUFTruJyX0I3KB+e2idA2WTRbJm/92GnpiUsL5qWwpk+ydON0CduFrddMi1RY4IkHAH5aA5Py0zYEtGDMzQ=="], - "@codelitdev/observability": ["@codelitdev/observability@0.6.0", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-RIRcxQm/B/bZ5BvAeShM8wyKUUo7i5zb3ljmfnMiK2V8wXKeHUkjHGC3jFPOorFrLqQbu0m73du8ddy1oPNcnQ=="], + "@codelitdev/observability": ["@codelitdev/observability@0.7.0", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-aj0yYsidVSw9iG57sVF3S5AVGiQC2kGapHTTqPafoXNr6OeDK3eQyuPoCGqAtLWzvrFQibD40ffgRbjWtayInQ=="], - "@codelitdev/platform": ["@codelitdev/platform@0.6.0", "", { "peerDependencies": { "@codelitdev/billing": ">=0.6.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-WqaL6b8c+DXVvQWUzt6pBsJjAdQqFgy3BzFgQ9wwQ+/7vZF9TwCL/dtFQ4IoNSohmvREmT2C5hLzqOAu+mLSGQ=="], + "@codelitdev/platform": ["@codelitdev/platform@0.7.0", "", { "peerDependencies": { "@codelitdev/billing": ">=0.7.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-oSV3+Uf68QrPZBNOb02Tbb16joxw6ktQqrnwq0BCfuxoFqUk9mOR7FH+WQiBZFli8Q4O6sblKkUW7f47dM6L3Q=="], - "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.6.0", "", { "peerDependencies": { "@codelitdev/platform": ">=0.6.0" } }, "sha512-KR6jaINk5YUjZOR52GwfVQuoaShmK+l/CBBuhevfiw2ubNG3BRBZrrbt4GVHtnR4WGAjHEY5gscqUmAIzUokdA=="], + "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.7.0", "", { "peerDependencies": { "@codelitdev/platform": ">=0.7.0" } }, "sha512-EHjBx2rIvN8OzL4cKUg+FIo1YdRFa3SKfOmmcGx8YrZUEV3ub6ylVfg0ycFP/qRPUgzYMMRuc7Oujj9SSbGrtw=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -612,7 +612,7 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw=="], - "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.3.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" } }, "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], @@ -2468,8 +2468,6 @@ "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - "@codelitdev/mcp-server-kit/@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="], - "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], @@ -2502,6 +2500,8 @@ "@modelcontextprotocol/sdk/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@modelcontextprotocol/server/@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="], + "@modelcontextprotocol/server/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], @@ -2664,10 +2664,6 @@ "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "@codelitdev/mcp-server-kit/@modelcontextprotocol/server/@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="], - - "@codelitdev/mcp-server-kit/@modelcontextprotocol/server/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - "@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], From 377a3f2c4b835633dff3588e5234ba18a2c53c1b Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 16:21:44 +0530 Subject: [PATCH 3/6] chore(platform): sync managed files with platform-cli 0.7.0 --- .codelit-platform.json | 4 ++-- .github/workflows/platform-conformance.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codelit-platform.json b/.codelit-platform.json index a0025c8c..57555c3f 100644 --- a/.codelit-platform.json +++ b/.codelit-platform.json @@ -4,7 +4,7 @@ "name": "SendLit", "slug": "sendlit" }, - "cliVersion": "0.6.0", + "cliVersion": "0.7.0", "capabilities": [ "auth", "mcp", @@ -12,7 +12,7 @@ "billing" ], "managedFiles": { - ".github/workflows/platform-conformance.yml": "2f7ededb9ad4a18228873bf23e5607aaa3822fc236762af2eda1a18a82d35d8f" + ".github/workflows/platform-conformance.yml": "247203888c4698fd82db6671daf40489d13161fa60b89c259107fdb257b443d1" }, "productOwnedGlobs": [ "apps/**", diff --git a/.github/workflows/platform-conformance.yml b/.github/workflows/platform-conformance.yml index a1b25a61..41d63abc 100644 --- a/.github/workflows/platform-conformance.yml +++ b/.github/workflows/platform-conformance.yml @@ -23,7 +23,7 @@ jobs: if: ${{ !hashFiles('bun.lock') }} - run: bun install --frozen-lockfile # Pinned to the CLI release that wrote this file. `sync` moves it. - - run: bunx @codelitdev/platform-cli@0.6.0 doctor + - run: bunx @codelitdev/platform-cli@0.7.0 doctor - run: bun run --filter "*/api" check:drift # Fails when the API package has no `test:conformance` script. - run: bun run --filter "*/api" test:conformance From d25671fb2f7edbc5fafca5a169b27762f45f3633 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 16:36:04 +0530 Subject: [PATCH 4/6] feat(mcp): one dedicated tool per operation again, ready for directory review Drop the generic api.* tools: the ChatGPT Plugins Directory requires each model-callable operation to be its own tool and rejects discovery plus a generic executor. All 115 tools are back on the kit, built by dedicatedTool() from their REST operation, with their former names and schemas. For directory review, every tool now has a title and an explicit openWorldHint, destructive tools advertise confirm (which the kit requires), and tests pin these. Unexpected errors no longer echo raw messages; known ones keep SendLit's reason. The generic tools' loopback listener, replayed credentials and client IP are gone. ADR 0010 records the decision. --- apps/api/src/app.ts | 3 +- apps/api/src/mcp/api-tools.test.ts | 298 ------------ apps/api/src/mcp/api-tools.ts | 447 ------------------ apps/api/src/mcp/context.ts | 22 - apps/api/src/mcp/parity.test.ts | 3 +- apps/api/src/mcp/routes.test.ts | 13 +- apps/api/src/mcp/routes.ts | 17 +- apps/api/src/mcp/server.test.ts | 65 ++- apps/api/src/mcp/server.ts | 38 +- apps/api/src/mcp/tool.ts | 29 +- apps/api/src/mcp/tools/contacts.test.ts | 240 +++++++++- apps/api/src/mcp/tools/contacts.ts | 202 +++++++- .../src/mcp/tools/delivery-feedback.test.ts | 348 +++++++++++++- apps/api/src/mcp/tools/delivery-feedback.ts | 319 ++++++++++++- apps/api/src/mcp/tools/esp.test.ts | 288 +++++++++++ apps/api/src/mcp/tools/esp.ts | 429 +++++++++++++++++ apps/api/src/mcp/tools/events.test.ts | 128 ++++- apps/api/src/mcp/tools/events.ts | 157 +++++- apps/api/src/mcp/tools/forms.test.ts | 149 ++++++ apps/api/src/mcp/tools/forms.ts | 119 +++++ apps/api/src/mcp/tools/general-settings.ts | 53 +++ apps/api/src/mcp/tools/media.test.ts | 143 ++++++ apps/api/src/mcp/tools/media.ts | 153 ++++++ apps/api/src/mcp/tools/reputation.ts | 130 +++++ apps/api/src/mcp/tools/schemas.ts | 192 +++++++- apps/api/src/mcp/tools/segments.test.ts | 65 ++- apps/api/src/mcp/tools/segments.ts | 92 +++- apps/api/src/mcp/tools/sequences.test.ts | 247 +++++++++- apps/api/src/mcp/tools/sequences.ts | 221 ++++++++- apps/api/src/mcp/tools/settings-tools.test.ts | 159 +++++++ apps/api/src/mcp/tools/tags.test.ts | 112 +++++ apps/api/src/mcp/tools/tags.ts | 92 ++++ apps/api/src/mcp/tools/teams.test.ts | 281 ++++++++++- apps/api/src/mcp/tools/teams.ts | 214 ++++++++- apps/api/src/mcp/tools/templates.test.ts | 63 ++- apps/api/src/mcp/tools/templates.ts | 77 ++- apps/api/src/mcp/tools/tracking-domain.ts | 96 ++++ apps/api/src/mcp/tools/transactional.test.ts | 2 - apps/api/src/mcp/tools/transactional.ts | 3 + apps/api/src/mcp/tools/web.test.ts | 187 ++++++++ apps/api/src/mcp/tools/web.ts | 158 +++++++ .../content/docs/activity/suppressions.mdx | 2 +- .../docs/developers/authentication.mdx | 2 +- apps/docs/content/docs/developers/mcp.mdx | 118 ++++- .../docs/email-marketing/broadcasts.mdx | 4 +- .../content/docs/email-marketing/contacts.mdx | 10 +- .../email-marketing/domain-authentication.mdx | 3 +- .../docs/email-marketing/sequences.mdx | 6 +- .../docs/email-marketing/tracking-domain.mdx | 4 +- .../content/docs/email-marketing/warm-up.mdx | 8 +- .../docs/self-hosting/amazon-ses-feedback.mdx | 2 +- ...the-platform-kit-with-generic-api-tools.md | 62 --- .../decisions/0010-mcp-on-the-platform-kit.md | 61 +++ packages/api-contract/src/index.ts | 1 - packages/api-contract/src/mcp-parity.ts | 194 ++++---- 55 files changed, 5382 insertions(+), 1149 deletions(-) delete mode 100644 apps/api/src/mcp/api-tools.test.ts delete mode 100644 apps/api/src/mcp/api-tools.ts create mode 100644 apps/api/src/mcp/tools/esp.test.ts create mode 100644 apps/api/src/mcp/tools/esp.ts create mode 100644 apps/api/src/mcp/tools/forms.test.ts create mode 100644 apps/api/src/mcp/tools/forms.ts create mode 100644 apps/api/src/mcp/tools/general-settings.ts create mode 100644 apps/api/src/mcp/tools/media.test.ts create mode 100644 apps/api/src/mcp/tools/media.ts create mode 100644 apps/api/src/mcp/tools/reputation.ts create mode 100644 apps/api/src/mcp/tools/settings-tools.test.ts create mode 100644 apps/api/src/mcp/tools/tags.test.ts create mode 100644 apps/api/src/mcp/tools/tags.ts create mode 100644 apps/api/src/mcp/tools/tracking-domain.ts create mode 100644 apps/api/src/mcp/tools/web.test.ts create mode 100644 apps/api/src/mcp/tools/web.ts delete mode 100644 docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md create mode 100644 docs/decisions/0010-mcp-on-the-platform-kit.md diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0c72266e..de4a359e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -100,8 +100,7 @@ export function createApp(options: CreateAppOptions) { app.use(oauthPagesRoutes); // The MCP handler owns JSON parsing and protocol content-type errors, so it // must be mounted before the API's global Express body parser. - // Generic api.* tools run REST operations by injecting into this app. - app.use(createMcpRoutes({ app: () => app })); + app.use(createMcpRoutes()); // Mounted before global body parsing: this route needs the unmodified raw // request bytes for provider signature verification and has no // session/API-key concept at all — see delivery-feedback/webhook-route.ts. diff --git a/apps/api/src/mcp/api-tools.test.ts b/apps/api/src/mcp/api-tools.test.ts deleted file mode 100644 index 7d81b322..00000000 --- a/apps/api/src/mcp/api-tools.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import express from "express"; - -mock.module("../db/client", () => ({ db: {}, pool: {} })); - -const { apiTools, MCP_API_OPERATIONS } = await import("./api-tools"); -const { MCP_SCOPES } = await import("./policy"); - -type Seen = { - method: string; - url: string; - headers: Record; - ip?: string; - body: unknown; -}; - -/** A stand-in for the SendLit app, serving a few real contract paths. */ -function fakeApp(seen: Seen[] = []) { - const app = express(); - app.set("trust proxy", 1); - app.use(express.json()); - app.use((req, _res, next) => { - seen.push({ - method: req.method, - url: req.originalUrl, - headers: req.headers, - ip: req.ip, - body: req.body, - }); - next(); - }); - app.get("/settings/esps", (_req, res) => res.json([{ espId: "esp_1" }])); - app.get("/settings/esps/:espId", (req, res) => - req.params.espId === "esp_missing" - ? res.status(404).json({ error: "not_found", error_description: "ESP not found" }) - : res.json({ espId: req.params.espId, createdAt: "2026-01-01T00:00:00.000Z" }), - ); - app.delete("/settings/esps/:espId", (_req, res) => res.status(204).end()); - app.post("/settings/esps/:espId/test", (_req, res) => - res.status(402).json({ error: "plan_limit_reached", limit: 3, nested: { x: 1 } }), - ); - app.post("/settings/esps", (_req, res) => - res.status(400).json({ - name: "ZodError", - issues: [ - { path: ["body", "name"], message: "Required" }, - { path: [], message: "Unrecognized key" }, - ], - }), - ); - app.get("/settings/general", (_req, res) => res.status(500).json({ error: "boom" })); - app.get("/contacts/export", (_req, res) => - res.type("text/csv").send("email\na@b.co\n"), - ); - return app; -} - -const teamKey = { - authKind: "team_key" as const, - teamId: "team-1", - user: null, - scopes: [] as string[], - requestId: "req-1", - clientIp: "203.0.113.9", - replayHeaders: { "x-sendlit-apikey": "tak_secret" }, -}; - -const oauth = (scopes: string[]) => ({ - ...teamKey, - authKind: "oauth" as const, - scopes, - replayHeaders: { authorization: "Bearer tok" }, -}); - -function tools(seen: Seen[] = []) { - const app = fakeApp(seen); - return new Map(apiTools({ app: () => app }).map((tool) => [tool.name, tool])); -} - -async function call( - name: string, - args: Record, - context: any = teamKey, - seen: Seen[] = [], -) { - return (await tools(seen).get(name)!.handler({ - context, - args, - signal: new AbortController().signal, - })) as any; -} - -describe("generic api.* MCP tools", () => { - it("routes only manifest operations, each to the tool for its risk", () => { - expect(MCP_API_OPERATIONS.get("settings.esps.list")).toMatchObject({ - method: "GET", - path: "/settings/esps", - tool: "api.read", - }); - expect(MCP_API_OPERATIONS.get("settings.esps.remove")?.tool).toBe("api.delete"); - expect(MCP_API_OPERATIONS.get("settings.esps.test")?.tool).toBe("api.write"); - // Dedicated tools and REST-only operations are not generic operations. - expect(MCP_API_OPERATIONS.has("contacts.list")).toBe(false); - expect(MCP_API_OPERATIONS.has("billing.portal")).toBe(false); - expect(MCP_API_OPERATIONS.has("organizations.list")).toBe(false); - for (const operation of MCP_API_OPERATIONS.values()) { - expect(operation.tool).toBe( - { read: "api.read", write: "api.write", destructive: "api.delete" }[ - operation.risk - ], - ); - } - }); - - it("lists operations with their scope and whether the connection may run them", async () => { - const { items } = await call( - "api.list_operations", - { search: "esps" }, - oauth([MCP_SCOPES.espRead]), - ); - const list = items.find((item: any) => item.operationId === "settings.esps.list"); - const remove = items.find( - (item: any) => item.operationId === "settings.esps.remove", - ); - expect(list).toMatchObject({ - tool: "api.read", - requiredScope: "esp:read", - available: true, - }); - expect(remove).toMatchObject({ - tool: "api.delete", - requiredScope: "esp:write", - available: false, - }); - expect(items.every((item: any) => item.path.includes("esps"))).toBe(true); - }); - - it("describes an operation's parameters and response from the OpenAPI document", async () => { - const described = await call("api.describe_operation", { - operationId: "settings.esps.get", - }); - expect(described).toMatchObject({ method: "GET", path: "/settings/esps/:espId" }); - expect(described.pathParams).toEqual([ - expect.objectContaining({ name: "espId", in: "path" }), - ]); - expect(described.response?.status).toBe(200); - expect(await call("api.describe_operation", { operationId: "nope" })).toMatchObject( - { - error: { - code: "validation_failed", - safeDetails: { reason: "unknown_operation" }, - }, - }, - ); - }); - - it("runs the REST route with the caller's credential, request id and IP", async () => { - const seen: Seen[] = []; - const result = await call( - "api.read", - { operationId: "settings.esps.get", pathParams: { espId: "esp 1" } }, - teamKey, - seen, - ); - expect(result).toEqual({ espId: "esp 1", createdAt: "2026-01-01T00:00:00.000Z" }); - expect(seen[0]).toMatchObject({ - method: "GET", - url: "/settings/esps/esp%201", - ip: "203.0.113.9", - }); - expect(seen[0]?.headers).toMatchObject({ - "x-sendlit-apikey": "tak_secret", - "x-request-id": "req-1", - }); - }); - - it("wraps arrays, empty responses and non-JSON bodies", async () => { - expect(await call("api.read", { operationId: "settings.esps.list" })).toEqual({ - items: [{ espId: "esp_1" }], - }); - expect( - await call("api.delete", { - operationId: "settings.esps.remove", - pathParams: { espId: "esp_1" }, - confirm: true, - }), - ).toEqual({ ok: true }); - expect(await call("api.read", { operationId: "contacts.export" })).toEqual({ - contentType: expect.stringContaining("text/csv"), - body: "email\na@b.co\n", - }); - }); - - it("maps REST errors to platform codes, keeping the REST reason and primitive details", async () => { - expect( - await call("api.read", { - operationId: "settings.esps.get", - pathParams: { espId: "esp_missing" }, - }), - ).toMatchObject({ - error: { - code: "not_found", - safeDetails: { reason: "not_found", message: "ESP not found" }, - }, - }); - const limited = await call("api.write", { - operationId: "settings.esps.test", - pathParams: { espId: "esp_1" }, - }); - expect(limited.error.code).toBe("forbidden"); - expect(limited.error.safeDetails).toEqual({ - reason: "plan_limit_reached", - limit: 3, - }); - expect( - await call("api.write", { operationId: "settings.esps.create", body: {} }), - ).toMatchObject({ - error: { - code: "validation_failed", - safeDetails: { - reason: "invalid_request", - message: "body.name: Required; Unrecognized key", - }, - }, - }); - // Server errors never leak their body. - const failed = await call("api.read", { operationId: "settings.general.get" }); - expect(failed.error).toEqual({ - code: "internal_error", - message: expect.any(String), - }); - }); - - it("refuses the wrong tool, missing or path-escaping parameters and unknown operations", async () => { - expect( - await call("api.read", { - operationId: "settings.esps.remove", - pathParams: { espId: "e" }, - }), - ).toMatchObject({ - error: { safeDetails: { reason: "wrong_tool", useTool: "api.delete" } }, - }); - expect(await call("api.read", { operationId: "settings.esps.get" })).toMatchObject({ - error: { safeDetails: { reason: "missing_path_param", param: "espId" } }, - }); - for (const espId of [".", ".."]) { - expect( - await call("api.read", { - operationId: "settings.esps.get", - pathParams: { espId }, - }), - ).toMatchObject({ error: { safeDetails: { reason: "invalid_path_param" } } }); - } - expect(await call("api.read", { operationId: "contacts.list" })).toMatchObject({ - error: { safeDetails: { reason: "unknown_operation" } }, - }); - expect(await call("api.write", { operationId: "billing.portal" })).toMatchObject({ - error: { safeDetails: { reason: "unknown_operation" } }, - }); - }); - - it("enforces the operation's OAuth scope and keeps API keys away from key management", async () => { - const seen: Seen[] = []; - expect( - await call( - "api.read", - { operationId: "settings.esps.list" }, - oauth([MCP_SCOPES.contactsRead]), - seen, - ), - ).toMatchObject({ - error: { - code: "forbidden", - safeDetails: { reason: "insufficient_scope", requiredScope: "esp:read" }, - }, - }); - expect( - await call( - "api.read", - { operationId: "teams.listKeys", pathParams: { teamId: "team_1" } }, - teamKey, - seen, - ), - ).toMatchObject({ - error: { code: "forbidden", safeDetails: { reason: "user_auth_required" } }, - }); - expect(seen).toHaveLength(0); - - const allowed: Seen[] = []; - await call( - "api.read", - { operationId: "settings.esps.list" }, - oauth([MCP_SCOPES.espRead]), - allowed, - ); - expect(allowed[0]?.headers).toMatchObject({ authorization: "Bearer tok" }); - }); -}); diff --git a/apps/api/src/mcp/api-tools.ts b/apps/api/src/mcp/api-tools.ts deleted file mode 100644 index 81e9315c..00000000 --- a/apps/api/src/mcp/api-tools.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { createServer, type RequestListener } from "node:http"; -import type { AddressInfo } from "node:net"; -import { createPlatformError, type PlatformErrorCode } from "@codelitdev/platform"; -import { contract, MCP_API_TOOLS, mcpParityManifest } from "@sendlit/api-contract"; -import { openApiDocument } from "@sendlit/api-contract/openapi"; -import { z } from "zod"; -import logger from "../services/log"; -import type { SendLitMcpContext, SendLitMcpTool } from "./context"; -import { authorizeMcpOperation, getRequiredScope } from "./policy"; -import { toolError } from "./tools/responses"; - -type ApiRisk = keyof typeof MCP_API_TOOLS; - -type ApiOperation = { - operationId: string; - method: string; - path: string; - risk: ApiRisk; - tool: string; -}; - -type ContractRoute = { method: string; path: string }; - -type OpenApiOperation = { - summary?: string; - tags?: string[]; - parameters?: Array<{ - name: string; - in: string; - required?: boolean; - schema?: unknown; - }>; - requestBody?: { content?: Record }; - responses?: Record }>; -}; - -/** Every ts-rest route, keyed by its dotted router-key operation id. */ -function contractRoutes( - router: object, - prefix: string[] = [], -): Map { - const routes = new Map(); - for (const [key, value] of Object.entries(router)) { - if (!value || typeof value !== "object") continue; - const route = value as Partial; - if (typeof route.method === "string" && typeof route.path === "string") { - routes.set([...prefix, key].join("."), { - method: route.method.toUpperCase(), - path: route.path, - }); - } else { - for (const entry of contractRoutes(value, [...prefix, key])) routes.set(...entry); - } - } - return routes; -} - -const ROUTES = contractRoutes(contract); - -const API_TOOL_RISK = new Map( - (Object.entries(MCP_API_TOOLS) as Array<[ApiRisk, string]>).map(([risk, tool]) => [ - tool, - risk, - ]), -); - -/** - * REST operations the parity manifest routes to a generic `api.*` tool. - * Operations with a dedicated tool, and REST-only operations (billing, - * organization administration, account deletion), are not here. - */ -export const MCP_API_OPERATIONS: ReadonlyMap = new Map( - mcpParityManifest.flatMap((entry) => { - if (entry.parity !== "required") return []; - const risk = API_TOOL_RISK.get(entry.mcp.tool); - const route = ROUTES.get(entry.rest.operationId); - if (!risk || !route) return []; - return [ - [ - entry.rest.operationId, - { operationId: entry.rest.operationId, ...route, risk, tool: entry.mcp.tool }, - ] as const, - ]; - }), -); - -function openApiOperation(operation: ApiOperation): OpenApiOperation | undefined { - const paths = ( - openApiDocument as { paths: Record> } - ).paths; - const openApiPath = operation.path.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); - return paths[openApiPath]?.[operation.method.toLowerCase()]; -} - -function pathPattern(path: string): RegExp { - const escaped = path - .split("/") - .map((segment) => - segment.startsWith(":") - ? "[^/]+" - : segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - ) - .join("/"); - return new RegExp(`^${escaped}$`); -} - -/** Whether a concrete path reaches exactly this operation, so a parameter - * value (`export` is its own route, not a contact id) can't re-route the call - * to an operation the caller was not authorized for. A path without - * parameters is the operation's own literal route. */ -function reachesOnly(operation: ApiOperation, concretePath: string): boolean { - if (!operation.path.includes(":")) return concretePath === operation.path; - for (const [operationId, route] of ROUTES) { - if (operationId === operation.operationId || route.method !== operation.method) - continue; - if (pathPattern(route.path).test(concretePath)) return false; - } - return pathPattern(operation.path).test(concretePath); -} - -function unknownOperation(operationId: string) { - return toolError("validation_failed", { reason: "unknown_operation", operationId }); -} - -const STATUS_CODES: Record = { - 400: "validation_failed", - 401: "unauthenticated", - 402: "forbidden", - 403: "forbidden", - 404: "not_found", - 409: "conflict", - 413: "validation_failed", - 415: "validation_failed", - 422: "validation_failed", - 429: "rate_limited", -}; - -/** A REST error body (`{ error, error_description, ... }`) as a tool error: - * the REST code becomes `reason`, its description `message`, and other - * primitive fields (limits, upgrade URLs) are kept. */ -function restError(status: number, body: unknown) { - const code = - STATUS_CODES[status] ?? (status >= 500 ? "internal_error" : "validation_failed"); - const details: Record = {}; - if (body && typeof body === "object" && !Array.isArray(body)) { - for (const [key, value] of Object.entries(body)) { - if (value !== null && !["string", "number", "boolean"].includes(typeof value)) - continue; - if (key === "error") details.reason = value as string; - else if (key === "error_description") details.message = value as string; - else details[key] = value as string | number | boolean | null; - } - // ts-rest's request validation answers `{ name: "ZodError", issues }`; - // the issues are what tell the model which fields to fix. - const issues = (body as { issues?: unknown }).issues; - if (Array.isArray(issues) && issues.length > 0) { - delete details.name; - details.reason ??= "invalid_request"; - details.message ??= issues - .slice(0, 10) - .map((issue: { path?: unknown[]; message?: unknown }) => { - const path = Array.isArray(issue.path) ? issue.path.join(".") : ""; - return path ? `${path}: ${String(issue.message)}` : String(issue.message); - }) - .join("; "); - } - } - if (code === "internal_error") return { error: createPlatformError(code) }; - return toolError(code, Object.keys(details).length ? details : undefined); -} - -const queryValueSchema = z.union([ - z.string(), - z.number(), - z.boolean(), - z.array(z.string()), -]); - -const callInputShape = { - operationId: z.string().min(1).describe("An operationId from api.list_operations."), - pathParams: z - .record(z.string()) - .optional() - .describe("Values for the operation's path parameters, e.g. { espId: 'esp_…' }."), - query: z - .record(queryValueSchema) - .optional() - .describe("Query-string parameters, as listed by api.describe_operation."), -}; - -const bodySchema = z.object({}).passthrough(); - -type CallInput = { - operationId: string; - pathParams?: Record; - query?: Record>; - body?: unknown; -}; - -export type ApiToolDeps = { - /** The SendLit HTTP app that serves each generic call. */ - app: () => RequestListener; -}; - -const loopbackOrigins = new WeakMap>(); - -/** - * A loopback-only listener for the same app, so a generic call is a real - * REST request through every middleware and route, without leaving the host - * or going through the public load balancer. Started on first use; it does - * not keep the process alive. - */ -function loopbackOrigin(app: RequestListener): Promise { - let origin = loopbackOrigins.get(app); - if (!origin) { - origin = new Promise((resolve, reject) => { - const server = createServer(app); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.unref(); - resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`); - }); - }); - origin.catch(() => loopbackOrigins.delete(app)); - loopbackOrigins.set(app, origin); - } - return origin; -} - -async function callOperation( - deps: ApiToolDeps, - context: SendLitMcpContext, - tool: string, - input: CallInput, - signal: AbortSignal, -): Promise { - const operation = MCP_API_OPERATIONS.get(input.operationId); - if (!operation) return unknownOperation(input.operationId); - if (operation.tool !== tool) { - return toolError("validation_failed", { - reason: "wrong_tool", - useTool: operation.tool, - }); - } - const denied = authorizeMcpOperation(operation.operationId, context); - if (denied) return { error: denied }; - - let path = operation.path; - for (const name of operation.path.match(/:([A-Za-z0-9_]+)/g) ?? []) { - const value = input.pathParams?.[name.slice(1)]; - if (!value) { - return toolError("validation_failed", { - reason: "missing_path_param", - param: name.slice(1), - }); - } - // `.` and `..` survive encoding and would be resolved away as path - // segments, reaching another route. - if (value === "." || value === "..") { - return toolError("validation_failed", { reason: "invalid_path_param" }); - } - path = path.replace(name, encodeURIComponent(value)); - } - if (!reachesOnly(operation, path)) { - return toolError("validation_failed", { reason: "invalid_path_param" }); - } - - const search = new URLSearchParams(); - for (const [key, value] of Object.entries(input.query ?? {})) { - for (const item of Array.isArray(value) ? value : [value]) { - search.append(key, String(item)); - } - } - const query = search.toString(); - const startedAt = performance.now(); - const origin = await loopbackOrigin(deps.app()); - const response = await fetch(`${origin}${path}${query ? `?${query}` : ""}`, { - method: operation.method, - headers: { - ...context.replayHeaders, - "content-type": "application/json", - ...(context.requestId ? { "x-request-id": context.requestId } : {}), - // With `trust proxy` on, REST sees (and rate limits) the MCP caller's - // IP, not this loopback hop. - ...(context.clientIp ? { "x-forwarded-for": context.clientIp } : {}), - }, - body: operation.method === "GET" ? undefined : JSON.stringify(input.body ?? {}), - redirect: "manual", - signal, - }); - const text = await response.text(); - logger.info( - { - tool, - operationId: operation.operationId, - requiredScope: getRequiredScope(operation.operationId), - authKind: context.authKind, - statusCode: response.status, - durationMs: Math.round(performance.now() - startedAt), - }, - "MCP api operation completed", - ); - - const contentType = response.headers.get("content-type") ?? ""; - const isJson = contentType.includes("application/json"); - let body: unknown; - if (isJson && text) { - try { - body = JSON.parse(text); - } catch { - body = undefined; - } - } - if (response.status >= 400) return restError(response.status, body); - if (response.status === 204 || !text) return { ok: true }; - if (!isJson) return { contentType, body: text }; - return Array.isArray(body) ? { items: body } : body; -} - -const CALL_GUIDE = - "Get the operationId from api.list_operations and its parameters and body from api.describe_operation. The call runs exactly as the REST API does, with the same validation, plan limits and errors, for the connected team."; - -function callTool( - deps: ApiToolDeps, - risk: ApiRisk, - description: string, - inputSchema: z.ZodObject, -): SendLitMcpTool { - const tool = MCP_API_TOOLS[risk]; - return { - name: tool, - description, - risk, - inputSchema, - handler: ({ context, args, signal }) => - callOperation(deps, context, tool, args as CallInput, signal), - }; -} - -function describe(operation: ApiOperation, context: SendLitMcpContext) { - const spec = openApiOperation(operation); - return { - operationId: operation.operationId, - tool: operation.tool, - method: operation.method, - path: operation.path, - summary: spec?.summary ?? null, - group: spec?.tags?.[0] ?? null, - requiredScope: getRequiredScope(operation.operationId), - available: authorizeMcpOperation(operation.operationId, context) === null, - }; -} - -/** - * Generic access to every team REST operation the parity manifest routes to - * MCP, beyond the dedicated tools. Calls run through SendLit's own REST - * routes, so validation, plan limits and errors are the REST API's. - */ -export function apiTools(deps: ApiToolDeps): SendLitMcpTool[] { - return [ - { - name: "api.list_operations", - description: - "Lists the SendLit API operations this connection can run beyond the dedicated tools: sending providers (ESPs) and their delivery feedback, suppressions, sender reputation, the tracking domain, general and web settings, signup forms, media, tags, segments, templates, sequence and contact management (including deletes), event definitions, teams and API keys. Each item gives the tool that runs it (api.read, api.write or api.delete), the OAuth scope it needs and whether this connection may run it. Filter with search (matches the operation id, path, summary or group) or group.", - risk: "read", - inputSchema: z.object({ - search: z.string().max(100).optional(), - group: z.string().max(100).optional(), - }), - annotations: { idempotentHint: true, openWorldHint: false }, - async handler({ context, args }) { - const { search, group } = args as { search?: string; group?: string }; - const needle = search?.trim().toLowerCase(); - const items = [...MCP_API_OPERATIONS.values()] - .map((operation) => describe(operation, context)) - .filter( - (item) => - !group || item.group?.toLowerCase() === group.trim().toLowerCase(), - ) - .filter( - (item) => - !needle || - [item.operationId, item.path, item.summary ?? "", item.group ?? ""].some( - (text) => text.toLowerCase().includes(needle), - ), - ); - return { items }; - }, - }, - { - name: "api.describe_operation", - description: - "Describes one operation from api.list_operations: its path and query parameters, the JSON Schema of its request body, and the schema of a successful response.", - risk: "read", - inputSchema: z.object({ operationId: z.string().min(1) }), - annotations: { idempotentHint: true, openWorldHint: false }, - async handler({ context, args }) { - const { operationId } = args as { operationId: string }; - const operation = MCP_API_OPERATIONS.get(operationId); - if (!operation) return unknownOperation(operationId); - const spec = openApiOperation(operation); - const success = Object.entries(spec?.responses ?? {}).find(([status]) => - status.startsWith("2"), - ); - return { - ...describe(operation, context), - pathParams: (spec?.parameters ?? []).filter((param) => param.in === "path"), - query: (spec?.parameters ?? []).filter((param) => param.in === "query"), - body: spec?.requestBody?.content?.["application/json"]?.schema ?? null, - response: success - ? { - status: Number(success[0]), - schema: success[1].content?.["application/json"]?.schema ?? null, - } - : null, - }; - }, - }, - callTool( - deps, - "read", - `Runs a read-only SendLit API operation (GET). ${CALL_GUIDE}`, - z.object(callInputShape), - ), - callTool( - deps, - "write", - `Runs a SendLit API operation that creates or changes data, or has an external effect such as testing an ESP. Pass the JSON request body in body. ${CALL_GUIDE} Repeating a create usually makes a duplicate or fails with conflict, so read before retrying.`, - z.object({ - ...callInputShape, - body: bodySchema.optional().describe("The JSON request body."), - }), - ), - callTool( - deps, - "destructive", - `Runs a SendLit API operation that deletes or permanently disables something (deleting contacts, templates, sequences, ESPs or API keys, retiring an ESP, disconnecting reputation sources). It can't be undone: confirm the target with the user first and pass confirm: true. ${CALL_GUIDE}`, - z.object({ - ...callInputShape, - body: bodySchema - .optional() - .describe("The JSON request body, if the operation takes one."), - confirm: z.boolean().optional(), - }), - ), - ]; -} diff --git a/apps/api/src/mcp/context.ts b/apps/api/src/mcp/context.ts index 011e9afb..ca7fdb77 100644 --- a/apps/api/src/mcp/context.ts +++ b/apps/api/src/mcp/context.ts @@ -6,16 +6,10 @@ import { type HeaderMap, type PlatformError, } from "@codelitdev/platform"; -import { CLIENT_IP_HEADER } from "../abuse/signup"; import { resolveUserTeam } from "../auth/require-team"; import { type AuthResult, resolveAuth } from "../auth/resolve-auth"; import type { User } from "../user/queries"; -/** Headers a generic `api.*` tool replays on the REST request it makes for - * the caller, so REST authenticates and picks the team exactly as it would - * for the caller's own request. */ -const REPLAYED_HEADERS = ["authorization", "x-sendlit-apikey", "x-sendlit-team-id"]; - /** What every SendLit MCP tool runs with. */ export type SendLitMcpContext = { authKind: "oauth" | "team_key"; @@ -26,9 +20,6 @@ export type SendLitMcpContext = { /** OAuth scopes granted to the token; empty for a team API key. */ scopes: readonly string[]; requestId: string; - clientIp: string; - /** The caller's credential and team headers. Never logged. */ - replayHeaders: Record; }; export type SendLitMcpTool = McpToolDefinition; @@ -142,17 +133,6 @@ export async function resolveMcpContext({ return { ok: false, error: createPlatformError("unauthenticated") }; } - const replayHeaders: Record = {}; - for (const name of REPLAYED_HEADERS) { - const value = headerValue(headers, name); - if (value) replayHeaders[name] = value; - } - // The route aliases `x-sendlit-apikey` to `X-API-Key` for the kit; REST - // reads the SendLit header. - const apiKey = headerValue(headers, "x-api-key"); - if (apiKey && !replayHeaders.authorization) - replayHeaders["x-sendlit-apikey"] = apiKey; - return { ok: true, context: { @@ -161,8 +141,6 @@ export async function resolveMcpContext({ user: auth.user, scopes: auth.kind === "oauth" ? auth.scopes : [], requestId: headerValue(headers, "x-request-id") ?? "", - clientIp: headerValue(headers, CLIENT_IP_HEADER) ?? "", - replayHeaders, }, }; } diff --git a/apps/api/src/mcp/parity.test.ts b/apps/api/src/mcp/parity.test.ts index ef1ab45a..c922d279 100644 --- a/apps/api/src/mcp/parity.test.ts +++ b/apps/api/src/mcp/parity.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, mock } from "bun:test"; import { validateParityManifest } from "@codelitdev/mcp-server-kit"; import { contract, mcpParityManifest } from "@sendlit/api-contract"; -import express from "express"; mock.module("../db/client", () => ({ db: {}, pool: {} })); @@ -23,7 +22,7 @@ function collectRestOperationIds(router: object, prefix: string[] = []): string[ describe("platform conformance: MCP parity manifest", () => { it("documents every REST operation and MCP tool with matching risk", () => { const restOperationIds = new Set(collectRestOperationIds(contract)); - const tools = createSendLitMcp({ app: () => express() }).listTools(); + const tools = createSendLitMcp().listTools(); const mcpToolNames = new Set(tools.map((tool) => tool.name)); const mcpToolRisks = new Map(tools.map((tool) => [tool.name, tool.risk] as const)); diff --git a/apps/api/src/mcp/routes.test.ts b/apps/api/src/mcp/routes.test.ts index 9f95c315..2614241a 100644 --- a/apps/api/src/mcp/routes.test.ts +++ b/apps/api/src/mcp/routes.test.ts @@ -77,7 +77,7 @@ beforeAll(async () => { return router; }); const { createMcpRoutes } = await import("./routes"); - routes = createMcpRoutes({ app: () => express() }); + routes = createMcpRoutes(); }); afterEach(async () => { @@ -156,11 +156,7 @@ describe("MCP route", () => { expect(authInput.headers["x-api-key"]).toBe("sl_live_key"); expect(authInput.headers["x-sendlit-apikey"]).toBeUndefined(); expect(authInput.apiKeyHeader).toBeUndefined(); - expect(contexts[0]).toMatchObject({ - requestId: "req-1", - clientIp: "127.0.0.1", - replayHeaders: { "x-sendlit-apikey": "sl_live_key" }, - }); + expect(contexts[0]).toMatchObject({ requestId: "req-1", scopes: [], user: null }); }); it("challenges a request without credentials so OAuth clients can discover the server", async () => { @@ -206,10 +202,7 @@ describe("MCP route", () => { expect(mocks.resolveUserTeam).toHaveBeenCalledWith("user-1", "team_public"); expect(contexts[0]).toMatchObject({ scopes: ["contacts:read"], - replayHeaders: { - authorization: "Bearer tok", - "x-sendlit-team-id": "team_public", - }, + user: { id: "user-1" }, }); mocks.resolveUserTeam.mockResolvedValue({ diff --git a/apps/api/src/mcp/routes.ts b/apps/api/src/mcp/routes.ts index 2a67729d..38d0dad1 100644 --- a/apps/api/src/mcp/routes.ts +++ b/apps/api/src/mcp/routes.ts @@ -7,7 +7,6 @@ import express, { Router, } from "express"; import rateLimit from "express-rate-limit"; -import { CLIENT_IP_HEADER } from "../abuse/signup"; import { auth, mcpProtectedResourceMetadataUrl, @@ -15,7 +14,6 @@ import { oauthResourceClient, } from "../auth/better-auth"; import logger from "../services/log"; -import type { ApiToolDeps } from "./api-tools"; import { runMcpRequest } from "./context"; import { MCP_SCOPES_SUPPORTED } from "./policy"; import { createSendLitMcp } from "./server"; @@ -25,11 +23,15 @@ const SENDLIT_CORS_HEADERS = "x-sendlit-apikey, X-Sendlit-Team-Id"; const highImpactTools = new Set([ "send_email", - "send_sequence_email_test", + "send_test_email", "track_event", + "test_esp", "start_sequence", - "api.write", - "api.delete", + "create_api_key", + "delete_api_key", + "delete_team", + "delete_contacts", + "release_suppression", ]); const mcpLimiter = rateLimit({ @@ -75,9 +77,9 @@ const jsonBody = (req: Request, res: Response, next: NextFunction) => { }); }; -export function createMcpRoutes(deps: ApiToolDeps): Router { +export function createMcpRoutes(): Router { const router = Router(); - const mcp = createSendLitMcp(deps); + const mcp = createSendLitMcp(); router.use( createMcpOAuthDiscoveryRoutes({ @@ -111,7 +113,6 @@ export function createMcpRoutes(deps: ApiToolDeps): Router { return; } if (sendlitKey) headers["x-api-key"] = sendlitKey; - headers[CLIENT_IP_HEADER] = req.ip ?? ""; headers["x-request-id"] = String(res.locals.requestId ?? ""); const controller = new AbortController(); diff --git a/apps/api/src/mcp/server.test.ts b/apps/api/src/mcp/server.test.ts index b6cd6443..71b4c6f8 100644 --- a/apps/api/src/mcp/server.test.ts +++ b/apps/api/src/mcp/server.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, mock } from "bun:test"; import { mcpParityManifest } from "@sendlit/api-contract"; -import express from "express"; +import { z } from "zod"; mock.module("../db/client", () => ({ db: {}, pool: {} })); -const { createSendLitMcp, dedicatedTools } = await import("./server"); +const { sendLitMcpTools } = await import("./server"); const { authorizeMcpOperation, listMcpOperationScopes, MCP_SCOPES } = await import( "./policy" ); @@ -15,29 +15,39 @@ const context = { user: null, scopes: [] as string[], requestId: "", - clientIp: "", - replayHeaders: {}, }; describe("SendLit MCP catalog and policy", () => { - it("serves the dedicated tools then the generic api.* tools, in a stable order", () => { - const names = createSendLitMcp({ app: () => express() }) - .listTools() - .map((tool) => tool.name); - expect(names.slice(-5)).toEqual([ - "api.list_operations", - "api.describe_operation", - "api.read", - "api.write", - "api.delete", - ]); - expect(names).toHaveLength(34); + it("serves one uniquely named tool per manifest tool, in a stable order", () => { + const names = sendLitMcpTools().map((tool) => tool.name); + const manifestTools = mcpParityManifest.flatMap((entry) => + entry.mcp ? [entry.mcp.tool] : [], + ); expect(new Set(names).size).toBe(names.length); - expect(names.slice(0, 3)).toEqual([ - "list_contacts", - "get_contact", - "create_contact", - ]); + expect([...names].sort()).toEqual([...new Set(manifestTools)].sort()); + expect(sendLitMcpTools().map((tool) => tool.name)).toEqual(names); + }); + + // What the Claude and ChatGPT directories review (see ADR 0010). + it("gives every tool a title, short name, description and explicit open-world hint", () => { + for (const tool of sendLitMcpTools()) { + expect(tool.title?.length, tool.name).toBeGreaterThan(0); + expect(tool.name.length, tool.name).toBeLessThanOrEqual(64); + expect(tool.description.length, tool.name).toBeGreaterThan(20); + expect(typeof tool.annotations?.openWorldHint, tool.name).toBe("boolean"); + } + }); + + it("advertises confirm on every destructive tool", () => { + const destructive = sendLitMcpTools().filter((tool) => tool.risk === "destructive"); + expect(destructive.length).toBeGreaterThan(0); + for (const tool of destructive) { + expect(tool.inputSchema, tool.name).toBeInstanceOf(z.ZodObject); + expect( + Object.keys((tool.inputSchema as z.AnyZodObject).shape), + tool.name, + ).toContain("confirm"); + } }); it("gives every MCP-reachable operation a scope, and no other operation one", () => { @@ -47,19 +57,6 @@ describe("SendLit MCP catalog and policy", () => { expect(Object.keys(listMcpOperationScopes()).sort()).toEqual(reachable); }); - it("maps each dedicated tool to the operation its manifest entry names", () => { - const toolOperation = new Map( - mcpParityManifest.flatMap((entry) => - entry.parity === "required" - ? [[entry.mcp.tool, entry.rest.operationId] as const] - : [], - ), - ); - for (const tool of dedicatedTools()) { - expect(toolOperation.has(tool.name)).toBe(true); - } - }); - it("lets team keys run everything but key management", () => { expect(authorizeMcpOperation("transactional.send", context)).toBeNull(); expect(authorizeMcpOperation("settings.esps.remove", context)).toBeNull(); diff --git a/apps/api/src/mcp/server.ts b/apps/api/src/mcp/server.ts index 4e0f93c6..49533840 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -1,6 +1,5 @@ import { createMcpServerKit, type McpServerKit } from "@codelitdev/mcp-server-kit"; import { captureError } from "../observability"; -import { type ApiToolDeps, apiTools } from "./api-tools"; import { authenticateMcpRequest, resolveMcpContext, @@ -8,48 +7,63 @@ import { type SendLitMcpTool, } from "./context"; import { contactTools } from "./tools/contacts"; -import { deliveryEventTools } from "./tools/delivery-feedback"; +import { deliveryFeedbackTools } from "./tools/delivery-feedback"; +import { espTools } from "./tools/esp"; import { eventTools } from "./tools/events"; +import { formTools } from "./tools/forms"; +import { generalSettingsTools } from "./tools/general-settings"; +import { mediaTools } from "./tools/media"; +import { reputationTools } from "./tools/reputation"; import { segmentTools } from "./tools/segments"; import { sequenceTools } from "./tools/sequences"; +import { tagTools } from "./tools/tags"; import { teamTools } from "./tools/teams"; import { templateTools } from "./tools/templates"; +import { trackingDomainTools } from "./tools/tracking-domain"; import { transactionalTools } from "./tools/transactional"; +import { webTools } from "./tools/web"; export const SENDLIT_MCP_VERSION = "3.0.0"; -/** Dedicated tools for the common flows, in a stable order so catalog caches - * and upstream prompt caches see a deterministic tool list. */ -export function dedicatedTools(): SendLitMcpTool[] { +/** Every SendLit MCP tool, one per operation, in a stable order so catalog + * caches and upstream prompt caches see a deterministic tool list. */ +export function sendLitMcpTools(): SendLitMcpTool[] { return [ ...contactTools(), ...segmentTools(), + ...tagTools(), + ...formTools(), + ...webTools(), + ...reputationTools(), ...templateTools(), + ...mediaTools(), ...sequenceTools(), ...transactionalTools(), ...eventTools(), - ...deliveryEventTools(), + ...generalSettingsTools(), + ...trackingDomainTools(), + ...espTools(), ...teamTools(), + ...deliveryFeedbackTools(), ]; } /** * SendLit's MCP endpoint on the CodeLit Platform kit (ADR 0010): stateless, * protocol 2026-07-28 plus 2025-era Streamable HTTP, with SendLit's own - * credential and team resolution. Dedicated tools cover the everyday flows; - * the generic api.* tools reach every other team REST operation. + * credential and team resolution and one dedicated tool per operation. */ -export function createSendLitMcp(deps: ApiToolDeps): McpServerKit { +export function createSendLitMcp(): McpServerKit { return createMcpServerKit({ name: "SendLit", version: SENDLIT_MCP_VERSION, description: - "SendLit MCP server: compose, send and automate email for a SendLit team. Dedicated tools manage contacts, segments, templates, broadcasts and sequences, transactional email, events and delivery events. Everything else the team REST API offers (sending providers, settings, reputation, signup forms, media, tags, suppressions, teams, API keys and deletes) is reachable through the api.* tools.", + "SendLit MCP server — compose, send and automate email for a SendLit team. Manage contacts and segments, templates and media, broadcasts and sequences, transactional email, sending providers, settings, team API keys, and delivery feedback. Organization bootstrap, shared-delivery policy, and team provisioning use the REST API, not MCP.", instructions: - "Every call acts on the connected team. Use the dedicated tools for contacts, segments, templates, sequences and broadcasts, sending email and tracking events. For anything else, find the operation with api.list_operations, read its parameters and body with api.describe_operation, then run it with api.read, api.write or api.delete. Read before you write. Sending, activation, deletion, key management and suppression changes have external or destructive effects: confirm the target and inputs with the user first, and pass confirm: true to api.delete. MCP is team-scoped: organization administration, shared-delivery setup, team provisioning and billing use the REST API (see /openapi.json), not MCP.", + "Use read tools to inspect existing state before mutations. Sending, activation, deletion, key management, and suppression-release tools have external or destructive effects; confirm the requested target and inputs with the user before calling them, and pass confirm: true to destructive tools. MCP is team-scoped and does not accept organization API keys. Use the REST API and /openapi.json for organization discovery, headless shared-delivery setup, and team provisioning.", authenticate: authenticateMcpRequest, resolveContext: resolveMcpContext, onError: ({ error, source }) => captureError({ error, source }), - tools: [...dedicatedTools(), ...apiTools(deps)], + tools: sendLitMcpTools(), }); } diff --git a/apps/api/src/mcp/tool.ts b/apps/api/src/mcp/tool.ts index 107c8d17..389b45da 100644 --- a/apps/api/src/mcp/tool.ts +++ b/apps/api/src/mcp/tool.ts @@ -1,5 +1,5 @@ import type { McpToolRisk } from "@codelitdev/mcp-server-kit"; -import type { z } from "zod"; +import { z } from "zod"; import logger from "../services/log"; import { serializeDates } from "../utils/serialize"; import type { SendLitMcpContext, SendLitMcpTool } from "./context"; @@ -9,7 +9,8 @@ export type DedicatedToolDefinition = { name: string; /** The REST operation this tool performs; its scope authorizes the call. */ operationId: McpOperationId; - title?: string; + /** Shown to people in place of `name`; directories require one. */ + title: string; description: string; risk: McpToolRisk; inputSchema: z.ZodType; @@ -18,20 +19,34 @@ export type DedicatedToolDefinition = { handler: (input: { context: SendLitMcpContext; args: Args }) => Promise; }; +const confirmSchema = z + .boolean() + .optional() + .describe("Set to true once the user has confirmed this action; it can't be undone."); + /** - * A dedicated MCP tool for a common flow. Every call is authorized against - * its REST operation's scope before the handler runs (default deny: an - * operation without a policy throws here, at startup), and dates in the - * result become ISO strings, as REST sends them. + * The MCP tool for one REST operation. Every call is authorized against the + * operation's scope before the handler runs (default deny: an operation + * without a policy throws here, at startup), and dates in the result become + * ISO strings, as REST sends them. A destructive tool advertises `confirm`, + * which the kit requires to be `true`. `openWorldHint` is always explicit: + * false unless the tool reaches outside the team's workspace. */ export function dedicatedTool( definition: DedicatedToolDefinition, ): SendLitMcpTool { const { operationId, handler, ...tool } = definition; const requiredScope = getRequiredScope(operationId); + const inputSchema = + tool.risk === "destructive" && + definition.inputSchema instanceof z.ZodObject && + !("confirm" in definition.inputSchema.shape) + ? definition.inputSchema.extend({ confirm: confirmSchema }) + : definition.inputSchema; return { ...tool, - inputSchema: definition.inputSchema, + inputSchema, + annotations: { openWorldHint: false, ...tool.annotations }, async handler({ context, args }) { const startedAt = performance.now(); const log = { tool: tool.name, requiredScope, authKind: context.authKind }; diff --git a/apps/api/src/mcp/tools/contacts.test.ts b/apps/api/src/mcp/tools/contacts.test.ts index 27162494..df91d65b 100644 --- a/apps/api/src/mcp/tools/contacts.test.ts +++ b/apps/api/src/mcp/tools/contacts.test.ts @@ -9,10 +9,19 @@ const mocks = { updateContact: vi.fn(), addTagToContact: vi.fn(), removeTagFromContact: vi.fn(), + deleteContact: vi.fn(), + getDeliveriesByContact: vi.fn(), getSegment: vi.fn(), + exportContactsCsv: vi.fn(), + selectionCondition: vi.fn(), + countSelection: vi.fn(), + startContactDeletion: vi.fn(), + getContactDeletion: vi.fn(), }; mock.module("../../contacts/import", () => ({ importContacts: mocks.importContacts })); +// events.test.ts mocks the same module; bun shares mocks across files run +// in one process, so both list every export either one needs. mock.module("../../contacts/queries", () => ({ countContacts: mocks.countContacts, createContact: mocks.createContact, @@ -21,13 +30,27 @@ mock.module("../../contacts/queries", () => ({ updateContact: mocks.updateContact, addTagToContact: mocks.addTagToContact, removeTagFromContact: mocks.removeTagFromContact, + deleteContact: mocks.deleteContact, + getDeliveriesByContact: mocks.getDeliveriesByContact, })); -// segments.test.ts mocks the same module; bun shares mocks across files run -// in one process, so both list every export either one needs. +// Likewise segments.test.ts. mock.module("../../contacts/segments-queries", () => ({ getSegment: mocks.getSegment, createSegment: vi.fn(), listSegments: vi.fn(), + updateSegment: vi.fn(), + deleteSegment: vi.fn(), +})); +mock.module("../../contacts/export", () => ({ + exportContactsCsv: mocks.exportContactsCsv, +})); +mock.module("../../contacts/bulk-delete", () => ({ + selectionCondition: mocks.selectionCondition, + countSelection: mocks.countSelection, +})); +mock.module("../../contacts/deletion-queue", () => ({ + startContactDeletion: mocks.startContactDeletion, + getContactDeletion: mocks.getContactDeletion, })); const { contactTools } = await import("./contacts"); @@ -40,8 +63,6 @@ const teamKeyContext = { user: null, scopes: [], requestId: "", - clientIp: "", - replayHeaders: {}, }; const oauthContext = { ...teamKeyContext, @@ -64,20 +85,30 @@ beforeEach(() => { }); describe("MCP contact tools", () => { - it("keeps only the dedicated contact tools", () => { - expect([...tools.keys()].sort()).toEqual( - [ - "add_contact_tag", - "create_contact", - "get_contact", - "import_contacts", - "list_contacts", - "remove_contact_tag", - "update_contact", - ].sort(), - ); + it("has one titled tool per contact operation, in the original order", () => { + expect([...tools.keys()]).toEqual([ + "import_contacts", + "export_contacts", + "list_contacts", + "get_contact", + "get_contact_deliveries", + "create_contact", + "update_contact", + "add_contact_tag", + "remove_contact_tag", + "delete_contact", + "preview_delete_contacts", + "delete_contacts", + "get_contact_deletion", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); expect(tools.get("list_contacts")!.risk).toBe("read"); expect(tools.get("create_contact")!.risk).toBe("write"); + expect(tools.get("delete_contact")!.risk).toBe("destructive"); + expect(tools.get("delete_contacts")!.risk).toBe("destructive"); + // Destructive tools advertise the kit's confirmation flag. + const deleteSchema = tools.get("delete_contacts")!.inputSchema as any; + expect(Object.keys(deleteSchema.shape)).toContain("confirm"); }); it("scopes list/create calls to the resolved team and serializes dates", async () => { @@ -205,4 +236,181 @@ describe("MCP contact tools", () => { }); expect(mocks.createContact).not.toHaveBeenCalled(); }); + + it("bulk deletes only with a valid selection and passes the acting user", async () => { + const missingCount = await call("delete_contacts", { match: {}, confirm: true }); + expect(missingCount).toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "invalid_selection" }, + }, + }); + expect(mocks.startContactDeletion).not.toHaveBeenCalled(); + + mocks.startContactDeletion.mockResolvedValue({ + status: 409, + body: { error: "count_changed", count: 7 }, + }); + await expect( + call( + "delete_contacts", + { match: { q: "old" }, expectedCount: 5, confirm: true }, + oauthContext, + ), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "count_changed", count: 7 } }, + }); + expect(mocks.startContactDeletion).toHaveBeenCalledWith({ + teamId: "team-1", + selection: { match: { q: "old" } }, + expectedCount: 5, + actorUserId: "user-1", + }); + + const queued = { + deletionId: "cdel_1", + status: "queued", + count: 5000, + deleted: 0, + suppressed: 0, + }; + mocks.startContactDeletion.mockResolvedValue({ status: 202, body: queued }); + await expect( + call("delete_contacts", { match: {}, expectedCount: 5000, confirm: true }), + ).resolves.toEqual(queued); + + mocks.startContactDeletion.mockResolvedValue({ + status: 404, + body: { error: "segment_not_found" }, + }); + await expect( + call("delete_contacts", { + match: { segmentId: "seg_x" }, + expectedCount: 1, + confirm: true, + }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + + mocks.getContactDeletion.mockResolvedValue(null); + await expect( + call("get_contact_deletion", { deletionId: "cdel_1" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + mocks.getContactDeletion.mockResolvedValue(queued); + await expect( + call("get_contact_deletion", { deletionId: "cdel_1" }), + ).resolves.toEqual(queued); + expect(mocks.getContactDeletion).toHaveBeenCalledWith("team-1", "cdel_1"); + }); + + it("previews a bulk delete", async () => { + mocks.selectionCondition.mockResolvedValue({ condition: "sql" }); + mocks.countSelection.mockResolvedValue({ count: 4, unsubscribed: 1 }); + await expect( + call("preview_delete_contacts", { contactIds: ["cnt_a"] }), + ).resolves.toEqual({ count: 4, unsubscribed: 1 }); + expect(mocks.selectionCondition).toHaveBeenCalledWith("team-1", { + contactIds: ["cnt_a"], + }); + + await expect( + call("preview_delete_contacts", { contactIds: ["cnt_a"], match: {} }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "invalid_selection" }, + }, + }); + + mocks.selectionCondition.mockResolvedValue({ error: "segment_not_found" }); + await expect( + call("preview_delete_contacts", { match: { segmentId: "seg_x" } }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + }); + + it("deletes one contact on behalf of the signed-in user", async () => { + mocks.deleteContact.mockResolvedValue(undefined); + await expect( + call("delete_contact", { contactId: "cnt_1", confirm: true }, oauthContext), + ).resolves.toEqual({ message: "Contact deleted." }); + expect(mocks.deleteContact).toHaveBeenCalledWith("team-1", "cnt_1", "user-1"); + }); + + it("returns contact deliveries after checking contact ownership", async () => { + mocks.getContactByContactId.mockResolvedValue({ + id: "internal-1", + contactId: "cnt_1", + teamId: "team-1", + }); + mocks.getDeliveriesByContact.mockResolvedValue([ + { + sequenceId: "seq-1", + sequenceTitle: "Welcome", + sequenceType: "sequence", + emailId: "email-1", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + ]); + await expect( + call("get_contact_deliveries", { contactId: "cnt_1" }), + ).resolves.toEqual({ + items: [ + { + sequenceId: "seq-1", + sequenceTitle: "Welcome", + sequenceType: "sequence", + emailId: "email-1", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + expect(mocks.getDeliveriesByContact).toHaveBeenCalledWith("team-1", "internal-1"); + + mocks.getContactByContactId.mockResolvedValue({ + contactId: "cnt_1", + teamId: "team-2", + }); + await expect( + call("get_contact_deliveries", { contactId: "cnt_1" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("exports CSV for a team segment and refuses another team's", async () => { + mocks.getSegment.mockResolvedValue({ + teamId: "team-1", + filter: { aggregator: "and", filters: [] }, + }); + mocks.exportContactsCsv.mockResolvedValue({ csv: "email\na@b.co\n", count: 1 }); + await expect(call("export_contacts", { segmentId: "seg_1" })).resolves.toEqual({ + csv: "email\na@b.co\n", + contacts: 1, + }); + expect(mocks.exportContactsCsv).toHaveBeenCalledWith({ + teamId: "team-1", + filter: [{ aggregator: "and", filters: [] }], + }); + + mocks.getSegment.mockResolvedValue({ teamId: "team-2", filter: {} }); + await expect( + call("export_contacts", { segmentId: "seg_1" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("needs contacts:write to delete over OAuth", async () => { + await expect( + call( + "delete_contact", + { contactId: "cnt_1", confirm: true }, + { ...oauthContext, scopes: ["contacts:read"] }, + ), + ).resolves.toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "insufficient_scope" } }, + }); + expect(mocks.deleteContact).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/mcp/tools/contacts.ts b/apps/api/src/mcp/tools/contacts.ts index 3fc02bf2..3f6ff919 100644 --- a/apps/api/src/mcp/tools/contacts.ts +++ b/apps/api/src/mcp/tools/contacts.ts @@ -1,30 +1,74 @@ +import type { PlatformErrorCode } from "@codelitdev/platform"; import { + contactDeletionSchema, contactFilterSchema, + contactSelectionObjectSchema, + contactSelectionSchema, customFieldsSchema, + deleteContactsBodySchema, + deleteContactsObjectSchema, importContactsBodySchema, importContactsResultSchema, } from "@sendlit/api-contract"; import { z } from "zod"; +import { countSelection, selectionCondition } from "../../contacts/bulk-delete"; +import { + getContactDeletion, + startContactDeletion, +} from "../../contacts/deletion-queue"; +import { exportContactsCsv } from "../../contacts/export"; import { importContacts } from "../../contacts/import"; import { addTagToContact, countContacts, createContact, + deleteContact, getContactByContactId, + getDeliveriesByContact, listContacts, removeTagFromContact, updateContact, } from "../../contacts/queries"; +import type { ContactFilterWithAggregator } from "../../contacts/segment"; import { getSegment } from "../../contacts/segments-queries"; import { omitInternal } from "../../utils/public"; import type { SendLitMcpTool } from "../context"; import { dedicatedTool } from "../tool"; import { isPlanGateError, notFound, planGateError, toolError } from "./responses"; -import { contactListSchema, contactSchema } from "./schemas"; +import { + contactDeliveryListSchema, + contactListSchema, + contactSchema, + successMessageSchema, +} from "./schemas"; + +const SELECTION_ERROR_CODES: Record = { + 400: "validation_failed", + 404: "not_found", + 409: "conflict", +}; + +/** A bulk-delete refusal as REST returns it (`{ error, ...primitives }`): + * the REST code becomes `reason`, and `count` and the like are kept. */ +function selectionError(status: number, body: Record) { + const details: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (value !== null && !["string", "number", "boolean"].includes(typeof value)) { + continue; + } + details[key === "error" ? "reason" : key] = value as + | string + | number + | boolean + | null; + } + return toolError(SELECTION_ERROR_CODES[status] ?? "validation_failed", details); +} export const contactTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "import_contacts", + title: "Import Contacts", operationId: "contacts.import", risk: "write", description: @@ -46,8 +90,42 @@ export const contactTools = (): SendLitMcpTool[] => [ }, }), + dedicatedTool({ + name: "export_contacts", + title: "Export Contacts", + operationId: "contacts.export", + risk: "read", + description: + "Exports every contact, or those matching a saved segment and/or inline filter, as CSV text: email, name, status (subscribed/unsubscribed/bounced/complained), tags, created_at, suppression_reason, then one column per custom field.", + inputSchema: z.object({ + segmentId: z.string().optional().describe("Only export this saved segment"), + filter: contactFilterSchema + .optional() + .describe("Inline contact filter; combines with segmentId using AND"), + }), + outputSchema: z.object({ csv: z.string(), contacts: z.number().int() }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const filters: ContactFilterWithAggregator[] = []; + if (args.segmentId) { + const segment = await getSegment(args.segmentId); + if (!segment || segment.teamId !== context.teamId) return notFound(); + const parsed = contactFilterSchema.safeParse(segment.filter); + if (!parsed.success) throw new Error("Saved segment filter is invalid"); + filters.push(parsed.data as ContactFilterWithAggregator); + } + if (args.filter) filters.push(args.filter as ContactFilterWithAggregator); + const { csv, count } = await exportContactsCsv({ + teamId: context.teamId, + filter: filters.length ? filters : undefined, + }); + return { csv, contacts: count }; + }, + }), + dedicatedTool({ name: "list_contacts", + title: "List Contacts", operationId: "contacts.list", risk: "read", description: @@ -119,6 +197,7 @@ export const contactTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "get_contact", + title: "Get Contact", operationId: "contacts.get", risk: "read", description: "Returns a single contact by its contact ID.", @@ -134,8 +213,26 @@ export const contactTools = (): SendLitMcpTool[] => [ }, }), + dedicatedTool({ + name: "get_contact_deliveries", + title: "Get Contact Deliveries", + operationId: "contacts.deliveries", + risk: "read", + description: + "Returns the broadcasts and sequence emails a contact has received, newest first.", + inputSchema: z.object({ contactId: z.string().describe("Contact ID") }), + outputSchema: contactDeliveryListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const contact = await getContactByContactId(args.contactId); + if (!contact || contact.teamId !== context.teamId) return notFound(); + return { items: await getDeliveriesByContact(context.teamId, contact.id) }; + }, + }), + dedicatedTool({ name: "create_contact", + title: "Create Contact", operationId: "contacts.create", risk: "write", description: @@ -168,6 +265,7 @@ export const contactTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "update_contact", + title: "Update Contact", operationId: "contacts.update", risk: "write", description: @@ -198,6 +296,7 @@ export const contactTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "add_contact_tag", + title: "Add Contact Tag", operationId: "contacts.addTag", risk: "write", description: @@ -214,6 +313,7 @@ export const contactTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "remove_contact_tag", + title: "Remove Contact Tag", operationId: "contacts.removeTag", risk: "write", description: @@ -231,4 +331,104 @@ export const contactTools = (): SendLitMcpTool[] => [ return omitInternal(contact); }, }), + dedicatedTool({ + name: "delete_contact", + title: "Delete Contact", + operationId: "contacts.remove", + risk: "destructive", + description: "Permanently deletes a contact. This action cannot be undone.", + inputSchema: z.object({ contactId: z.string() }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + await deleteContact(context.teamId, args.contactId, context.user?.id ?? null); + return { message: "Contact deleted." }; + }, + }), + + dedicatedTool({ + name: "preview_delete_contacts", + title: "Preview Contact Deletion", + operationId: "contacts.previewBulkDelete", + risk: "read", + description: + "Counts the contacts delete_contacts would remove, and how many of them are unsubscribed, without deleting anything. Select either contactIds or match (q, segmentId and filter, combined with AND; {} is every contact). Pass the count to delete_contacts as expectedCount.", + inputSchema: contactSelectionObjectSchema, + outputSchema: z.object({ + count: z.number().int(), + unsubscribed: z.number().int(), + }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const parsed = contactSelectionSchema.safeParse(args); + if (!parsed.success) { + return toolError("validation_failed", { + reason: "invalid_selection", + message: "Pass exactly one of contactIds or match.", + }); + } + const resolved = await selectionCondition(context.teamId, parsed.data); + if ("error" in resolved) { + return resolved.error === "segment_not_found" + ? notFound() + : toolError("validation_failed", { reason: resolved.error }); + } + return await countSelection(resolved.condition); + }, + }), + + dedicatedTool({ + name: "delete_contacts", + title: "Delete Contacts", + operationId: "contacts.bulkDelete", + risk: "destructive", + description: + "Permanently deletes contacts in bulk: up to 1000 contactIds, or every contact matching match (q, segmentId, filter; {} is every contact). Call preview_delete_contacts first and confirm the count with the user; with match, expectedCount is required and nothing is deleted if the live count differs (count_changed). Up to 1000 contacts are deleted immediately; larger selections run in the background — poll get_contact_deletion with the returned deletionId. Deleting doesn't trigger sequences. Unsubscribed contacts' addresses are added to the suppression list so they are never mailed again. Delivery logs and suppressions keep the address, so this is not a full privacy erasure. Cannot be undone.", + inputSchema: deleteContactsObjectSchema, + outputSchema: contactDeletionSchema, + annotations: { idempotentHint: false, openWorldHint: false }, + async handler({ context, args }) { + // `confirm` is the kit's destructive-action guard, not part of the body. + const { confirm: _confirm, ...body } = args as typeof args & { + confirm?: boolean; + }; + const parsed = deleteContactsBodySchema.safeParse(body); + if (!parsed.success) { + return toolError("validation_failed", { + reason: "invalid_selection", + message: parsed.error.issues.map((issue) => issue.message).join("; "), + }); + } + const { expectedCount, ...selection } = parsed.data; + const result = await startContactDeletion({ + teamId: context.teamId, + selection, + expectedCount, + actorUserId: context.user?.id ?? null, + }); + if (result.status === 404) return notFound(); + if (result.status === 400 || result.status === 409) { + return selectionError(result.status, result.body as Record); + } + return result.body; + }, + }), + + dedicatedTool({ + name: "get_contact_deletion", + title: "Get Contact Deletion", + operationId: "contacts.bulkDeleteStatus", + risk: "read", + description: + "Returns the progress of a background delete_contacts run: status (queued, running, completed, failed), count, deleted and suppressed. Kept for 24 hours after it finishes.", + inputSchema: z.object({ + deletionId: z.string().describe("deletionId from delete_contacts"), + }), + outputSchema: contactDeletionSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const deletion = await getContactDeletion(context.teamId, args.deletionId); + return deletion ?? notFound(); + }, + }), ]; diff --git a/apps/api/src/mcp/tools/delivery-feedback.test.ts b/apps/api/src/mcp/tools/delivery-feedback.test.ts index 575728df..74e2d548 100644 --- a/apps/api/src/mcp/tools/delivery-feedback.test.ts +++ b/apps/api/src/mcp/tools/delivery-feedback.test.ts @@ -3,39 +3,88 @@ import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; const mocks = { getEspConfigByEspId: vi.fn(), listEspConfigs: vi.fn(), + getFeedbackConnectionForTeamEsp: vi.fn(), + upsertFeedbackConnection: vi.fn(), + feedbackConnectionReadinessError: vi.fn(), + recordFeedbackConnectionVerified: vi.fn(), + disableFeedbackConnection: vi.fn(), listDeliveryEvents: vi.fn(), countDeliveryEvents: vi.fn(), + getDeliveryEventByEventId: vi.fn(), getOutboundMessagesByIds: vi.fn(), + addOrStrengthenSuppression: vi.fn(), + listSuppressions: vi.fn(), + countSuppressions: vi.fn(), + getSuppressionBySuppressionId: vi.fn(), + releaseSuppression: vi.fn(), }; mock.module("../../settings/esp/queries", () => ({ + checkAndRememberDomainAuthentication: vi.fn(), getEspConfigByEspId: mocks.getEspConfigByEspId, listEspConfigs: mocks.listEspConfigs, })); +mock.module("../../delivery-feedback/feedback-connection-queries", () => ({ + getFeedbackConnectionForTeamEsp: mocks.getFeedbackConnectionForTeamEsp, + upsertFeedbackConnection: mocks.upsertFeedbackConnection, + feedbackConnectionReadinessError: mocks.feedbackConnectionReadinessError, + recordFeedbackConnectionVerified: mocks.recordFeedbackConnectionVerified, + disableFeedbackConnection: mocks.disableFeedbackConnection, +})); mock.module("../../delivery-feedback/delivery-event-queries", () => ({ listDeliveryEvents: mocks.listDeliveryEvents, countDeliveryEvents: mocks.countDeliveryEvents, + getDeliveryEventByEventId: mocks.getDeliveryEventByEventId, })); mock.module("../../delivery-feedback/outbound-queries", () => ({ getOutboundMessagesByIds: mocks.getOutboundMessagesByIds, })); +mock.module("../../delivery-feedback/suppression-queries", () => ({ + addOrStrengthenSuppression: mocks.addOrStrengthenSuppression, + listSuppressions: mocks.listSuppressions, + countSuppressions: mocks.countSuppressions, + getSuppressionBySuppressionId: mocks.getSuppressionBySuppressionId, + releaseSuppression: mocks.releaseSuppression, +})); -const { deliveryEventTools } = await import("./delivery-feedback"); +const { deliveryFeedbackTools } = await import("./delivery-feedback"); const signal = new AbortController().signal; const context = { authKind: "team_key" as const, teamId: "team-1", user: null, - scopes: [], + scopes: [] as string[], requestId: "", - clientIp: "", - replayHeaders: {}, }; -const [tool] = deliveryEventTools(); -function call(args: unknown): Promise { - return tool!.handler({ context, args, signal }); +const oauth = { + ...context, + authKind: "oauth" as const, + user: { id: "user-1" } as any, + scopes: [ + "feedback:read", + "feedback:write", + "suppressions:read", + "suppressions:write", + ], +}; +const tools = new Map(deliveryFeedbackTools().map((tool) => [tool.name, tool])); +function call(name: string, args: unknown, ctx: any = context): Promise { + return tools.get(name)!.handler({ context: ctx, args, signal }) as Promise; } +const callList = (args: unknown) => call("list_delivery_events", args); + +const suppressionRow = (overrides: Record = {}) => ({ + suppressionId: "sup_1", + recipientEmail: "ada@example.com", + reason: "hard_bounce", + active: true, + firstSuppressedAt: new Date("2026-10-01T00:00:00.000Z"), + lastSuppressedAt: new Date("2026-10-01T00:00:00.000Z"), + releasedAt: null, + releaseReason: null, + ...overrides, +}); beforeEach(() => { for (const fn of Object.values(mocks)) fn.mockReset(); @@ -43,12 +92,283 @@ beforeEach(() => { mocks.getOutboundMessagesByIds.mockResolvedValue(new Map()); }); -describe("MCP list_delivery_events", () => { - it("is the only dedicated delivery-feedback tool", () => { - expect(deliveryEventTools().map((t) => t.name)).toEqual(["list_delivery_events"]); - expect(tool!.risk).toBe("read"); +describe("MCP delivery-feedback tools", () => { + it("serves every feedback, delivery-event and suppression tool, each with a title", () => { + expect([...tools.keys()]).toEqual([ + "get_esp_feedback_connection", + "upsert_esp_feedback_connection", + "test_esp_feedback_connection", + "delete_esp_feedback_connection", + "list_delivery_events", + "get_delivery_event", + "list_suppressions", + "get_suppression", + "release_suppression", + "add_suppression", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + const remove = tools.get("delete_esp_feedback_connection")!; + expect(remove.risk).toBe("destructive"); + expect((remove.inputSchema as any).shape.confirm).toBeDefined(); + }); + + it("needs the suppressions:write scope over OAuth", async () => { + await expect( + call( + "add_suppression", + { email: "a@example.com" }, + { ...oauth, scopes: ["suppressions:read"] }, + ), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { + reason: "insufficient_scope", + requiredScope: "suppressions:write", + }, + }, + }); + expect(mocks.addOrStrengthenSuppression).not.toHaveBeenCalled(); + }); + + it("rejects configuring feedback for a provider without a reviewed adapter", async () => { + mocks.getEspConfigByEspId.mockResolvedValue({ + id: "esp-internal-1", + espId: "esp_1", + provider: "smtp", + }); + await expect( + call("upsert_esp_feedback_connection", { espId: "esp_1", credential: "secret" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "feedback_provider_unsupported" }, + }, + }); + expect(mocks.upsertFeedbackConnection).not.toHaveBeenCalled(); + }); + + it("requires an SNS topic ARN instead of a credential for SES", async () => { + mocks.getEspConfigByEspId.mockResolvedValue({ + id: "esp-internal-1", + espId: "esp_1", + provider: "ses", + }); + const missing = await call("upsert_esp_feedback_connection", { + espId: "esp_1", + credential: "secret", + }); + expect(missing.error).toMatchObject({ + code: "validation_failed", + safeDetails: { reason: "invalid_feedback_connection" }, + }); + expect(mocks.upsertFeedbackConnection).not.toHaveBeenCalled(); + + mocks.upsertFeedbackConnection.mockResolvedValue({ + id: "conn-1", + connectionId: "whc_ses", + provider: "ses", + encryptedCredentials: null, + expectedTopicArn: "arn:aws:sns:us-east-1:123456789012:feedback", + subscriptionConfirmedAt: null, + status: "pending", + lastReceivedAt: null, + lastVerifiedAt: null, + lastErrorCode: null, + }); + const created = await call("upsert_esp_feedback_connection", { + espId: "esp_1", + expectedTopicArn: "arn:aws:sns:us-east-1:123456789012:feedback", + }); + expect(created.error).toBeUndefined(); + expect(mocks.upsertFeedbackConnection).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "ses", + expectedTopicArn: "arn:aws:sns:us-east-1:123456789012:feedback", + }), + ); + }); + + it("upserts a feedback connection for a capable provider", async () => { + mocks.getEspConfigByEspId.mockResolvedValue({ + id: "esp-internal-1", + espId: "esp_1", + provider: "postmark", + }); + mocks.upsertFeedbackConnection.mockResolvedValue({ + id: "conn-1", + connectionId: "whc_abc", + provider: "postmark", + encryptedCredentials: "enc", + status: "pending", + lastReceivedAt: null, + lastVerifiedAt: null, + lastErrorCode: null, + }); + const result = await call("upsert_esp_feedback_connection", { + espId: "esp_1", + credential: "top-secret", + }); + expect(mocks.upsertFeedbackConnection).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: "team-1", + espConfigId: "esp-internal-1", + provider: "postmark", + credential: "top-secret", + }), + ); + expect(result).toMatchObject({ + connectionId: "whc_abc", + espId: "esp_1", + hasCredential: true, + }); + }); + + it("returns not_found for an unknown ESP or connection", async () => { + mocks.getEspConfigByEspId.mockResolvedValue(null); + for (const name of [ + "get_esp_feedback_connection", + "test_esp_feedback_connection", + "delete_esp_feedback_connection", + ]) { + await expect(call(name, { espId: "esp_missing" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + } + + mocks.getEspConfigByEspId.mockResolvedValue({ + id: "esp-internal-1", + espId: "esp_1", + }); + mocks.disableFeedbackConnection.mockResolvedValue(false); + await expect( + call("delete_esp_feedback_connection", { espId: "esp_1", confirm: true }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + mocks.disableFeedbackConnection.mockResolvedValue(true); + await expect( + call("delete_esp_feedback_connection", { espId: "esp_1", confirm: true }), + ).resolves.toEqual({ message: "Feedback connection disabled." }); + }); + + it("reports whether a connection is armed", async () => { + mocks.getEspConfigByEspId.mockResolvedValue({ + id: "esp-internal-1", + espId: "esp_1", + }); + mocks.getFeedbackConnectionForTeamEsp.mockResolvedValue(null); + await expect( + call("test_esp_feedback_connection", { espId: "esp_1" }), + ).resolves.toEqual({ + success: false, + error: "feedback_not_configured", + }); + + mocks.getFeedbackConnectionForTeamEsp.mockResolvedValue({ id: "conn-1" }); + mocks.feedbackConnectionReadinessError.mockReturnValue(null); + await expect( + call("test_esp_feedback_connection", { espId: "esp_1" }), + ).resolves.toEqual({ + success: true, + }); + expect(mocks.recordFeedbackConnectionVerified).toHaveBeenCalledWith("conn-1"); + }); + + it("returns a delivery event of the team, or not_found", async () => { + mocks.getDeliveryEventByEventId.mockResolvedValue(null); + await expect( + call("get_delivery_event", { eventId: "dev_x" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + expect(mocks.getDeliveryEventByEventId).toHaveBeenCalledWith("team-1", "dev_x"); + }); + + it("blocks a workspace user from releasing a complaint suppression", async () => { + mocks.releaseSuppression.mockRejectedValue(new Error("suppression_not_releasable")); + const result = await call("release_suppression", { suppressionId: "sup_1" }, oauth); + expect(mocks.releaseSuppression).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: "team-1", + suppressionId: "sup_1", + actorType: "workspace_user", + actorUserId: "user-1", + }), + ); + expect(result.error).toMatchObject({ + code: "forbidden", + safeDetails: { reason: "suppression_not_releasable" }, + }); + + mocks.releaseSuppression.mockRejectedValue(new Error("suppression_not_found")); + await expect( + call("release_suppression", { suppressionId: "sup_9" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + mocks.releaseSuppression.mockRejectedValue(new Error("db down")); + await expect( + call("release_suppression", { suppressionId: "sup_9" }), + ).rejects.toThrow("db down"); + }); + + it("releases an eligible suppression", async () => { + mocks.releaseSuppression.mockResolvedValue( + suppressionRow({ + active: false, + releasedAt: new Date(), + releaseReason: "mailbox fixed", + }), + ); + const result = await call("release_suppression", { + suppressionId: "sup_1", + explanation: "mailbox fixed", + }); + expect(result).toMatchObject({ suppressionId: "sup_1", active: false }); + expect(mocks.releaseSuppression).toHaveBeenCalledWith( + expect.objectContaining({ actorUserId: null }), + ); + }); + + it("lists, gets and adds suppressions for the team", async () => { + mocks.listSuppressions.mockResolvedValue([suppressionRow()]); + mocks.countSuppressions.mockResolvedValue(1); + const list = await call("list_suppressions", { active: true }); + expect(mocks.listSuppressions).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "team-1", active: true }), + ); + expect(list).toMatchObject({ total: 1, items: [{ suppressionId: "sup_1" }] }); + expect(list.items[0].firstSuppressedAt).toBe("2026-10-01T00:00:00.000Z"); + + mocks.getSuppressionBySuppressionId.mockResolvedValue(null); + await expect( + call("get_suppression", { suppressionId: "sup_x" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + + mocks.addOrStrengthenSuppression.mockResolvedValue( + suppressionRow({ reason: "manual" }), + ); + await expect( + call( + "add_suppression", + { email: "ada@example.com", note: "role account" }, + oauth, + ), + ).resolves.toMatchObject({ reason: "manual" }); + expect(mocks.addOrStrengthenSuppression).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: "team-1", + recipientEmail: "ada@example.com", + reason: "manual", + actorUserId: "user-1", + explanation: "role account", + }), + ); }); +}); +describe("MCP list_delivery_events", () => { it("lists the team's events with the ESP and message of each", async () => { const occurredAt = new Date("2026-10-01T00:00:00.000Z"); mocks.listDeliveryEvents.mockResolvedValue([ @@ -78,7 +398,7 @@ describe("MCP list_delivery_events", () => { ]), ); - const result = await call({ + const result = await callList({ eventType: "hard_bounce", offset: 2, itemsPerPage: 10, @@ -110,7 +430,7 @@ describe("MCP list_delivery_events", () => { it("filters by an ESP of the team, and returns nothing for an unknown one", async () => { mocks.getEspConfigByEspId.mockResolvedValue(null); - await expect(call({ espId: "esp_other" })).resolves.toEqual({ + await expect(callList({ espId: "esp_other" })).resolves.toEqual({ items: [], total: 0, }); @@ -120,7 +440,7 @@ describe("MCP list_delivery_events", () => { mocks.getEspConfigByEspId.mockResolvedValue({ id: "esp-internal" }); mocks.listDeliveryEvents.mockResolvedValue([]); mocks.countDeliveryEvents.mockResolvedValue(0); - await call({ espId: "esp_1" }); + await callList({ espId: "esp_1" }); expect(mocks.listDeliveryEvents).toHaveBeenCalledWith( expect.objectContaining({ espConfigId: "esp-internal" }), ); diff --git a/apps/api/src/mcp/tools/delivery-feedback.ts b/apps/api/src/mcp/tools/delivery-feedback.ts index 8f514f49..62bbe4fb 100644 --- a/apps/api/src/mcp/tools/delivery-feedback.ts +++ b/apps/api/src/mcp/tools/delivery-feedback.ts @@ -1,14 +1,64 @@ import { z } from "zod"; +import { feedbackCapableProviders } from "../../config/constants"; import { countDeliveryEvents, type DeliveryEvent, + getDeliveryEventByEventId, listDeliveryEvents, } from "../../delivery-feedback/delivery-event-queries"; +import { + disableFeedbackConnection, + feedbackConnectionReadinessError, + getFeedbackConnectionForTeamEsp, + recordFeedbackConnectionVerified, + upsertFeedbackConnection, +} from "../../delivery-feedback/feedback-connection-queries"; +import { + feedbackConnectionInputError, + toPublicFeedbackConnection, +} from "../../delivery-feedback/feedback-connection-shape"; import { getOutboundMessagesByIds } from "../../delivery-feedback/outbound-queries"; +import { + addOrStrengthenSuppression, + countSuppressions, + getSuppressionBySuppressionId, + listSuppressions, + releaseSuppression, + type Suppression, +} from "../../delivery-feedback/suppression-queries"; import { getEspConfigByEspId, listEspConfigs } from "../../settings/esp/queries"; import type { SendLitMcpTool } from "../context"; import { dedicatedTool } from "../tool"; -import { deliveryEventListSchema } from "./schemas"; +import { notFound, toolError } from "./responses"; +import { + deliveryEventListSchema, + deliveryEventSchema, + feedbackConnectionSchema, + successMessageSchema, + suppressionListSchema, + suppressionSchema, + testEspResultSchema, +} from "./schemas"; + +type FeedbackProvider = (typeof feedbackCapableProviders)[number]; + +function toPublicSuppression(suppression: Suppression) { + return { + suppressionId: suppression.suppressionId, + recipientEmail: suppression.recipientEmail, + reason: suppression.reason as + | "hard_bounce" + | "complaint" + | "repeated_soft_bounce" + | "provider_suppression" + | "manual", + active: suppression.active, + firstSuppressedAt: suppression.firstSuppressedAt, + lastSuppressedAt: suppression.lastSuppressedAt, + releasedAt: suppression.releasedAt, + releaseReason: suppression.releaseReason, + }; +} async function toPublicEvents(teamId: string, events: DeliveryEvent[]) { const [esps, outboundById] = await Promise.all([ @@ -43,14 +93,133 @@ async function toPublicEvents(teamId: string, events: DeliveryEvent[]) { } /** - * The delivery-events side of `docs/bounces-and-complaints.md`, mirroring - * `GET /delivery-events`. Feedback connections and suppressions are reachable - * through the generic `api.*` tools. + * MCP tools for `docs/bounces-and-complaints.md` — mirrors the + * `/settings/esps/:espId/feedback`, `/delivery-events`, and `/suppressions` + * REST resources so MCP clients get the same collection-aware, tenant-scoped + * behavior. Only providers with a reviewed adapter + * (`feedbackCapableProviders`) can be configured. */ -export const deliveryEventTools = (): SendLitMcpTool[] => [ +export const deliveryFeedbackTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "get_esp_feedback_connection", + operationId: "feedback.get", + title: "Get ESP Feedback Connection", + risk: "read", + description: + "Returns a user ESP's bounce/complaint webhook connection (URL, status, health), or null if not configured yet. Never returns the credential.", + inputSchema: z.object({ espId: z.string().min(1) }), + outputSchema: z.object({ connection: feedbackConnectionSchema.nullable() }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const esp = await getEspConfigByEspId(context.teamId, args.espId); + if (!esp) return notFound(); + const connection = await getFeedbackConnectionForTeamEsp(context.teamId, esp.id); + return { + connection: connection + ? toPublicFeedbackConnection(esp.espId, connection) + : null, + }; + }, + }), + + dedicatedTool({ + name: "upsert_esp_feedback_connection", + operationId: "feedback.upsert", + title: "Set Up ESP Feedback Connection", + risk: "write", + description: + "Creates a user ESP's feedback connection (first call) or rotates its credential (later calls) — the webhook URL stays stable across rotation. The provider is always the ESP's current provider (must be one of: " + + feedbackCapableProviders.join(", ") + + "). Amazon SES takes `expectedTopicArn` (the SNS topic subscribed to the returned webhook URL) instead of a credential; every other provider requires `credential`.", + inputSchema: z.object({ + espId: z.string().min(1), + credential: z + .string() + .min(1) + .optional() + .describe( + "The provider's webhook signing secret / shared header value. Required for every provider except SES.", + ), + expectedTopicArn: z + .string() + .optional() + .describe( + "SES only, required: the ARN of the SNS topic SES publishes bounce/complaint events to", + ), + }), + outputSchema: feedbackConnectionSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const esp = await getEspConfigByEspId(context.teamId, args.espId); + if (!esp) return notFound(); + if (!feedbackCapableProviders.includes(esp.provider as FeedbackProvider)) { + return toolError("validation_failed", { + reason: "feedback_provider_unsupported", + message: `Provider "${esp.provider}" does not have a reviewed feedback adapter yet.`, + }); + } + const inputError = feedbackConnectionInputError(esp.provider, args); + if (inputError) { + return toolError("validation_failed", { + reason: "invalid_feedback_connection", + message: inputError, + }); + } + const connection = await upsertFeedbackConnection({ + teamId: context.teamId, + espConfigId: esp.id, + provider: esp.provider as FeedbackProvider, + credential: args.credential, + expectedTopicArn: args.expectedTopicArn, + }); + return toPublicFeedbackConnection(esp.espId, connection); + }, + }), + + dedicatedTool({ + name: "test_esp_feedback_connection", + operationId: "feedback.test", + title: "Test ESP Feedback Connection", + risk: "write", + description: + "Checks a feedback connection is fully armed (a smoke test — it does not call the provider): the stored credential decrypts, or for SES, the SNS subscription has been confirmed.", + inputSchema: z.object({ espId: z.string().min(1) }), + outputSchema: testEspResultSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const esp = await getEspConfigByEspId(context.teamId, args.espId); + if (!esp) return notFound(); + const connection = await getFeedbackConnectionForTeamEsp(context.teamId, esp.id); + if (!connection) return { success: false, error: "feedback_not_configured" }; + const readinessError = feedbackConnectionReadinessError(connection); + if (readinessError) return { success: false, error: readinessError }; + await recordFeedbackConnectionVerified(connection.id); + return { success: true }; + }, + }), + + dedicatedTool({ + name: "delete_esp_feedback_connection", + operationId: "feedback.remove", + title: "Delete ESP Feedback Connection", + risk: "destructive", + description: "Disables a user ESP's feedback connection.", + inputSchema: z.object({ espId: z.string().min(1) }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const esp = await getEspConfigByEspId(context.teamId, args.espId); + if (!esp) return notFound(); + const disabled = await disableFeedbackConnection(context.teamId, esp.id); + if (!disabled) return notFound(); + return { message: "Feedback connection disabled." }; + }, + }), + dedicatedTool({ name: "list_delivery_events", operationId: "deliveryEvents.list", + title: "List Delivery Events", risk: "read", description: "Returns a paginated list of normalized bounce/complaint/delivery events for the team, most recent first.", @@ -113,4 +282,144 @@ export const deliveryEventTools = (): SendLitMcpTool[] => [ return { items: await toPublicEvents(teamId, events), total }; }, }), + + dedicatedTool({ + name: "get_delivery_event", + operationId: "deliveryEvents.get", + title: "Get Delivery Event", + risk: "read", + description: "Returns a single normalized delivery event by its ID.", + inputSchema: z.object({ eventId: z.string() }), + outputSchema: deliveryEventSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const event = await getDeliveryEventByEventId(context.teamId, args.eventId); + if (!event) return notFound(); + const [publicEvent] = await toPublicEvents(context.teamId, [event]); + return publicEvent; + }, + }), + + dedicatedTool({ + name: "list_suppressions", + operationId: "suppressions.list", + title: "List Suppressions", + risk: "read", + description: + "Returns a paginated list of the team's suppressed (do-not-send) recipients.", + inputSchema: z.object({ + active: z.boolean().optional(), + reason: z + .enum([ + "hard_bounce", + "complaint", + "repeated_soft_bounce", + "provider_suppression", + "manual", + ]) + .optional(), + offset: z.number().int().min(1).optional(), + itemsPerPage: z.number().int().min(1).optional(), + }), + outputSchema: suppressionListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const filters = { + teamId: context.teamId, + active: args.active, + reason: args.reason, + }; + const [items, total] = await Promise.all([ + listSuppressions({ + ...filters, + offset: args.offset, + rowsPerPage: args.itemsPerPage, + }), + countSuppressions(filters), + ]); + return { items: items.map(toPublicSuppression), total }; + }, + }), + + dedicatedTool({ + name: "get_suppression", + operationId: "suppressions.get", + title: "Get Suppression", + risk: "read", + description: "Returns a single suppression by its ID.", + inputSchema: z.object({ suppressionId: z.string() }), + outputSchema: suppressionSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const suppression = await getSuppressionBySuppressionId( + context.teamId, + args.suppressionId, + ); + if (!suppression) return notFound(); + return toPublicSuppression(suppression); + }, + }), + + dedicatedTool({ + name: "release_suppression", + operationId: "suppressions.release", + title: "Release Suppression", + risk: "write", + description: + "Releases an active suppression. Only hard_bounce, repeated_soft_bounce, and manual suppressions can be released this way — complaint suppressions require a SendLit operator and are never releasable through this tool.", + inputSchema: z.object({ + suppressionId: z.string(), + explanation: z.string().min(1).max(2000).optional(), + }), + outputSchema: suppressionSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const suppression = await releaseSuppression({ + teamId: context.teamId, + suppressionId: args.suppressionId, + actorType: "workspace_user", + actorUserId: context.user?.id ?? null, + explanation: args.explanation, + }); + return toPublicSuppression(suppression); + } catch (err: any) { + if (err?.message === "suppression_not_found") return notFound(); + if (err?.message === "suppression_not_releasable") { + return toolError("forbidden", { + reason: "suppression_not_releasable", + message: + "This suppression cannot be released by a workspace user (e.g. a complaint suppression) — it requires a SendLit operator.", + }); + } + throw err; + } + }, + }), + + dedicatedTool({ + name: "add_suppression", + operationId: "suppressions.create", + title: "Add Suppression", + risk: "write", + description: + "Suppresses an address for the team (reason `manual`): SendLit never mails it again (broadcasts, sequences or transactional) until released. For role accounts, legal requests or known spam traps.", + inputSchema: z.object({ + email: z.string().email(), + note: z.string().max(2000).optional().describe("Why; kept in the audit trail"), + }), + outputSchema: suppressionSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const suppression = await addOrStrengthenSuppression({ + teamId: context.teamId, + recipientEmail: args.email, + reason: "manual", + actorType: "workspace_user", + actorUserId: context.user?.id ?? null, + explanation: args.note ?? null, + }); + return toPublicSuppression(suppression); + }, + }), ]; diff --git a/apps/api/src/mcp/tools/esp.test.ts b/apps/api/src/mcp/tools/esp.test.ts new file mode 100644 index 00000000..55a9625b --- /dev/null +++ b/apps/api/src/mcp/tools/esp.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + checkAndRememberDomainAuthentication: vi.fn(), + createEspConfig: vi.fn(), + deleteEspConfig: vi.fn(), + getEspConfig: vi.fn(), + getEspConfigByEspId: vi.fn(), + listEspConfigs: vi.fn(), + transitionEspConfig: vi.fn(), + updateEspConfig: vi.fn(), + upsertEspConfig: vi.fn(), + invalidateEspTransport: vi.fn(), + invalidateTeamTransport: vi.fn(), + testEspConfig: vi.fn(), +}; + +// bun shares module mocks across test files in one process, so this mock +// exports everything the MCP tool modules import from the ESP queries. +mock.module("../../settings/esp/queries", () => ({ + checkAndRememberDomainAuthentication: mocks.checkAndRememberDomainAuthentication, + createEspConfig: mocks.createEspConfig, + deleteEspConfig: mocks.deleteEspConfig, + getEspConfig: mocks.getEspConfig, + getEspConfigByEspId: mocks.getEspConfigByEspId, + listEspConfigs: mocks.listEspConfigs, + transitionEspConfig: mocks.transitionEspConfig, + updateEspConfig: mocks.updateEspConfig, + upsertEspConfig: mocks.upsertEspConfig, +})); +mock.module("../../mail/transport", () => ({ + invalidateEspTransport: mocks.invalidateEspTransport, + invalidateTeamTransport: mocks.invalidateTeamTransport, +})); +mock.module("../../settings/esp/test", () => ({ testEspConfig: mocks.testEspConfig })); + +const { espTools } = await import("./esp"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [] as string[], + requestId: "", +}; +const oauth = { + ...context, + authKind: "oauth" as const, + user: { id: "user-1", email: "owner@example.com" } as any, + scopes: ["esp:read", "esp:write"], +}; +const tools = new Map(espTools().map((tool) => [tool.name, tool])); +const call = (name: string, args: Record = {}, ctx: any = context) => + tools.get(name)!.handler({ context: ctx, args, signal }) as Promise; + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP ESP tools", () => { + it("registers every ESP tool with a title, and confirm on destructive ones", () => { + expect([...tools.keys()]).toEqual([ + "get_esp_config", + "update_esp_config", + "delete_esp_config", + "send_test_email", + "list_esps", + "create_esp", + "get_esp", + "check_esp_domain_authentication", + "update_esp", + "delete_esp", + "activate_esp", + "retire_esp", + "test_esp", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + const retire = tools.get("retire_esp")!.inputSchema as any; + expect(retire.shape.confirm).toBeDefined(); + expect((tools.get("get_esp")!.inputSchema as any).shape.confirm).toBeUndefined(); + }); + + it("returns the public ESP shape and invalidates the transport on update", async () => { + mocks.upsertEspConfig.mockResolvedValue({ + provider: "smtp", + host: "smtp.example.com", + port: 587, + secure: false, + username: "user", + encryptedSecret: "secret", + }); + const args = { + provider: "smtp", + host: "smtp.example.com", + port: 587, + secure: false, + }; + const result = await call("update_esp_config", args); + expect(mocks.upsertEspConfig).toHaveBeenCalledWith("team-1", args); + expect(mocks.invalidateTeamTransport).toHaveBeenCalledWith("team-1"); + expect(result).toMatchObject({ provider: "smtp", hasPassword: true }); + expect(result).not.toHaveProperty("encryptedSecret"); + }); + + it("requires an explicit destination for API-key test sends", async () => { + mocks.getEspConfig.mockResolvedValue({ provider: "smtp" }); + mocks.testEspConfig.mockResolvedValue({ + success: false, + error: "No destination email address available.", + }); + await expect(call("send_test_email")).resolves.toEqual({ + success: false, + error: "No destination email address available.", + }); + expect(mocks.testEspConfig).toHaveBeenCalledWith( + expect.objectContaining({ account: null, source: "mcp.send_test_email" }), + ); + + mocks.getEspConfig.mockResolvedValue(null); + await expect(call("send_test_email")).resolves.toEqual({ + success: false, + error: "No ESP configured for this team yet.", + }); + }); + + it("lists, creates and gets the team's ESPs", async () => { + mocks.listEspConfigs.mockResolvedValue([ + { espId: "esp_1", name: "Primary" }, + { espId: "esp_2", name: "Backup" }, + ]); + await expect(call("list_esps")).resolves.toMatchObject({ + items: [{ espId: "esp_1" }, { espId: "esp_2" }], + }); + expect(mocks.listEspConfigs).toHaveBeenCalledWith("team-1"); + + mocks.createEspConfig.mockResolvedValue({ espId: "esp_2", name: "Backup" }); + const args = { + name: "Backup", + provider: "smtp", + host: "smtp.example.com", + port: 587, + secure: false, + }; + await expect(call("create_esp", args)).resolves.toMatchObject({ espId: "esp_2" }); + expect(mocks.createEspConfig).toHaveBeenCalledWith("team-1", args); + expect(mocks.invalidateTeamTransport).toHaveBeenCalledWith("team-1"); + + mocks.getEspConfigByEspId.mockResolvedValue(null); + await expect(call("get_esp", { espId: "esp_missing" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("updates an ESP and invalidates its transport, refusing a retired one", async () => { + mocks.updateEspConfig.mockResolvedValue({ id: "internal-2", espId: "esp_2" }); + await expect( + call("update_esp", { espId: "esp_2", provider: "smtp" }), + ).resolves.toMatchObject({ espId: "esp_2" }); + expect(mocks.updateEspConfig).toHaveBeenCalledWith("team-1", "esp_2", { + provider: "smtp", + }); + expect(mocks.invalidateEspTransport).toHaveBeenCalledWith("team-1", "internal-2"); + + mocks.updateEspConfig.mockRejectedValue(new Error("invalid_lifecycle_transition")); + await expect(call("update_esp", { espId: "esp_2" })).resolves.toMatchObject({ + error: { + code: "conflict", + safeDetails: { reason: "invalid_lifecycle_transition" }, + }, + }); + + mocks.updateEspConfig.mockRejectedValue(new Error("connection reset")); + await expect(call("update_esp", { espId: "esp_2" })).rejects.toThrow( + "connection reset", + ); + }); + + it("explains why an ESP can't be deleted", async () => { + mocks.getEspConfigByEspId.mockResolvedValue({ id: "internal-2", espId: "esp_2" }); + mocks.deleteEspConfig.mockRejectedValue(new Error("delivery_source_in_use")); + const inUse = await call("delete_esp", { espId: "esp_2", confirm: true }); + expect(inUse.error).toMatchObject({ + code: "conflict", + safeDetails: { reason: "delivery_source_in_use" }, + }); + expect(inUse.error.safeDetails.message).toContain("queued email"); + + mocks.deleteEspConfig.mockRejectedValue(new Error("esp_is_default")); + const isDefault = await call("delete_esp", { espId: "esp_2", confirm: true }); + expect(isDefault.error.safeDetails.message).toContain("default delivery source"); + expect(mocks.invalidateEspTransport).not.toHaveBeenCalled(); + + mocks.deleteEspConfig.mockRejectedValue(new Error("esp_has_delivery_history")); + await expect(call("delete_esp_config", { confirm: true })).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "esp_has_delivery_history" } }, + }); + + mocks.deleteEspConfig.mockResolvedValue(undefined); + await expect( + call("delete_esp", { espId: "esp_2", confirm: true }), + ).resolves.toEqual({ + message: "ESP configuration removed.", + }); + expect(mocks.invalidateEspTransport).toHaveBeenCalledWith("team-1", "internal-2"); + }); + + it("retires an ESP so it stops sending, and explains a refused retirement", async () => { + const config = { id: "internal-2", espId: "esp_2", status: "active" }; + mocks.getEspConfigByEspId.mockResolvedValue(config); + mocks.transitionEspConfig.mockResolvedValue({ ...config, status: "retired" }); + await expect( + call("retire_esp", { espId: "esp_2", confirm: true }), + ).resolves.toMatchObject({ + espId: "esp_2", + status: "retired", + }); + expect(mocks.transitionEspConfig).toHaveBeenCalledWith(config, "retire", { + cancel: true, + }); + expect(mocks.invalidateEspTransport).toHaveBeenCalledWith("team-1", "internal-2"); + + mocks.transitionEspConfig.mockRejectedValue( + new Error("invalid_lifecycle_transition"), + ); + const refused = await call("retire_esp", { espId: "esp_2", confirm: true }); + expect(refused.error).toMatchObject({ + code: "conflict", + safeDetails: { reason: "invalid_lifecycle_transition" }, + }); + expect(refused.error.safeDetails.message).toContain("Delete a draft ESP instead"); + }); + + it("activates a verified ESP and explains the verification it needs", async () => { + const config = { id: "internal-2", espId: "esp_2", status: "draft" }; + mocks.getEspConfigByEspId.mockResolvedValue(config); + mocks.transitionEspConfig.mockResolvedValue({ ...config, status: "active" }); + await expect(call("activate_esp", { espId: "esp_2" })).resolves.toMatchObject({ + status: "active", + }); + expect(mocks.transitionEspConfig).toHaveBeenCalledWith(config, "activate"); + + mocks.transitionEspConfig.mockRejectedValue(new Error("esp_verification_required")); + await expect(call("activate_esp", { espId: "esp_2" })).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "esp_verification_required" }, + }, + }); + }); + + it("tests an ESP, sending to the signed-in person by default", async () => { + mocks.getEspConfigByEspId.mockResolvedValue({ id: "internal-2", espId: "esp_2" }); + mocks.testEspConfig.mockResolvedValue({ success: true }); + await expect(call("test_esp", { espId: "esp_2" }, oauth)).resolves.toEqual({ + success: true, + error: undefined, + }); + expect(mocks.getEspConfigByEspId).toHaveBeenCalledWith("team-1", "esp_2"); + expect(mocks.testEspConfig).toHaveBeenCalledWith( + expect.objectContaining({ account: oauth.user, source: "mcp.test_esp" }), + ); + }); + + it("checks domain authentication and asks for a From address first", async () => { + mocks.getEspConfigByEspId.mockResolvedValue({ id: "internal-2", espId: "esp_2" }); + mocks.checkAndRememberDomainAuthentication.mockResolvedValue(null); + await expect( + call("check_esp_domain_authentication", { espId: "esp_2" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "from_email_missing" }, + }, + }); + }); + + it("needs the esp:write scope to change an ESP over OAuth", async () => { + const readOnly = { ...oauth, scopes: ["esp:read"] }; + await expect(call("create_esp", { name: "x" }, readOnly)).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "esp:write" }, + }, + }); + expect(mocks.createEspConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/esp.ts b/apps/api/src/mcp/tools/esp.ts new file mode 100644 index 00000000..58521380 --- /dev/null +++ b/apps/api/src/mcp/tools/esp.ts @@ -0,0 +1,429 @@ +import { + domainAuthenticationQuerySchema, + domainAuthenticationSchema, + upsertEspConfigBodySchema, +} from "@sendlit/api-contract"; +import { z } from "zod"; +import { invalidateEspTransport, invalidateTeamTransport } from "../../mail/transport"; +import { warmupState } from "../../mail/warmup"; +import { espDeleteBlockerMessage } from "../../settings/esp/delete-blockers"; +import { + checkAndRememberDomainAuthentication, + createEspConfig, + deleteEspConfig, + type EspConfig, + getEspConfig, + getEspConfigByEspId, + listEspConfigs, + transitionEspConfig, + updateEspConfig, + upsertEspConfig, +} from "../../settings/esp/queries"; +import { testEspConfig } from "../../settings/esp/test"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound, type ToolError, toolError } from "./responses"; +import { + espConfigListSchema, + espConfigSchema, + espProviders, + successMessageSchema, + testEspResultSchema, +} from "./schemas"; + +function toPublicShape(config: EspConfig | null) { + if (!config) return null; + return { + espId: config.espId, + name: config.name, + provider: config.provider, + host: config.host, + port: config.port, + secure: config.secure, + username: config.username, + hasPassword: Boolean(config.encryptedSecret), + fromName: config.fromName, + fromEmail: config.fromEmail, + replyTo: config.replyTo, + maxSendRate: config.maxSendRate, + dailySendLimit: config.dailySendLimit, + warmup: warmupState(config), + status: config.status, + secretVersion: config.secretVersion, + lastTestedAt: config.lastTestedAt, + lastTestStatus: config.lastTestStatus, + lastTestError: config.lastTestError, + }; +} + +/** A write to a retired ESP: the only code the ESP queries name. Anything + * else is unexpected and thrown. */ +function lifecycleError(err: unknown): ToolError { + if (err instanceof Error && err.message === "invalid_lifecycle_transition") { + return toolError("conflict", { + reason: "invalid_lifecycle_transition", + message: "A retired ESP can't be changed.", + }); + } + throw err; +} + +/** Why an ESP can't be deleted, with what to do instead. */ +function deleteBlockedError(err: unknown): ToolError { + const code = err instanceof Error ? err.message : undefined; + const message = espDeleteBlockerMessage(code); + if (!code || !message) throw err; + return toolError("conflict", { reason: code, message }); +} + +const connectionFields = { + provider: z + .enum(espProviders) + .describe("A label for which ESP this is — the transport is always SMTP"), + host: z.string().min(1).describe("SMTP host, e.g. smtp.sendgrid.net"), + port: z.number().int().min(1).max(65535).describe("SMTP port, e.g. 587"), + secure: z.boolean().describe("Use implicit TLS (usually true only for port 465)"), + username: z.string().optional(), + password: z + .string() + .optional() + .describe("Omit to keep unchanged; empty string clears it"), + fromName: z.string().optional(), + fromEmail: z.string().email().optional(), + replyTo: z + .union([z.string().email(), z.literal("")]) + .optional() + .describe("Reply-To for mail sent through this ESP; empty string clears it"), + maxSendRate: z + .number() + .positive() + .max(10_000) + .nullable() + .optional() + .describe( + "Messages per second across all workers; null uses the provider default (SES 14, Resend 2, others unlimited). Set SES to your account's MaxSendRate.", + ), + dailySendLimit: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + "Most broadcast/sequence emails per UTC day through this ESP, e.g. a warm-up ramp; emails over it wait for the next day. null removes the cap.", + ), + warmupTargetDailyVolume: upsertEspConfigBodySchema.shape.warmupTargetDailyVolume, +}; + +const readOnly = { idempotentHint: true, openWorldHint: false }; + +export const espTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "get_esp_config", + title: "Get Default Sending Provider", + operationId: "settings.esps.get", + risk: "read", + description: + "Returns the team's configured email sending provider (ESP/SMTP), or null if none is configured yet. Teams without ESP cannot send campaign mail. Never includes the password.", + inputSchema: z.object({}), + outputSchema: z.object({ config: espConfigSchema.nullable() }), + annotations: readOnly, + async handler({ context }) { + return { config: toPublicShape(await getEspConfig(context.teamId)) }; + }, + }), + + dedicatedTool({ + name: "update_esp_config", + title: "Update Default Sending Provider", + operationId: "settings.esps.update", + risk: "write", + description: + "Creates or updates the team's *default* ESP (SMTP) configuration — an alias over the first/default row in the team's ESP collection (see `create_esp`/`update_esp` for named, non-default configurations). Works with any provider that exposes an SMTP relay (SendGrid, Mailgun, Postmark, SES, Resend, or a custom/self-hosted SMTP server). Omit `password` to keep the existing secret; send an empty string to clear it.", + inputSchema: z.object(connectionFields), + outputSchema: espConfigSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const config = await upsertEspConfig(context.teamId, args); + invalidateTeamTransport(context.teamId); + return toPublicShape(config); + } catch (err) { + return lifecycleError(err); + } + }, + }), + + dedicatedTool({ + name: "delete_esp_config", + title: "Delete Default Sending Provider", + operationId: "settings.esps.remove", + risk: "destructive", + description: + "Legacy: removes the team's first ESP configuration. A live team default can't be removed (make another source the default, or retire it with retire_esp, first), and an ESP with delivery history or queued work is never deleted.", + inputSchema: z.object({}), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context }) { + try { + await deleteEspConfig(context.teamId); + } catch (err) { + return deleteBlockedError(err); + } + invalidateTeamTransport(context.teamId); + return { message: "ESP configuration removed." }; + }, + }), + + dedicatedTool({ + name: "send_test_email", + title: "Send Test Email via Default Provider", + operationId: "settings.esps.test", + risk: "write", + description: + "Sends a test email through the team's default ESP to verify it works. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", + inputSchema: z.object({ to: z.string().email().optional() }), + outputSchema: testEspResultSchema, + annotations: { openWorldHint: true }, + async handler({ context, args }) { + const config = await getEspConfig(context.teamId); + if (!config) { + return { success: false, error: "No ESP configured for this team yet." }; + } + const result = await testEspConfig({ + config, + to: args.to, + account: context.user, + source: "mcp.send_test_email", + }); + return { success: result.success, error: result.error }; + }, + }), + + dedicatedTool({ + name: "list_esps", + title: "List Sending Providers", + operationId: "settings.esps.list", + risk: "read", + description: + "Lists every user-managed ESP configuration the team has created. Never includes passwords.", + inputSchema: z.object({}), + outputSchema: espConfigListSchema, + annotations: readOnly, + async handler({ context }) { + const configs = await listEspConfigs(context.teamId); + return { items: configs.map(toPublicShape) }; + }, + }), + + dedicatedTool({ + name: "create_esp", + title: "Create Sending Provider", + operationId: "settings.esps.create", + risk: "write", + description: + "Creates a new team-owned ESP configuration. Delivery selection is managed separately.", + inputSchema: z.object({ + name: z.string().trim().min(1).max(100), + ...connectionFields, + }), + outputSchema: espConfigSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const config = await createEspConfig(context.teamId, args); + invalidateTeamTransport(context.teamId); + return toPublicShape(config); + }, + }), + + dedicatedTool({ + name: "get_esp", + title: "Get Sending Provider", + operationId: "settings.esps.get", + risk: "read", + description: "Returns a single user-managed ESP configuration by its ESP ID.", + inputSchema: z.object({ espId: z.string().min(1) }), + outputSchema: espConfigSchema, + annotations: readOnly, + async handler({ context, args }) { + const config = await getEspConfigByEspId(context.teamId, args.espId); + if (!config) return notFound(); + return toPublicShape(config); + }, + }), + + dedicatedTool({ + name: "check_esp_domain_authentication", + title: "Check Domain Authentication", + operationId: "settings.esps.domainAuthentication", + risk: "read", + description: + "Checks SPF, DKIM and DMARC for the domain of an ESP's From address (live DNS lookups) and returns each check's status, plus the DNS records to add. Records whose value only the provider's console knows (e.g. DKIM keys) have a null value. Pass dkimSelector if the provider uses a custom DKIM selector (for Amazon SES, one Easy DKIM token); a selector that passes is saved on the ESP and reused by later checks.", + inputSchema: z.object({ + espId: z.string().min(1), + dkimSelector: domainAuthenticationQuerySchema.shape.dkimSelector, + }), + outputSchema: domainAuthenticationSchema, + annotations: { idempotentHint: true, openWorldHint: true }, + async handler({ context, args }) { + const config = await getEspConfigByEspId(context.teamId, args.espId); + if (!config) return notFound(); + const result = await checkAndRememberDomainAuthentication( + config, + args.dkimSelector, + ); + if (!result) { + return toolError("validation_failed", { + reason: "from_email_missing", + message: "Set the ESP's From address first.", + }); + } + return result; + }, + }), + + dedicatedTool({ + name: "update_esp", + title: "Update Sending Provider", + operationId: "settings.esps.update", + risk: "write", + description: + "Updates a team-owned ESP configuration by its ESP ID. Omit `password` to keep the existing secret; send an empty string to clear it.", + inputSchema: z.object({ + espId: z.string().min(1), + name: z.string().trim().min(1).max(100).optional(), + provider: connectionFields.provider.optional(), + host: connectionFields.host.optional(), + port: connectionFields.port.optional(), + secure: connectionFields.secure.optional(), + username: connectionFields.username, + password: connectionFields.password, + fromName: connectionFields.fromName, + fromEmail: connectionFields.fromEmail, + replyTo: connectionFields.replyTo, + maxSendRate: connectionFields.maxSendRate, + dailySendLimit: connectionFields.dailySendLimit, + warmupTargetDailyVolume: connectionFields.warmupTargetDailyVolume, + }), + outputSchema: espConfigSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const { espId, ...patch } = args; + try { + const config = await updateEspConfig(context.teamId, espId, patch); + if (!config) return notFound(); + invalidateEspTransport(context.teamId, config.id); + return toPublicShape(config); + } catch (err) { + return lifecycleError(err); + } + }, + }), + + dedicatedTool({ + name: "delete_esp", + title: "Delete Sending Provider", + operationId: "settings.esps.remove", + risk: "destructive", + description: + "Deletes an ESP that has never carried delivery history. Blocked when it is the team's live default, is used by a sending sequence or broadcast or by queued email, or has transactional email, delivery feedback or grants (retire it with retire_esp instead). Draft, paused and completed sequences are detached first; sent-mail history is kept.", + inputSchema: z.object({ espId: z.string().min(1) }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const config = await getEspConfigByEspId(context.teamId, args.espId); + if (!config) return notFound(); + try { + await deleteEspConfig(context.teamId, args.espId); + } catch (err) { + return deleteBlockedError(err); + } + invalidateEspTransport(context.teamId, config.id); + return { message: "ESP configuration removed." }; + }, + }), + + dedicatedTool({ + name: "activate_esp", + title: "Activate Sending Provider", + operationId: "settings.esps.activate", + risk: "write", + description: + "Activates a verified team-owned ESP so it can be selected as the team's default or pinned for delivery. Run test_esp successfully after the latest credential or sender change before activating.", + inputSchema: z.object({ espId: z.string().min(1) }), + outputSchema: espConfigSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const config = await getEspConfigByEspId(context.teamId, args.espId); + if (!config) return notFound(); + try { + return toPublicShape(await transitionEspConfig(config, "activate")); + } catch (err) { + if (err instanceof Error && err.message === "esp_verification_required") { + return toolError("validation_failed", { + reason: "esp_verification_required", + message: + "ESP verification is required: send a successful test after its latest configuration change, and configure a sender email.", + }); + } + return lifecycleError(err); + } + }, + }), + + dedicatedTool({ + name: "retire_esp", + title: "Retire Sending Provider", + operationId: "settings.esps.retire", + risk: "destructive", + description: + "Retires a team-owned ESP (active, suspended or draining) so it no longer sends. This is how an ESP with delivery history is archived; its history stays intact. A retired ESP can be deleted with delete_esp only if it has no history.", + inputSchema: z.object({ espId: z.string().min(1) }), + outputSchema: espConfigSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const config = await getEspConfigByEspId(context.teamId, args.espId); + if (!config) return notFound(); + try { + const retired = await transitionEspConfig(config, "retire", { cancel: true }); + invalidateEspTransport(context.teamId, config.id); + return toPublicShape(retired); + } catch (err) { + if (err instanceof Error && err.message === "invalid_lifecycle_transition") { + return toolError("conflict", { + reason: "invalid_lifecycle_transition", + message: + "Only an active, suspended or draining ESP can be retired. Delete a draft ESP instead.", + }); + } + throw err; + } + }, + }), + + dedicatedTool({ + name: "test_esp", + title: "Test Sending Provider", + operationId: "settings.esps.test", + risk: "write", + description: + "Sends a test email through a user-managed ESP (by its ESP ID) to verify it works. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", + inputSchema: z.object({ + espId: z.string().min(1), + to: z.string().email().optional(), + }), + outputSchema: testEspResultSchema, + annotations: { openWorldHint: true }, + async handler({ context, args }) { + const config = await getEspConfigByEspId(context.teamId, args.espId); + if (!config) return notFound(); + const result = await testEspConfig({ + config, + to: args.to, + account: context.user, + source: "mcp.test_esp", + }); + return { success: result.success, error: result.error }; + }, + }), +]; diff --git a/apps/api/src/mcp/tools/events.test.ts b/apps/api/src/mcp/tools/events.test.ts index dc02151e..4eedaf65 100644 --- a/apps/api/src/mcp/tools/events.test.ts +++ b/apps/api/src/mcp/tools/events.test.ts @@ -6,9 +6,32 @@ class FakeEventsError extends Error { } } const trackEvent = vi.fn(); +const queries = { + listEventDefinitions: vi.fn(), + getEventDefinition: vi.fn(), + createEventDefinition: vi.fn(), + updateEventDefinition: vi.fn(), + deleteEventDefinition: vi.fn(), + listContactEvents: vi.fn(), +}; +const getContactByContactId = vi.fn(); mock.module("../../events/queries", () => ({ EventsError: FakeEventsError, trackEvent, + ...queries, +})); +// contacts.test.ts mocks the same module; bun shares mocks across files run +// in one process, so both list every export either one needs. +mock.module("../../contacts/queries", () => ({ + getContactByContactId, + countContacts: vi.fn(), + createContact: vi.fn(), + listContacts: vi.fn(), + updateContact: vi.fn(), + addTagToContact: vi.fn(), + removeTagFromContact: vi.fn(), + deleteContact: vi.fn(), + getDeliveriesByContact: vi.fn(), })); const { eventTools } = await import("./events"); @@ -20,20 +43,36 @@ const context = { user: null, scopes: [], requestId: "", - clientIp: "", - replayHeaders: {}, }; -const [tool] = eventTools(); +const tools = new Map(eventTools().map((t) => [t.name, t])); +const tool = tools.get("track_event"); +function callTool(name: string, args: unknown, ctx: any = context): Promise { + return tools.get(name)!.handler({ context: ctx, args, signal }); +} function call(args: unknown): Promise { return tool!.handler({ context, args, signal }); } -beforeEach(() => trackEvent.mockReset()); +beforeEach(() => { + for (const fn of [trackEvent, getContactByContactId, ...Object.values(queries)]) { + fn.mockReset(); + } +}); describe("MCP track_event", () => { - it("is the only dedicated event tool", () => { - expect(eventTools().map((t) => t.name)).toEqual(["track_event"]); + it("has one titled tool per event operation", () => { + expect(eventTools().map((t) => t.name)).toEqual([ + "track_event", + "list_event_definitions", + "get_event_definition", + "create_event_definition", + "update_event_definition", + "delete_event_definition", + "get_contact_events", + ]); + for (const t of tools.values()) expect(t.title).toBeTruthy(); expect(tool!.risk).toBe("write"); + expect(tools.get("delete_event_definition")!.risk).toBe("destructive"); }); it("tracks an event for the resolved team with source mcp", async () => { @@ -89,3 +128,80 @@ describe("MCP track_event", () => { }); }); }); + +describe("MCP event definition tools", () => { + it("scopes definitions to the team and maps conflicts", async () => { + queries.listEventDefinitions.mockResolvedValue([]); + await expect(callTool("list_event_definitions", {})).resolves.toEqual({ + items: [], + }); + expect(queries.listEventDefinitions).toHaveBeenCalledWith("team-1"); + + queries.getEventDefinition.mockResolvedValue(null); + await expect( + callTool("get_event_definition", { name: "nope" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + + queries.createEventDefinition.mockRejectedValue( + new FakeEventsError("event_definition_exists"), + ); + await expect( + callTool("create_event_definition", { name: "x" }), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "event_definition_exists" } }, + }); + + queries.deleteEventDefinition.mockRejectedValue( + new FakeEventsError("event_definition_in_use"), + ); + await expect( + callTool("delete_event_definition", { name: "x", confirm: true }), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "event_definition_in_use" } }, + }); + + queries.updateEventDefinition.mockResolvedValue(null); + await expect( + callTool("update_event_definition", { name: "x", description: "d" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + expect(queries.updateEventDefinition).toHaveBeenCalledWith("team-1", "x", { + description: "d", + }); + }); + + it("only lists events of a contact in the team", async () => { + getContactByContactId.mockResolvedValue({ id: "c-1", teamId: "team-2" }); + await expect( + callTool("get_contact_events", { contactId: "cnt_1" }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + expect(queries.listContactEvents).not.toHaveBeenCalled(); + + getContactByContactId.mockResolvedValue({ id: "c-1", teamId: "team-1" }); + queries.listContactEvents.mockResolvedValue({ items: [], total: 0 }); + await expect( + callTool("get_contact_events", { contactId: "cnt_1", limit: 5 }), + ).resolves.toEqual({ items: [], total: 0 }); + expect(queries.listContactEvents).toHaveBeenCalledWith("c-1", { + page: undefined, + limit: 5, + name: undefined, + }); + }); + + it("needs contacts:write to change definitions over OAuth", async () => { + await expect( + callTool( + "create_event_definition", + { name: "x" }, + { ...context, authKind: "oauth", scopes: ["contacts:read"] }, + ), + ).resolves.toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "insufficient_scope" } }, + }); + expect(queries.createEventDefinition).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/events.ts b/apps/api/src/mcp/tools/events.ts index 90d91797..78aa032b 100644 --- a/apps/api/src/mcp/tools/events.ts +++ b/apps/api/src/mcp/tools/events.ts @@ -1,19 +1,52 @@ import { + contactEventSchema, + createEventDefinitionBodySchema, + eventDefinitionDetailSchema, + eventDefinitionSchema, + eventNameSchema, + listContactEventsQuerySchema, trackEventBodySchema, trackEventFieldsSchema, trackEventResponseSchema, + updateEventDefinitionBodySchema, } from "@sendlit/api-contract"; -import { EventsError, trackEvent } from "../../events/queries"; +import { z } from "zod"; +import { getContactByContactId } from "../../contacts/queries"; +import { + createEventDefinition, + deleteEventDefinition, + EventsError, + getEventDefinition, + listContactEvents, + listEventDefinitions, + trackEvent, + updateEventDefinition, +} from "../../events/queries"; import type { SendLitMcpTool } from "../context"; import { dedicatedTool } from "../tool"; -import { isPlanGateError, planGateError, toolError } from "./responses"; +import { isPlanGateError, notFound, planGateError, toolError } from "./responses"; + +/** An `EventsError` as REST splits it: a missing contact is 404, a name that + * exists or is in use is 409, anything else (the event name limit) is 422. */ +function eventsError(err: EventsError) { + if (err.code === "contact_not_found") { + return toolError("not_found", { reason: err.code }); + } + if ( + err.code === "event_definition_exists" || + err.code === "event_definition_in_use" + ) { + return toolError("conflict", { reason: err.code }); + } + return toolError("validation_failed", { reason: err.code }); +} /** Custom events — the MCP side of `events/routes.ts`. See - * docs/custom_events.md. Event definitions and a contact's event history - * are reachable through the generic `api.*` tools. */ + * docs/custom_events.md. */ export const eventTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "track_event", + title: "Track Event", operationId: "events.track", risk: "write", description: @@ -39,14 +72,118 @@ export const eventTools = (): SendLitMcpTool[] => [ }); } catch (err) { if (isPlanGateError(err)) return planGateError(err); - if (err instanceof EventsError) { - return toolError( - err.code === "contact_not_found" ? "not_found" : "validation_failed", - { reason: err.code }, - ); - } + if (err instanceof EventsError) return eventsError(err); + throw err; + } + }, + }), + + dedicatedTool({ + name: "list_event_definitions", + title: "List Events", + operationId: "events.definitions", + risk: "read", + description: + "Lists the team's events: registered on first receipt or by hand, with their properties, when they were last received, and how many sequences use each.", + inputSchema: z.object({}), + outputSchema: z.object({ items: z.array(eventDefinitionSchema) }), + annotations: { openWorldHint: false }, + async handler({ context }) { + return { items: await listEventDefinitions(context.teamId) }; + }, + }), + + dedicatedTool({ + name: "get_event_definition", + title: "Get Event", + operationId: "events.definition", + risk: "read", + description: "Gets one event, including the properties of the last one received.", + inputSchema: z.object({ name: eventNameSchema }), + outputSchema: eventDefinitionDetailSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const row = await getEventDefinition(context.teamId, args.name); + return row ?? notFound(); + }, + }), + + dedicatedTool({ + name: "create_event_definition", + title: "Create Event", + operationId: "events.createDefinition", + risk: "write", + description: + "Registers an event before your app sends it, so sequences can use it as a trigger and its properties show up in the builder. Counts toward the team's 500-event limit.", + inputSchema: createEventDefinitionBodySchema, + outputSchema: eventDefinitionDetailSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + return await createEventDefinition(context.teamId, args); + } catch (err) { + if (err instanceof EventsError) return eventsError(err); throw err; } }, }), + + dedicatedTool({ + name: "update_event_definition", + title: "Update Event", + operationId: "events.updateDefinition", + risk: "write", + description: + "Updates an event's description or declared properties. properties replaces the declared set; properties seen in received events stay.", + inputSchema: updateEventDefinitionBodySchema.extend({ name: eventNameSchema }), + outputSchema: eventDefinitionDetailSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const { name, ...patch } = args; + const row = await updateEventDefinition(context.teamId, name, patch); + return row ?? notFound(); + }, + }), + + dedicatedTool({ + name: "delete_event_definition", + title: "Delete Event", + operationId: "events.deleteDefinition", + risk: "destructive", + description: + "Removes an event from the team's list. Stored events are kept, and a later event with the name registers it again. Fails with event_definition_in_use while a sequence uses it as its trigger.", + inputSchema: z.object({ name: eventNameSchema }), + outputSchema: z.object({ deleted: z.boolean() }), + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const deleted = await deleteEventDefinition(context.teamId, args.name); + return deleted ? { deleted: true } : notFound(); + } catch (err) { + if (err instanceof EventsError) return eventsError(err); + throw err; + } + }, + }), + + dedicatedTool({ + name: "get_contact_events", + title: "Get Contact Events", + operationId: "contacts.events", + risk: "read", + description: + "Lists the custom events received for a contact, newest first, with the sequences each one started.", + inputSchema: listContactEventsQuerySchema.extend({ contactId: z.string() }), + outputSchema: z.object({ items: z.array(contactEventSchema), total: z.number() }), + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const contact = await getContactByContactId(args.contactId); + if (!contact || contact.teamId !== context.teamId) return notFound(); + return await listContactEvents(contact.id, { + page: args.page, + limit: args.limit, + name: args.name, + }); + }, + }), ]; diff --git a/apps/api/src/mcp/tools/forms.test.ts b/apps/api/src/mcp/tools/forms.test.ts new file mode 100644 index 00000000..730605b8 --- /dev/null +++ b/apps/api/src/mcp/tools/forms.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +class FakeFormSenderError extends Error { + constructor(readonly issue: string) { + super("confirmation_sender_unavailable"); + } +} +const mocks = { + createForm: vi.fn(), + deleteForm: vi.fn(), + getForm: vi.fn(), + listForms: vi.fn(), + updateForm: vi.fn(), + toPublicForms: vi.fn(async (_teamId: string, forms: any[]) => + forms.map((form) => ({ ...form, url: `https://forms.example/${form.formId}` })), + ), +}; +mock.module("../../forms/queries", () => ({ + ...mocks, + FormSenderError: FakeFormSenderError, +})); + +const { formTools } = await import("./forms"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [] as string[], + requestId: "", +}; +const tools = new Map(formTools().map((tool) => [tool.name, tool])); +function call(name: string, args: unknown, ctx: any = context): Promise { + return tools.get(name)!.handler({ context: ctx, args, signal }); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockClear(); + for (const fn of [ + mocks.createForm, + mocks.deleteForm, + mocks.getForm, + mocks.listForms, + mocks.updateForm, + ]) { + fn.mockReset(); + } +}); + +describe("MCP signup form tools", () => { + it("has one titled tool per form operation", () => { + expect([...tools.keys()]).toEqual([ + "list_signup_forms", + "create_signup_form", + "get_signup_form", + "update_signup_form", + "delete_signup_form", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + expect(tools.get("delete_signup_form")!.risk).toBe("destructive"); + }); + + it("lists, gets and creates the team's forms with their public URLs", async () => { + mocks.listForms.mockResolvedValue([{ formId: "frm_1", name: "Newsletter" }]); + await expect(call("list_signup_forms", {})).resolves.toEqual({ + items: [ + { formId: "frm_1", name: "Newsletter", url: "https://forms.example/frm_1" }, + ], + }); + expect(mocks.listForms).toHaveBeenCalledWith("team-1"); + + mocks.getForm.mockResolvedValue(null); + await expect(call("get_signup_form", { formId: "frm_x" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + + mocks.createForm.mockResolvedValue({ formId: "frm_2", name: "Launch" }); + await expect(call("create_signup_form", { name: "Launch" })).resolves.toMatchObject( + { + formId: "frm_2", + url: "https://forms.example/frm_2", + }, + ); + expect(mocks.createForm).toHaveBeenCalledWith( + "team-1", + expect.objectContaining({ name: "Launch" }), + ); + }); + + it("explains a missing confirmation sender", async () => { + mocks.createForm.mockRejectedValue(new FakeFormSenderError("no_sending_mailbox")); + await expect(call("create_signup_form", { name: "Launch" })).resolves.toMatchObject( + { + error: { + code: "conflict", + safeDetails: { + reason: "confirmation_sender_unavailable", + message: "no_sending_mailbox", + }, + }, + }, + ); + + mocks.updateForm.mockRejectedValue(new FakeFormSenderError("no_sending_mailbox")); + await expect( + call("update_signup_form", { formId: "frm_1", doubleOptIn: true }), + ).resolves.toMatchObject({ + error: { safeDetails: { reason: "confirmation_sender_unavailable" } }, + }); + + mocks.createForm.mockRejectedValue(new Error("db down")); + await expect(call("create_signup_form", { name: "Launch" })).rejects.toThrow( + "db down", + ); + }); + + it("updates and deletes only existing forms", async () => { + mocks.updateForm.mockResolvedValue(null); + await expect( + call("update_signup_form", { formId: "frm_1", name: "Renamed" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + expect(mocks.updateForm).toHaveBeenCalledWith("team-1", "frm_1", { + name: "Renamed", + }); + + mocks.deleteForm.mockResolvedValue(true); + await expect( + call("delete_signup_form", { formId: "frm_1", confirm: true }), + ).resolves.toEqual({ deleted: true }); + mocks.deleteForm.mockResolvedValue(false); + await expect( + call("delete_signup_form", { formId: "frm_1", confirm: true }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + }); + + it("needs contacts:write to create a form over OAuth", async () => { + await expect( + call( + "create_signup_form", + { name: "Launch" }, + { ...context, authKind: "oauth", scopes: ["contacts:read"] }, + ), + ).resolves.toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "insufficient_scope" } }, + }); + expect(mocks.createForm).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/forms.ts b/apps/api/src/mcp/tools/forms.ts new file mode 100644 index 00000000..2ed94e89 --- /dev/null +++ b/apps/api/src/mcp/tools/forms.ts @@ -0,0 +1,119 @@ +import { createSignupFormBodySchema, signupFormSchema } from "@sendlit/api-contract"; +import { z } from "zod"; +import { + createForm, + deleteForm, + FormSenderError, + getForm, + listForms, + toPublicForms, + updateForm, +} from "../../forms/queries"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound, toolError } from "./responses"; + +/** Double opt-in needs a sender; explain instead of failing generically. */ +async function withSenderCheck(run: () => Promise) { + try { + return await run(); + } catch (error) { + if (error instanceof FormSenderError) { + return toolError("conflict", { + reason: "confirmation_sender_unavailable", + message: error.issue, + }); + } + throw error; + } +} + +export const formTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_signup_forms", + title: "List Signup Forms", + operationId: "forms.list", + risk: "read", + description: "Lists the team's public signup forms with their hosted URLs.", + inputSchema: z.object({}), + outputSchema: z.object({ items: z.array(signupFormSchema) }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context }) { + return { + items: await toPublicForms(context.teamId, await listForms(context.teamId)), + }; + }, + }), + + dedicatedTool({ + name: "create_signup_form", + title: "Create Signup Form", + operationId: "forms.create", + risk: "write", + description: + "Creates a public signup form. People subscribe on its hosted page (`url`) or via an HTML form on any site posting `email` (and `name`) to that URL. Double opt-in (default) emails a confirmation link first; new subscribers get the form's tags and trigger subscriber:added sequences (e.g. a welcome email). Double opt-in needs a sending mailbox: without one this fails with confirmation_sender_unavailable. `sendingIssue` on a form says why its confirmations can't be sent.", + inputSchema: createSignupFormBodySchema, + outputSchema: signupFormSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + return withSenderCheck( + async () => + ( + await toPublicForms(context.teamId, [ + await createForm(context.teamId, args), + ]) + )[0], + ); + }, + }), + + dedicatedTool({ + name: "get_signup_form", + title: "Get Signup Form", + operationId: "forms.get", + risk: "read", + description: "Returns one signup form.", + inputSchema: z.object({ formId: z.string() }), + outputSchema: signupFormSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const form = await getForm(context.teamId, args.formId); + return form ? (await toPublicForms(context.teamId, [form]))[0] : notFound(); + }, + }), + + dedicatedTool({ + name: "update_signup_form", + title: "Update Signup Form", + operationId: "forms.update", + risk: "write", + description: + "Updates a signup form's name, tags, double opt-in or redirect URL. Turning double opt-in on fails with confirmation_sender_unavailable when the team has no sending mailbox.", + inputSchema: createSignupFormBodySchema.partial().extend({ formId: z.string() }), + outputSchema: signupFormSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const { formId, ...patch } = args; + return withSenderCheck(async () => { + const form = await updateForm(context.teamId, formId, patch); + return form ? (await toPublicForms(context.teamId, [form]))[0] : notFound(); + }); + }, + }), + + dedicatedTool({ + name: "delete_signup_form", + title: "Delete Signup Form", + operationId: "forms.remove", + risk: "destructive", + description: "Deletes a signup form; its URL stops accepting signups.", + inputSchema: z.object({ formId: z.string() }), + outputSchema: z.object({ deleted: z.boolean() }), + annotations: { openWorldHint: false }, + async handler({ context, args }) { + return (await deleteForm(context.teamId, args.formId)) + ? { deleted: true } + : notFound(); + }, + }), +]; diff --git a/apps/api/src/mcp/tools/general-settings.ts b/apps/api/src/mcp/tools/general-settings.ts new file mode 100644 index 00000000..a8c16fa4 --- /dev/null +++ b/apps/api/src/mcp/tools/general-settings.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { + type GeneralSettings, + getGeneralSettings, + upsertGeneralSettings, +} from "../../settings/general/queries"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { generalSettingsSchema } from "./schemas"; + +function toPublicShape(settings: GeneralSettings) { + return { + mailingAddress: settings.mailingAddress, + updatedAt: settings.updatedAt, + }; +} + +export const generalSettingsTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "get_general_settings", + title: "Get General Settings", + operationId: "settings.general.get", + risk: "read", + description: + "Returns the team's general workspace settings. The mailing address is used in the managed marketing footer and is required for broadcasts and sequences. Returns defaults (null fields) when nothing has been saved yet.", + inputSchema: z.object({}), + outputSchema: generalSettingsSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context }) { + return toPublicShape(await getGeneralSettings(context.teamId)); + }, + }), + + dedicatedTool({ + name: "update_general_settings", + title: "Update General Settings", + operationId: "settings.general.update", + risk: "write", + description: + "Updates the team's general workspace settings. Omitted fields are left unchanged; send an empty string to clear a field. `mailingAddress` is the physical postal address rendered in the managed marketing footer and required for broadcasts and sequences.", + inputSchema: z.object({ + mailingAddress: z + .string() + .optional() + .describe("Omit to keep unchanged; empty string clears it"), + }), + outputSchema: generalSettingsSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + return toPublicShape(await upsertGeneralSettings(context.teamId, args)); + }, + }), +]; diff --git a/apps/api/src/mcp/tools/media.test.ts b/apps/api/src/mcp/tools/media.test.ts new file mode 100644 index 00000000..b03e330c --- /dev/null +++ b/apps/api/src/mcp/tools/media.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + countMedia: vi.fn(), + deleteUnusedMedia: vi.fn(), + getMediaByMediaId: vi.fn(), + listMedia: vi.fn(), + listMediaReferences: vi.fn(), + updateMediaMetadata: vi.fn(), +}; + +mock.module("../../media/queries", () => ({ ...mocks })); + +const { mediaTools } = await import("./media"); + +const teamKey = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", +}; + +const tools = new Map(mediaTools().map((tool) => [tool.name, tool])); + +function call(name: string, args: Record, context: any = teamKey) { + const tool = tools.get(name); + if (!tool) throw new Error(`unknown tool ${name}`); + return tool.handler({ context, args, signal: new AbortController().signal }); +} + +const row = { + id: "internal-media-1", + teamId: "team-1", + mediaId: "med_1", + url: "https://cdn.test/p/media-lit/main.webp", + mediaLitId: "media-lit", +}; + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP media tools", () => { + it("has one titled tool per media operation", () => { + expect([...tools.keys()]).toEqual([ + "list_media", + "get_media", + "update_media", + "delete_media", + "list_media_references", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + expect((tools.get("delete_media")!.inputSchema as any).shape.confirm).toBeDefined(); + }); + + it("lists and updates media within the resolved team", async () => { + mocks.listMedia.mockResolvedValue([row]); + mocks.countMedia.mockResolvedValue(1); + const listed: any = await call("list_media", { query: "hero", page: 2 }); + expect(listed).toMatchObject({ + items: [{ mediaId: "med_1", url: "https://cdn.test/p/media-lit/main.webp" }], + total: 1, + }); + expect(listed.items[0]).not.toHaveProperty("id"); + expect(mocks.listMedia).toHaveBeenCalledWith({ + teamId: "team-1", + query: "hero", + page: 2, + pageSize: undefined, + }); + expect(mocks.countMedia).toHaveBeenCalledWith({ teamId: "team-1", query: "hero" }); + + mocks.updateMediaMetadata.mockResolvedValue({ ...row, alt: "Hero" }); + await expect( + call("update_media", { mediaId: "med_1", alt: "Hero" }), + ).resolves.toMatchObject({ mediaId: "med_1", alt: "Hero" }); + expect(mocks.updateMediaMetadata).toHaveBeenCalledWith({ + teamId: "team-1", + mediaId: "med_1", + alt: "Hero", + caption: undefined, + }); + + mocks.getMediaByMediaId.mockResolvedValue(null); + await expect(call("get_media", { mediaId: "med_x" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + expect(mocks.getMediaByMediaId).toHaveBeenCalledWith("team-1", "med_x"); + }); + + it("blocks deleting in-use media and lists references", async () => { + mocks.deleteUnusedMedia.mockResolvedValueOnce("in_use"); + await expect(call("delete_media", { mediaId: "med_1" })).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "media_in_use" } }, + }); + expect(mocks.deleteUnusedMedia).toHaveBeenCalledWith("team-1", "med_1"); + + mocks.deleteUnusedMedia.mockResolvedValueOnce("not_found"); + await expect(call("delete_media", { mediaId: "med_x" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + mocks.deleteUnusedMedia.mockResolvedValueOnce("deleted"); + await expect(call("delete_media", { mediaId: "med_1" })).resolves.toEqual({ + message: "Media deleted.", + }); + + mocks.listMediaReferences.mockResolvedValue([ + { + resourceType: "SEQUENCE_EMAIL", + resourcePublicId: "email_1", + parentResourcePublicId: "seq_1", + internalId: "hidden", + }, + ]); + const references: any = await call("list_media_references", { mediaId: "med_1" }); + expect(references.items).toEqual([ + { + resourceType: "SEQUENCE_EMAIL", + resourcePublicId: "email_1", + parentResourcePublicId: "seq_1", + createdAt: undefined, + updatedAt: undefined, + }, + ]); + }); + + it("refuses an OAuth token without media:write", async () => { + await expect( + call( + "delete_media", + { mediaId: "med_1" }, + { ...teamKey, authKind: "oauth", scopes: ["media:read"] }, + ), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "media:write" }, + }, + }); + expect(mocks.deleteUnusedMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/media.ts b/apps/api/src/mcp/tools/media.ts new file mode 100644 index 00000000..48d01603 --- /dev/null +++ b/apps/api/src/mcp/tools/media.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import { + countMedia, + deleteUnusedMedia, + getMediaByMediaId, + listMedia, + listMediaReferences, + updateMediaMetadata, +} from "../../media/queries"; +import { omitInternal } from "../../utils/public"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound, toolError } from "./responses"; +import { + mediaListSchema, + mediaReferenceListSchema, + mediaSchema, + successMessageSchema, +} from "./schemas"; + +function toPublicMedia(row: Record) { + return omitInternal(row); +} + +function toPublicReference(reference: { + resourceType: string; + resourcePublicId: string; + parentResourcePublicId?: string | null; + createdAt?: Date | null; + updatedAt?: Date | null; +}) { + return { + resourceType: reference.resourceType, + resourcePublicId: reference.resourcePublicId, + parentResourcePublicId: reference.parentResourcePublicId, + createdAt: reference.createdAt, + updatedAt: reference.updatedAt, + }; +} + +export const mediaTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_media", + title: "List Media", + operationId: "media.list", + risk: "read", + description: + "Returns uploaded MediaLit-backed media for the team. Unsplash and external URL images are not stored here.", + inputSchema: z.object({ + query: z.string().optional(), + page: z.number().int().min(1).optional(), + pageSize: z.number().int().min(1).max(100).optional(), + }), + outputSchema: mediaListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const [items, total] = await Promise.all([ + listMedia({ + teamId: context.teamId, + query: args.query, + page: args.page, + pageSize: args.pageSize, + }), + countMedia({ teamId: context.teamId, query: args.query }), + ]); + return { items: items.map(toPublicMedia), total }; + }, + }), + + dedicatedTool({ + name: "get_media", + title: "Get Media", + operationId: "media.get", + risk: "read", + description: "Returns one uploaded media item by its media ID.", + inputSchema: z.object({ mediaId: z.string().min(1) }), + outputSchema: mediaSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const row = await getMediaByMediaId(context.teamId, args.mediaId); + if (!row) return notFound(); + return toPublicMedia(row); + }, + }), + + dedicatedTool({ + name: "update_media", + title: "Update Media", + operationId: "media.update", + risk: "write", + description: "Updates editable metadata for uploaded media.", + inputSchema: z.object({ + mediaId: z.string().min(1), + alt: z.string().nullable().optional(), + caption: z.string().nullable().optional(), + }), + outputSchema: mediaSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const row = await updateMediaMetadata({ + teamId: context.teamId, + mediaId: args.mediaId, + alt: args.alt, + caption: args.caption, + }); + if (!row) return notFound(); + return toPublicMedia(row); + }, + }), + + dedicatedTool({ + name: "delete_media", + title: "Delete Media", + operationId: "media.remove", + risk: "destructive", + description: + "Deletes uploaded media only when no saved template, broadcast, or sequence email references it.", + inputSchema: z.object({ mediaId: z.string().min(1) }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const result = await deleteUnusedMedia(context.teamId, args.mediaId); + if (result === "not_found") return notFound(); + if (result === "in_use") { + return toolError("conflict", { + reason: "media_in_use", + message: "Media is still in use. list_media_references shows what uses it.", + }); + } + return { message: "Media deleted." }; + }, + }), + + dedicatedTool({ + name: "list_media_references", + title: "List Media References", + operationId: "media.references", + risk: "read", + description: + "Lists saved templates, broadcasts, or sequence emails that use the uploaded media item.", + inputSchema: z.object({ mediaId: z.string().min(1) }), + outputSchema: mediaReferenceListSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const references = await listMediaReferences({ + teamId: context.teamId, + mediaId: args.mediaId, + }); + if (!references) return notFound(); + return { items: references.map(toPublicReference) }; + }, + }), +]; diff --git a/apps/api/src/mcp/tools/reputation.ts b/apps/api/src/mcp/tools/reputation.ts new file mode 100644 index 00000000..603e71f3 --- /dev/null +++ b/apps/api/src/mcp/tools/reputation.ts @@ -0,0 +1,130 @@ +import { connectSndsBodySchema, reputationSchema } from "@sendlit/api-contract"; +import { z } from "zod"; +import { startGoogleConnect } from "../../reputation/google"; +import { deleteConnection, getReputation } from "../../reputation/queries"; +import { SndsLinkError } from "../../reputation/snds"; +import { connectSnds, syncTeamNow } from "../../reputation/sync"; +import { consumeRateLimit } from "../../services/rate-counter"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound, toolError } from "./responses"; + +const disconnected = z.object({ disconnected: z.boolean() }); + +export const reputationTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "get_reputation", + title: "Get Sender Reputation", + operationId: "reputation.get", + risk: "read", + description: + "Returns the last 30 days of mailbox provider reputation data and current alerts: Google Postmaster Tools per sending domain (user-reported spam rate, SPF/DKIM/DMARC pass rates, delivery error rate, compliance with Google's sender requirements) and Microsoft SNDS per IP (filter result, complaint rate, spam trap hits). Rates are fractions (0.003 = 0.3%).", + inputSchema: z.object({}), + outputSchema: reputationSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + handler: ({ context }) => getReputation(context.teamId), + }), + + dedicatedTool({ + name: "connect_google_postmaster", + title: "Connect Google Postmaster Tools", + operationId: "reputation.connectGoogle", + risk: "write", + description: + "Returns a Google sign-in URL that a person must open in a browser to connect Google Postmaster Tools. Each sending domain must be verified in Postmaster Tools with that Google account.", + inputSchema: z.object({}), + outputSchema: z.object({ authorizationUrl: z.string() }), + annotations: { openWorldHint: true }, + async handler({ context }) { + const url = startGoogleConnect(context.teamId, context.user?.id ?? null); + if (!url) { + return toolError("conflict", { + reason: "google_postmaster_not_configured", + message: "Google Postmaster Tools isn't configured on this SendLit server.", + }); + } + return { authorizationUrl: url }; + }, + }), + + dedicatedTool({ + name: "disconnect_google_postmaster", + title: "Disconnect Google Postmaster Tools", + operationId: "reputation.disconnectGoogle", + risk: "destructive", + description: "Disconnects Google Postmaster Tools and deletes its stored data.", + inputSchema: z.object({}), + outputSchema: disconnected, + annotations: { openWorldHint: false }, + async handler({ context }) { + return (await deleteConnection(context.teamId, "google_postmaster")) + ? { disconnected: true } + : notFound(); + }, + }), + + dedicatedTool({ + name: "connect_snds", + title: "Connect Microsoft SNDS", + operationId: "reputation.connectSnds", + risk: "write", + description: + "Connects Microsoft SNDS with its automated data access link (from SNDS's Automated Data Access page). The link is checked by downloading the data once; it expires after 30 days.", + inputSchema: connectSndsBodySchema, + outputSchema: reputationSchema, + annotations: { openWorldHint: true }, + async handler({ context, args }) { + try { + await connectSnds(context.teamId, args.dataUrl); + } catch (error) { + if (error instanceof SndsLinkError) { + return toolError("validation_failed", { + reason: "snds_link_invalid", + message: + "SNDS didn't return data for this link. Copy the automated data access link again; it expires after 30 days.", + }); + } + throw error; + } + return getReputation(context.teamId); + }, + }), + + dedicatedTool({ + name: "disconnect_snds", + title: "Disconnect Microsoft SNDS", + operationId: "reputation.disconnectSnds", + risk: "destructive", + description: "Disconnects Microsoft SNDS and deletes its stored data.", + inputSchema: z.object({}), + outputSchema: disconnected, + annotations: { openWorldHint: false }, + async handler({ context }) { + return (await deleteConnection(context.teamId, "microsoft_snds")) + ? { disconnected: true } + : notFound(); + }, + }), + + dedicatedTool({ + name: "sync_reputation", + title: "Sync Sender Reputation", + operationId: "reputation.sync", + risk: "write", + description: + "Refreshes reputation data now instead of waiting for the daily sync. Limited to once every 5 minutes.", + inputSchema: z.object({}), + outputSchema: reputationSchema, + annotations: { openWorldHint: true }, + async handler({ context }) { + if (!(await consumeRateLimit(`reputation-sync:${context.teamId}`, 1, 300))) { + return toolError("rate_limited", { + reason: "too_many_requests", + message: "Reputation can be synced once every 5 minutes.", + }); + } + await syncTeamNow(context.teamId); + return getReputation(context.teamId); + }, + }), +]; diff --git a/apps/api/src/mcp/tools/schemas.ts b/apps/api/src/mcp/tools/schemas.ts index 4135dfd1..96be22df 100644 --- a/apps/api/src/mcp/tools/schemas.ts +++ b/apps/api/src/mcp/tools/schemas.ts @@ -4,8 +4,12 @@ import { sequenceSchema as contractSequenceSchema, customFieldsSchema, emailContentSchema, + espWarmupSchema, + sequenceScheduleSchema, + waitingContactsPageSchema, } from "@sendlit/api-contract"; import { z } from "zod"; +import { feedbackCapableProviders } from "../../config/constants"; export { emailContentSchema }; @@ -26,6 +30,18 @@ export const contactListSchema = z.object({ total: z.number(), }); +const contactDeliverySchema = z.object({ + sequenceId: z.string(), + sequenceTitle: z.string(), + sequenceType: z.string(), + emailId: z.string(), + createdAt: z.string().nullable(), +}); + +export const contactDeliveryListSchema = z.object({ + items: z.array(contactDeliverySchema), +}); + export const segmentSchema = z.object({ segmentId: z.string(), name: z.string(), @@ -48,6 +64,57 @@ export const templateSchema = z.object({ updatedAt: z.string(), }); +export const mediaSchema = z.object({ + mediaId: z.string(), + url: z.string(), + thumbnailUrl: z.string().nullable().optional(), + mediaLitId: z.string(), + fileName: z.string().nullable().optional(), + mimeType: z.string().nullable().optional(), + size: z.number().nullable().optional(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + alt: z.string().nullable().optional(), + caption: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), +}); + +export const mediaListSchema = z.object({ + items: z.array(mediaSchema), + total: z.number(), +}); + +const mediaReferenceSchema = z.object({ + resourceType: z.enum(["TEMPLATE", "SEQUENCE_EMAIL"]), + resourcePublicId: z.string(), + parentResourcePublicId: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), +}); + +export const mediaReferenceListSchema = z.object({ + items: z.array(mediaReferenceSchema), +}); + +export const systemTemplateSchema = z.object({ + templateId: z.string().min(1), + title: z.string(), + description: z.string(), + purpose: z.enum(["marketing", "transactional"]), + content: emailContentSchema, + requiredVariables: z.array(z.string()), + variableDefinitions: z + .array( + z.object({ + path: z.string(), + description: z.string(), + example: z.any(), + }), + ) + .optional(), +}); + /** What the sequence tools return (`toPublicSequence` in tools/sequences.ts). * Built on the REST contract's schema, which is the source of truth, and * open to extra fields: a hand-kept copy here had drifted (it required an @@ -75,9 +142,110 @@ export const sequenceStatsSchema = z.object({ complaintRate: z.number().optional(), }); +// The REST contract's shapes, so MCP output can't drift from REST. +export { sequenceScheduleSchema, waitingContactsPageSchema as waitingContactsSchema }; + +export const successMessageSchema = z.object({ + message: z.string(), +}); + +export const testEspResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); + +export const teamSchema = z.object({ + teamId: z.string(), + name: z.string(), + organizationId: z.string().optional(), + organizationName: z.string().optional(), +}); + +/** The secret is stored hashed; listings only expose `keyPrefix`. Only + * `create_api_key` returns the full secret (see `createdApiKeySchema`). */ +export const apiKeySchema = z.object({ + id: z.string(), + keyPrefix: z.string(), + name: z.string().nullable(), + createdAt: z.string().nullable(), +}); + +export const createdApiKeySchema = apiKeySchema.extend({ + key: z.string(), +}); + +export const espProviders = [ + "smtp", + "sendgrid", + "mailgun", + "postmark", + "ses", + "resend", +] as const; + +export const espConfigSchema = z.object({ + espId: z.string(), + name: z.string(), + provider: z.enum(espProviders), + host: z.string(), + port: z.number(), + secure: z.boolean(), + username: z.string().nullable().optional(), + hasPassword: z.boolean(), + fromName: z.string().nullable().optional(), + fromEmail: z.string().nullable().optional(), + replyTo: z.string().nullable().optional(), + maxSendRate: z.number().nullable().optional(), + dailySendLimit: z.number().int().nullable().optional(), + warmup: espWarmupSchema.nullable().optional(), + status: z.string(), + secretVersion: z.number(), + lastTestedAt: z.string().nullable().optional(), + lastTestStatus: z.string().nullable().optional(), + lastTestError: z.string().nullable().optional(), +}); + +export const espConfigListSchema = z.object({ + items: z.array(espConfigSchema), +}); + +/** General (non-ESP) per-team settings singleton — see settings/general. */ +export const generalSettingsSchema = z.object({ + mailingAddress: z.string().nullable(), + updatedAt: z.string().nullable().optional(), +}); + +/** Custom tracking domain singleton — see settings/tracking-domain. */ +export const trackingDomainSchema = z.object({ + domain: z.string().nullable(), + status: z.enum(["pending", "verified", "failed"]).nullable(), + cnameTarget: z.string(), + error: z.string().nullable(), + verifiedAt: z.string().nullable(), + checkedAt: z.string().nullable(), +}); + // ---- Bounce and complaint processing (docs/bounces-and-complaints.md) ----- -const deliveryEventSchema = z.object({ +// Re-exported from the single source of truth so the MCP surface can never +// drift from the registered adapters (see `config/constants.ts`). +export { feedbackCapableProviders }; + +export const feedbackConnectionSchema = z.object({ + connectionId: z.string(), + espId: z.string(), + provider: z.string(), + webhookUrl: z.string(), + hasCredential: z.boolean(), + expectedTopicArn: z.string().nullable().optional(), + subscriptionConfirmedAt: z.string().nullable().optional(), + status: z.enum(["pending", "healthy", "stale", "error", "retiring", "disabled"]), + lastReceivedAt: z.string().nullable().optional(), + lastVerifiedAt: z.string().nullable().optional(), + lastErrorCode: z.string().nullable().optional(), +}); + +export const deliveryEventSchema = z.object({ eventId: z.string(), provider: z.string(), espId: z.string().nullable(), @@ -95,3 +263,25 @@ export const deliveryEventListSchema = z.object({ items: z.array(deliveryEventSchema), total: z.number(), }); + +export const suppressionSchema = z.object({ + suppressionId: z.string(), + recipientEmail: z.string().nullable(), + reason: z.enum([ + "hard_bounce", + "complaint", + "repeated_soft_bounce", + "provider_suppression", + "manual", + ]), + active: z.boolean(), + firstSuppressedAt: z.string(), + lastSuppressedAt: z.string(), + releasedAt: z.string().nullable().optional(), + releaseReason: z.string().nullable().optional(), +}); + +export const suppressionListSchema = z.object({ + items: z.array(suppressionSchema), + total: z.number(), +}); diff --git a/apps/api/src/mcp/tools/segments.test.ts b/apps/api/src/mcp/tools/segments.test.ts index 30badba7..a4c08653 100644 --- a/apps/api/src/mcp/tools/segments.test.ts +++ b/apps/api/src/mcp/tools/segments.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; -const mocks = { createSegment: vi.fn(), listSegments: vi.fn() }; -// contacts.test.ts mocks the same module; see the note there. -mock.module("../../contacts/segments-queries", () => ({ - ...mocks, +const mocks = { + createSegment: vi.fn(), + listSegments: vi.fn(), getSegment: vi.fn(), -})); + updateSegment: vi.fn(), + deleteSegment: vi.fn(), +}; +// contacts.test.ts mocks the same module; see the note there. +mock.module("../../contacts/segments-queries", () => mocks); const { segmentTools } = await import("./segments"); @@ -16,8 +19,6 @@ const context = { user: null, scopes: [], requestId: "", - clientIp: "", - replayHeaders: {}, }; const tools = new Map(segmentTools().map((tool) => [tool.name, tool])); function call(name: string, args: unknown, ctx: any = context): Promise { @@ -29,8 +30,54 @@ beforeEach(() => { }); describe("MCP segment tools", () => { - it("keeps list_segments and create_segment", () => { - expect([...tools.keys()]).toEqual(["list_segments", "create_segment"]); + it("has one titled tool per segment operation", () => { + expect([...tools.keys()]).toEqual([ + "list_segments", + "get_segment", + "create_segment", + "update_segment", + "delete_segment", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + expect(tools.get("delete_segment")!.risk).toBe("destructive"); + }); + + it("gets, updates and deletes only the team's segments", async () => { + mocks.getSegment.mockResolvedValue({ teamId: "team-2", segmentId: "seg_1" }); + await expect(call("get_segment", { segmentId: "seg_1" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + mocks.getSegment.mockResolvedValue({ + id: "internal", + teamId: "team-1", + segmentId: "seg_1", + name: "VIP", + }); + await expect(call("get_segment", { segmentId: "seg_1" })).resolves.toEqual({ + segmentId: "seg_1", + name: "VIP", + }); + + mocks.updateSegment.mockResolvedValue(null); + await expect( + call("update_segment", { segmentId: "seg_1", name: "Gold" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + expect(mocks.updateSegment).toHaveBeenCalledWith({ + teamId: "team-1", + segmentId: "seg_1", + name: "Gold", + }); + mocks.updateSegment.mockRejectedValue(new Error("duplicate_name")); + await expect( + call("update_segment", { segmentId: "seg_1", name: "Gold" }), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "duplicate_name" } }, + }); + + await expect( + call("delete_segment", { segmentId: "seg_1", confirm: true }), + ).resolves.toEqual({ message: "Segment deleted." }); + expect(mocks.deleteSegment).toHaveBeenCalledWith("team-1", "seg_1"); }); it("lists the team's segments without internal ids", async () => { diff --git a/apps/api/src/mcp/tools/segments.ts b/apps/api/src/mcp/tools/segments.ts index f38f573d..69d556c9 100644 --- a/apps/api/src/mcp/tools/segments.ts +++ b/apps/api/src/mcp/tools/segments.ts @@ -1,15 +1,31 @@ import { contactFilterSchema } from "@sendlit/api-contract"; import { z } from "zod"; -import { createSegment, listSegments } from "../../contacts/segments-queries"; +import { + createSegment, + deleteSegment, + getSegment, + listSegments, + updateSegment, +} from "../../contacts/segments-queries"; import { omitInternal } from "../../utils/public"; import type { SendLitMcpTool } from "../context"; import { dedicatedTool } from "../tool"; -import { toolError } from "./responses"; +import { notFound, toolError } from "./responses"; import { segmentListSchema, segmentSchema } from "./schemas"; +function duplicateName(err: any) { + return err?.message === "duplicate_name" + ? toolError("conflict", { + reason: "duplicate_name", + message: "A segment with this name already exists", + }) + : null; +} + export const segmentTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "list_segments", + title: "List Segments", operationId: "segments.list", risk: "read", description: @@ -23,8 +39,25 @@ export const segmentTools = (): SendLitMcpTool[] => [ }, }), + dedicatedTool({ + name: "get_segment", + title: "Get Segment", + operationId: "segments.get", + risk: "read", + description: "Returns a single saved segment by its segment ID.", + inputSchema: z.object({ segmentId: z.string().describe("Segment ID") }), + outputSchema: segmentSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const segment = await getSegment(args.segmentId); + if (!segment || segment.teamId !== context.teamId) return notFound(); + return omitInternal(segment); + }, + }), + dedicatedTool({ name: "create_segment", + title: "Create Segment", operationId: "segments.create", risk: "write", description: @@ -44,14 +77,57 @@ export const segmentTools = (): SendLitMcpTool[] => [ }); return omitInternal(segment); } catch (err: any) { - if (err?.message === "duplicate_name") { - return toolError("conflict", { - reason: "duplicate_name", - message: "A segment with this name already exists", - }); - } + const mapped = duplicateName(err); + if (mapped) return mapped; + throw err; + } + }, + }), + + dedicatedTool({ + name: "update_segment", + title: "Update Segment", + operationId: "segments.update", + risk: "write", + description: "Updates a saved segment's name and/or filter.", + inputSchema: z.object({ + segmentId: z.string().describe("Segment ID"), + name: z.string().optional(), + filter: contactFilterSchema.optional(), + }), + outputSchema: segmentSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const { segmentId, ...patch } = args; + try { + const segment = await updateSegment({ + teamId: context.teamId, + segmentId, + ...patch, + } as Parameters[0]); + if (!segment) return notFound(); + return omitInternal(segment); + } catch (err: any) { + const mapped = duplicateName(err); + if (mapped) return mapped; throw err; } }, }), + + dedicatedTool({ + name: "delete_segment", + title: "Delete Segment", + operationId: "segments.remove", + risk: "destructive", + description: + "Permanently deletes a saved segment. This action cannot be undone; it does not delete the contacts themselves.", + inputSchema: z.object({ segmentId: z.string() }), + outputSchema: z.object({ message: z.string() }), + annotations: { openWorldHint: false }, + async handler({ context, args }) { + await deleteSegment(context.teamId, args.segmentId); + return { message: "Segment deleted." }; + }, + }), ]; diff --git a/apps/api/src/mcp/tools/sequences.test.ts b/apps/api/src/mcp/tools/sequences.test.ts index f1dd5d45..979de232 100644 --- a/apps/api/src/mcp/tools/sequences.test.ts +++ b/apps/api/src/mcp/tools/sequences.test.ts @@ -14,9 +14,18 @@ const mocks = { startSequence: vi.fn(), updateMailInSequence: vi.fn(), updateSequence: vi.fn(), + deleteMailFromSequence: vi.fn(), + deleteSequence: vi.fn(), + getSequenceDeletionImpact: vi.fn(), + getSequenceSchedule: vi.fn(), + getSubscribers: vi.fn(), + getWaitingContacts: vi.fn(), + setBroadcastArchived: vi.fn(), sendSequenceEmailTest: vi.fn(), }; +class InvalidCursorError extends Error {} + mock.module("../../sequences/queries", () => ({ addMailToSequence: mocks.addMailToSequence, countSequences: mocks.countSequences, @@ -31,6 +40,14 @@ mock.module("../../sequences/queries", () => ({ startSequence: mocks.startSequence, updateMailInSequence: mocks.updateMailInSequence, updateSequence: mocks.updateSequence, + deleteMailFromSequence: mocks.deleteMailFromSequence, + deleteSequence: mocks.deleteSequence, + getSequenceDeletionImpact: mocks.getSequenceDeletionImpact, + getSequenceSchedule: mocks.getSequenceSchedule, + getSubscribers: mocks.getSubscribers, + getWaitingContacts: mocks.getWaitingContacts, + setBroadcastArchived: mocks.setBroadcastArchived, + InvalidCursorError, })); mock.module("../../sequences/test-send", () => ({ sendSequenceEmailTest: mocks.sendSequenceEmailTest, @@ -51,8 +68,6 @@ const teamKey = { user: null, scopes: [], requestId: "", - clientIp: "", - replayHeaders: {}, }; const oauth = { ...teamKey, @@ -74,7 +89,7 @@ beforeEach(() => { }); describe("MCP sequence tools", () => { - it("keeps only the dedicated sequence tools", () => { + it("has one titled tool per sequence operation, in a stable order", () => { expect([...tools.keys()]).toEqual([ "list_sequences", "get_sequence", @@ -83,10 +98,25 @@ describe("MCP sequence tools", () => { "add_sequence_email", "update_sequence_email", "send_sequence_email_test", + "delete_sequence_email", + "preview_sequence_deletion", + "delete_sequence", + "archive_broadcast", + "unarchive_broadcast", "start_sequence", "pause_sequence", "get_sequence_stats", + "get_sequence_schedule", + "list_sequence_email_waiting_contacts", + "get_sequence_subscribers", ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + // Destructive tools advertise `confirm`, which the kit requires. + for (const name of ["delete_sequence", "delete_sequence_email"]) { + const tool = tools.get(name)!; + expect(tool.risk).toBe("destructive"); + expect((tool.inputSchema as any).shape.confirm).toBeDefined(); + } }); it("refuses an OAuth token without the sequences scope", async () => { @@ -304,6 +334,217 @@ describe("MCP sequence tools", () => { }); }); +describe("MCP sequence deletion and archive tools", () => { + it("explains why an active sequence or a sent broadcast can't be deleted", async () => { + mocks.deleteSequence.mockRejectedValueOnce(new Error("sequence_active")); + const active: any = await call("delete_sequence", { sequenceId: "seq-1" }); + expect(active.error).toMatchObject({ + code: "conflict", + safeDetails: { reason: "sequence_active" }, + }); + expect(active.error.safeDetails.message).toContain("Pause it before deleting it"); + + mocks.deleteSequence.mockRejectedValueOnce(new Error("broadcast_sent")); + const sent: any = await call("delete_sequence", { sequenceId: "seq-1" }); + expect(sent.error.safeDetails).toMatchObject({ reason: "broadcast_sent" }); + expect(sent.error.safeDetails.message).toContain("Archive it instead"); + }); + + it("deletes a sequence of the caller's team", async () => { + mocks.deleteSequence.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + await expect(call("delete_sequence", { sequenceId: "seq-1" })).resolves.toEqual({ + message: "Sequence deleted.", + }); + expect(mocks.deleteSequence).toHaveBeenCalledWith({ + teamId: "team-1", + sequenceId: "seq-1", + }); + await expect( + call("delete_sequence", { sequenceId: "seq-2" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + }); + + it("deletes one email and maps its failures", async () => { + mocks.deleteMailFromSequence.mockResolvedValueOnce({ + sequenceId: "seq-1", + emails: [], + }); + await expect( + call("delete_sequence_email", { sequenceId: "seq-1", emailId: "email_1" }), + ).resolves.toMatchObject({ sequenceId: "seq-1" }); + expect(mocks.deleteMailFromSequence).toHaveBeenCalledWith({ + teamId: "team-1", + sequenceId: "seq-1", + emailId: "email_1", + }); + mocks.deleteMailFromSequence.mockResolvedValueOnce(null); + await expect( + call("delete_sequence_email", { sequenceId: "seq-1", emailId: "email_x" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + }); + + it("archives and restores a broadcast of the caller's team", async () => { + const archivedAt = new Date("2026-10-29T00:00:00Z"); + mocks.setBroadcastArchived + .mockResolvedValueOnce({ sequenceId: "seq-1", archivedAt, emails: [] }) + .mockResolvedValueOnce({ sequenceId: "seq-1", archivedAt: null, emails: [] }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error("broadcast_not_sent")); + + await expect( + call("archive_broadcast", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ archivedAt: archivedAt.toISOString() }); + expect(mocks.setBroadcastArchived).toHaveBeenLastCalledWith({ + teamId: "team-1", + sequenceId: "seq-1", + archived: true, + }); + await expect( + call("unarchive_broadcast", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ archivedAt: null }); + expect(mocks.setBroadcastArchived).toHaveBeenLastCalledWith({ + teamId: "team-1", + sequenceId: "seq-1", + archived: false, + }); + await expect( + call("archive_broadcast", { sequenceId: "seq-2" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + const draft: any = await call("archive_broadcast", { sequenceId: "seq-3" }); + expect(draft.error).toMatchObject({ + code: "conflict", + safeDetails: { reason: "broadcast_not_sent" }, + }); + expect(draft.error.safeDetails.message).toContain( + "Only a sent broadcast can be archived", + ); + }); + + it("previews a sequence deletion", async () => { + const preview = { + status: "paused", + blocker: null, + enrolledCount: 2, + emailsSentCount: 5, + }; + mocks.getSequenceDeletionImpact.mockResolvedValueOnce(preview); + await expect( + call("preview_sequence_deletion", { sequenceId: "seq-1" }), + ).resolves.toEqual(preview); + expect(mocks.getSequenceDeletionImpact).toHaveBeenCalledWith("team-1", "seq-1"); + }); + + it("refuses a delete to an OAuth token without sequences:write", async () => { + await expect( + call( + "delete_sequence", + { sequenceId: "seq-1" }, + { ...oauth, scopes: ["sequences:read"] }, + ), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "sequences:write" }, + }, + }); + expect(mocks.deleteSequence).not.toHaveBeenCalled(); + }); +}); + +describe("MCP sequence schedule tools", () => { + it("checks sequence ownership before returning the schedule", async () => { + mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); + await expect( + call("get_sequence_schedule", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + expect(mocks.getSequenceSchedule).not.toHaveBeenCalled(); + + const sequence = { sequenceId: "seq-1" }; + const schedule = { + waiting: 3, + emails: [{ emailId: "email_1", waiting: 3, nextSendAt: 1_800_000_000_000 }], + }; + mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); + mocks.getSequenceSchedule.mockResolvedValueOnce(schedule); + await expect( + call("get_sequence_schedule", { sequenceId: "seq-1" }), + ).resolves.toEqual(schedule); + expect(mocks.getSequenceSchedule).toHaveBeenCalledWith(sequence); + }); + + it("lists waiting contacts only for an email of the caller's sequence", async () => { + const sequence = { sequenceId: "seq-1", emailsOrder: ["email_1"] }; + mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); + await expect( + call("list_sequence_email_waiting_contacts", { + sequenceId: "seq-1", + emailId: "email_1", + }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); + await expect( + call("list_sequence_email_waiting_contacts", { + sequenceId: "seq-1", + emailId: "email_other", + }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + expect(mocks.getWaitingContacts).not.toHaveBeenCalled(); + + const page = { + items: [ + { contactId: "cnt_1", email: "a@example.com", name: null, nextSendAt: 1_000 }, + ], + total: 1, + nextCursor: null, + }; + mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); + mocks.getWaitingContacts.mockResolvedValueOnce(page); + await expect( + call("list_sequence_email_waiting_contacts", { + sequenceId: "seq-1", + emailId: "email_1", + after: "c1", + q: "ana", + }), + ).resolves.toEqual(page); + expect(mocks.getWaitingContacts).toHaveBeenCalledWith(sequence, "email_1", { + after: "c1", + limit: undefined, + q: "ana", + }); + + mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); + mocks.getWaitingContacts.mockRejectedValueOnce(new InvalidCursorError("bad")); + await expect( + call("list_sequence_email_waiting_contacts", { + sequenceId: "seq-1", + emailId: "email_1", + after: "garbage", + }), + ).resolves.toMatchObject({ + error: { code: "validation_failed", safeDetails: { reason: "invalid_cursor" } }, + }); + }); + + it("pages the contacts a sequence has sent to", async () => { + mocks.getSequenceBySequenceId.mockResolvedValueOnce({ sequenceId: "seq-1" }); + mocks.getSubscribers.mockResolvedValueOnce(["cnt_1", "cnt_2"]); + mocks.getSubscribersCount.mockResolvedValueOnce(7); + await expect( + call("get_sequence_subscribers", { sequenceId: "seq-1", page: 2, limit: 2 }), + ).resolves.toEqual({ items: ["cnt_1", "cnt_2"], total: 7 }); + expect(mocks.getSubscribers).toHaveBeenCalledWith({ + sequenceId: "seq-1", + page: 2, + limit: 2, + }); + mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); + await expect( + call("get_sequence_subscribers", { sequenceId: "seq-x" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + }); +}); + describe("MCP sequence output schema", () => { it("accepts what the sequence tools return", () => { // Regression: the schema required an `espId` the tools never return, diff --git a/apps/api/src/mcp/tools/sequences.ts b/apps/api/src/mcp/tools/sequences.ts index 23aaca96..427c3fc4 100644 --- a/apps/api/src/mcp/tools/sequences.ts +++ b/apps/api/src/mcp/tools/sequences.ts @@ -2,6 +2,7 @@ import { archivedFilters, contactFilterSchema, emailContentInputSchema, + sequenceDeletionPreviewSchema, sequenceEmailTestResultSchema, updateSequenceBodySchema, } from "@sendlit/api-contract"; @@ -10,18 +11,30 @@ import { broadcastPacing } from "../../abuse/pacing-status"; import { sequenceDeliveryRates } from "../../automation/sending-health"; import { emailActionTypes, mailTypes } from "../../config/constants"; import { responses } from "../../config/strings"; +import { + broadcastArchiveErrors, + sequenceDeleteBlockerMessages, +} from "../../sequences/delete-blockers"; import type { HydratedSequence } from "../../sequences/queries"; import { addMailToSequence, countSequences, createSequence, + deleteMailFromSequence, + deleteSequence, getEmailSentCount, getSequenceBySequenceId, getSequenceClickThroughRate, + getSequenceDeletionImpact, getSequenceOpenRate, + getSequenceSchedule, + getSubscribers, getSubscribersCount, + getWaitingContacts, + InvalidCursorError, listSequences, pauseSequence, + setBroadcastArchived, startSequence, updateMailInSequence, updateSequence, @@ -38,7 +51,14 @@ import { type ToolError, toolError, } from "./responses"; -import { sequenceListSchema, sequenceSchema, sequenceStatsSchema } from "./schemas"; +import { + sequenceListSchema, + sequenceScheduleSchema, + sequenceSchema, + sequenceStatsSchema, + successMessageSchema, + waitingContactsSchema, +} from "./schemas"; // `sequenceEmails.sequenceId` stores its *parent* sequence's internal `id` // (see the file-level doc comment in `db/schema.ts`) — dropped here rather @@ -107,6 +127,13 @@ function sequenceError(err: unknown): ToolError { message = match[2]; } const details: Record = message ? { reason, message } : { reason }; + // Delete and archive blockers say what to do instead (pause, archive or + // delete), so the model gets the same explanation as REST and the dashboard. + const blocker = + sequenceDeleteBlockerMessages[ + reason as keyof typeof sequenceDeleteBlockerMessages + ] ?? broadcastArchiveErrors[reason as keyof typeof broadcastArchiveErrors]; + if (blocker) return toolError("conflict", { reason, message: blocker }); if (reason === "action_not_allowed") return toolError("forbidden", details); if (NOT_FOUND_REASONS.has(reason)) return toolError("not_found", details); if (RATE_LIMITED_REASONS.has(reason)) return toolError("rate_limited", details); @@ -131,6 +158,7 @@ const deliverySourceSchema = z.discriminatedUnion("type", [ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "list_sequences", + title: "List Sequences", operationId: "sequences.list", risk: "read", description: @@ -163,6 +191,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "get_sequence", + title: "Get Sequence", operationId: "sequences.get", risk: "read", description: @@ -179,6 +208,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "create_sequence", + title: "Create Sequence", operationId: "sequences.create", risk: "write", description: @@ -207,6 +237,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "update_sequence", + title: "Update Sequence", operationId: "sequences.update", risk: "write", description: @@ -263,6 +294,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "add_sequence_email", + title: "Add Sequence Email", operationId: "sequences.addEmail", risk: "write", description: @@ -290,6 +322,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "update_sequence_email", + title: "Update Sequence Email", operationId: "sequences.updateEmail", risk: "write", description: @@ -333,6 +366,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "send_sequence_email_test", + title: "Send Sequence Email Test", operationId: "sequences.sendEmailTest", risk: "write", description: @@ -362,8 +396,103 @@ export const sequenceTools = (): SendLitMcpTool[] => [ }, }), + dedicatedTool({ + name: "delete_sequence_email", + title: "Delete Sequence Email", + operationId: "sequences.removeEmail", + risk: "destructive", + description: + "Removes an email from a sequence. Broadcasts cannot have their only email removed. Images only this email used are deleted from the media library, unless it was already sent.", + inputSchema: z.object({ sequenceId: z.string(), emailId: z.string() }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const sequence = await deleteMailFromSequence({ + teamId: context.teamId, + sequenceId: args.sequenceId, + emailId: args.emailId, + }); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); + } + }, + }), + + dedicatedTool({ + name: "preview_sequence_deletion", + title: "Preview Sequence Deletion", + operationId: "sequences.deletionPreview", + risk: "read", + description: + "Shows what delete_sequence would remove without changing anything: how many contacts are enrolled, how many emails it has sent, and whether it can be deleted now (blocker sequence_active means pause it first; broadcast_sent means archive the broadcast with archive_broadcast instead).", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: sequenceDeletionPreviewSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const preview = await getSequenceDeletionImpact(context.teamId, args.sequenceId); + if (!preview) return notFound(); + return preview; + }, + }), + + dedicatedTool({ + name: "delete_sequence", + title: "Delete Sequence", + operationId: "sequences.remove", + risk: "destructive", + description: + "Permanently deletes a broadcast or sequence with its emails and triggers. An active sequence or sending broadcast must be paused first (pause_sequence). A draft or scheduled broadcast can be deleted; a broadcast that has started sending can't (broadcast_sent): use archive_broadcast. Contacts still enrolled leave it, and its delivery, open and click history (its stats) is deleted and can't be recovered; call preview_sequence_deletion first and confirm with the user. Images only it used are deleted; images in sent mail are kept.", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const deleted = await deleteSequence({ + teamId: context.teamId, + sequenceId: args.sequenceId, + }); + if (!deleted) return notFound(); + return { message: "Sequence deleted." }; + } catch (err) { + return sequenceError(err); + } + }, + }), + + ...[true, false].map((archived) => + dedicatedTool({ + name: archived ? "archive_broadcast" : "unarchive_broadcast", + title: archived ? "Archive Broadcast" : "Unarchive Broadcast", + operationId: archived ? "sequences.archive" : "sequences.unarchive", + risk: "write", + description: archived + ? "Archives a sent broadcast: it leaves the broadcasts list (list_sequences with archived=only shows it) and the public web archive, feed and sitemap. Its report, delivery history and images are kept. Drafts and scheduled broadcasts can't be archived; delete them with delete_sequence." + : "Restores an archived broadcast to the broadcasts list, and to the web archive when it is published there.", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: sequenceSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + try { + const sequence = await setBroadcastArchived({ + teamId: context.teamId, + sequenceId: args.sequenceId, + archived, + }); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); + } + }, + }), + ), + dedicatedTool({ name: "start_sequence", + title: "Start Sequence", operationId: "sequences.start", risk: "write", description: @@ -386,6 +515,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "pause_sequence", + title: "Pause Sequence", operationId: "sequences.pause", risk: "write", description: @@ -408,6 +538,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "get_sequence_stats", + title: "Get Sequence Stats", operationId: "sequences.stats", risk: "read", description: @@ -436,4 +567,92 @@ export const sequenceTools = (): SendLitMcpTool[] => [ }; }, }), + dedicatedTool({ + name: "get_sequence_schedule", + title: "Get Sequence Schedule", + operationId: "sequences.schedule", + risk: "read", + description: + "Returns, for each email of a broadcast/sequence in send order, how many contacts will get it next and when the first of them is due (epoch millis), plus the total waiting. Use it to check that delays work as intended: a sequence that has sent nothing yet can still have contacts waiting.", + inputSchema: z.object({ sequenceId: z.string() }), + outputSchema: sequenceScheduleSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const sequence = await getSequenceBySequenceId(context.teamId, args.sequenceId); + if (!sequence) return notFound(); + return getSequenceSchedule(sequence); + }, + }), + + dedicatedTool({ + name: "list_sequence_email_waiting_contacts", + title: "List Waiting Contacts for a Sequence Email", + operationId: "sequences.waitingContacts", + risk: "read", + description: + "Lists the contacts whose next email in a sequence is this one, soonest due first, each with their own due time (epoch millis): each contact's delay counts from their own entry. q matches email or name. Returns up to limit (default 10, at most 100) with total and nextCursor; pass nextCursor as after for the next page.", + inputSchema: z.object({ + sequenceId: z.string(), + emailId: z.string(), + after: z.string().optional().describe("The previous page's nextCursor"), + limit: z.number().int().min(1).max(100).optional(), + q: z.string().optional().describe("Matches the contact's email or name"), + }), + outputSchema: waitingContactsSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const sequence = await getSequenceBySequenceId(context.teamId, args.sequenceId); + if (!sequence?.emailsOrder.includes(args.emailId)) return notFound(); + try { + return await getWaitingContacts(sequence, args.emailId, { + after: args.after, + limit: args.limit, + q: args.q, + }); + } catch (err) { + if (err instanceof InvalidCursorError) { + return toolError("validation_failed", { + reason: "invalid_cursor", + message: "Pass the nextCursor from the previous page as after.", + }); + } + throw err; + } + }, + }), + + dedicatedTool({ + name: "get_sequence_subscribers", + title: "Get Sequence Subscribers", + operationId: "sequences.subscribers", + risk: "read", + description: + "Returns a paginated list of contact IDs that have received at least one email in this sequence or broadcast.", + inputSchema: z.object({ + sequenceId: z.string().describe("Sequence or broadcast ID"), + page: z.number().int().min(1).optional().describe("Page number (default: 1)"), + limit: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe("Results per page (default: 50)"), + }), + outputSchema: z.object({ items: z.array(z.string()), total: z.number() }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const sequence = await getSequenceBySequenceId(context.teamId, args.sequenceId); + if (!sequence) return notFound(); + const [items, total] = await Promise.all([ + getSubscribers({ + sequenceId: args.sequenceId, + page: args.page, + limit: args.limit, + }), + getSubscribersCount(args.sequenceId), + ]); + return { items, total }; + }, + }), ]; diff --git a/apps/api/src/mcp/tools/settings-tools.test.ts b/apps/api/src/mcp/tools/settings-tools.test.ts new file mode 100644 index 00000000..98607658 --- /dev/null +++ b/apps/api/src/mcp/tools/settings-tools.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + getTrackingDomain: vi.fn(), + setTrackingDomain: vi.fn(), + verifyTrackingDomain: vi.fn(), + startGoogleConnect: vi.fn(), + deleteConnection: vi.fn(), + getReputation: vi.fn(), + connectSnds: vi.fn(), + syncTeamNow: vi.fn(), + consumeRateLimit: vi.fn(), +}; + +class FakeSndsLinkError extends Error {} + +mock.module("../../settings/tracking-domain/queries", () => ({ + getTrackingDomain: mocks.getTrackingDomain, + setTrackingDomain: mocks.setTrackingDomain, + verifyTrackingDomain: mocks.verifyTrackingDomain, + toPublicTrackingDomain: (domain: unknown) => domain, +})); +mock.module("../../reputation/google", () => ({ + startGoogleConnect: mocks.startGoogleConnect, +})); +mock.module("../../reputation/queries", () => ({ + deleteConnection: mocks.deleteConnection, + getReputation: mocks.getReputation, +})); +mock.module("../../reputation/snds", () => ({ SndsLinkError: FakeSndsLinkError })); +mock.module("../../reputation/sync", () => ({ + connectSnds: mocks.connectSnds, + syncTeamNow: mocks.syncTeamNow, +})); +mock.module("../../services/rate-counter", () => ({ + consumeRateLimit: mocks.consumeRateLimit, +})); + +const { trackingDomainTools } = await import("./tracking-domain"); +const { reputationTools } = await import("./reputation"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [] as string[], + requestId: "", +}; +const tools = new Map( + [...trackingDomainTools(), ...reputationTools()].map((tool) => [tool.name, tool]), +); +const call = (name: string, args: Record = {}, ctx: any = context) => + tools.get(name)!.handler({ context: ctx, args, signal }) as Promise; + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP tracking domain tools", () => { + it("sets the domain for the team and maps REST's error codes", async () => { + mocks.setTrackingDomain.mockResolvedValue({ domain: "track.example.com" }); + await expect( + call("set_tracking_domain", { domain: "track.example.com" }), + ).resolves.toEqual({ domain: "track.example.com" }); + expect(mocks.setTrackingDomain).toHaveBeenCalledWith("team-1", "track.example.com"); + + for (const [reason, code] of [ + ["tracking_domain_invalid", "validation_failed"], + ["tracking_domain_taken", "conflict"], + ]) { + mocks.setTrackingDomain.mockRejectedValueOnce(new Error(reason)); + await expect(call("set_tracking_domain", { domain: "x" })).resolves.toMatchObject( + { + error: { code, safeDetails: { reason } }, + }, + ); + } + + mocks.verifyTrackingDomain.mockRejectedValueOnce( + new Error("tracking_domain_not_set"), + ); + await expect(call("verify_tracking_domain")).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "tracking_domain_not_set" }, + }, + }); + + mocks.verifyTrackingDomain.mockRejectedValueOnce(new Error("dns exploded")); + await expect(call("verify_tracking_domain")).rejects.toThrow("dns exploded"); + }); + + it("needs settings:write over OAuth", async () => { + const oauth = { ...context, authKind: "oauth", scopes: ["settings:read"] }; + await expect( + call("set_tracking_domain", { domain: null }, oauth), + ).resolves.toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "insufficient_scope" } }, + }); + }); +}); + +describe("MCP reputation tools", () => { + it("returns the Google sign-in URL, or says Postmaster isn't configured", async () => { + mocks.startGoogleConnect.mockReturnValue("https://accounts.google.com/x"); + await expect(call("connect_google_postmaster")).resolves.toEqual({ + authorizationUrl: "https://accounts.google.com/x", + }); + expect(mocks.startGoogleConnect).toHaveBeenCalledWith("team-1", null); + + mocks.startGoogleConnect.mockReturnValue(null); + await expect(call("connect_google_postmaster")).resolves.toMatchObject({ + error: { + code: "conflict", + safeDetails: { reason: "google_postmaster_not_configured" }, + }, + }); + }); + + it("reports an invalid SNDS link and a missing connection", async () => { + mocks.connectSnds.mockRejectedValueOnce(new FakeSndsLinkError("bad")); + await expect( + call("connect_snds", { + dataUrl: "https://sendersupport.olc.protection.outlook.com/x", + }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "snds_link_invalid" }, + }, + }); + + mocks.deleteConnection.mockResolvedValue(false); + await expect(call("disconnect_snds", { confirm: true })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + mocks.deleteConnection.mockResolvedValue(true); + await expect( + call("disconnect_google_postmaster", { confirm: true }), + ).resolves.toEqual({ + disconnected: true, + }); + expect(mocks.deleteConnection).toHaveBeenCalledWith("team-1", "google_postmaster"); + }); + + it("limits manual syncs to once every five minutes", async () => { + mocks.consumeRateLimit.mockResolvedValue(false); + await expect(call("sync_reputation")).resolves.toMatchObject({ + error: { code: "rate_limited", safeDetails: { reason: "too_many_requests" } }, + }); + expect(mocks.syncTeamNow).not.toHaveBeenCalled(); + + mocks.consumeRateLimit.mockResolvedValue(true); + mocks.getReputation.mockResolvedValue({ alerts: [] }); + await expect(call("sync_reputation")).resolves.toEqual({ alerts: [] }); + expect(mocks.syncTeamNow).toHaveBeenCalledWith("team-1"); + }); +}); diff --git a/apps/api/src/mcp/tools/tags.test.ts b/apps/api/src/mcp/tools/tags.test.ts new file mode 100644 index 00000000..d73bec27 --- /dev/null +++ b/apps/api/src/mcp/tools/tags.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + listTags: vi.fn(), + getTagReferences: vi.fn(), + renameTag: vi.fn(), + deleteTag: vi.fn(), +}; +mock.module("../../contacts/tags-queries", () => mocks); + +const { tagTools } = await import("./tags"); + +const signal = new AbortController().signal; +const context = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [] as string[], + requestId: "", +}; +const tools = new Map(tagTools().map((tool) => [tool.name, tool])); +function call(name: string, args: unknown, ctx: any = context): Promise { + return tools.get(name)!.handler({ context: ctx, args, signal }); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP tag tools", () => { + it("has one titled tool per tag operation", () => { + expect([...tools.keys()]).toEqual([ + "list_tags", + "get_tag_references", + "rename_tag", + "delete_tag", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + expect(tools.get("list_tags")!.risk).toBe("read"); + expect(tools.get("rename_tag")!.risk).toBe("write"); + expect(tools.get("delete_tag")!.risk).toBe("destructive"); + expect(Object.keys((tools.get("delete_tag")!.inputSchema as any).shape)).toContain( + "confirm", + ); + }); + + it("lists and inspects the team's tags", async () => { + mocks.listTags.mockResolvedValue([{ tag: "vip", contacts: 2 }]); + await expect(call("list_tags", {})).resolves.toEqual({ + items: [{ tag: "vip", contacts: 2 }], + }); + expect(mocks.listTags).toHaveBeenCalledWith("team-1"); + + mocks.getTagReferences.mockResolvedValue({ tag: "vip", contacts: 2, segments: [] }); + await expect(call("get_tag_references", { tag: "vip" })).resolves.toMatchObject({ + tag: "vip", + }); + expect(mocks.getTagReferences).toHaveBeenCalledWith("team-1", "vip"); + }); + + it("renames a tag and maps a missing or unchanged tag", async () => { + mocks.renameTag.mockResolvedValue({ tag: "gold", contactsUpdated: 2 }); + await expect(call("rename_tag", { tag: "vip", name: "gold" })).resolves.toEqual({ + tag: "gold", + contactsUpdated: 2, + }); + expect(mocks.renameTag).toHaveBeenCalledWith("team-1", "vip", "gold"); + + mocks.renameTag.mockResolvedValue("tag_not_found"); + await expect(call("rename_tag", { tag: "nope", name: "x" })).resolves.toMatchObject( + { + error: { code: "not_found" }, + }, + ); + + mocks.renameTag.mockResolvedValue("tag_unchanged"); + await expect( + call("rename_tag", { tag: "vip", name: "vip" }), + ).resolves.toMatchObject({ + error: { code: "validation_failed", safeDetails: { reason: "tag_unchanged" } }, + }); + }); + + it("deletes a tag and returns not_found for an unknown one", async () => { + mocks.deleteTag.mockResolvedValue({ tag: "gold", contactsUpdated: 2 }); + await expect(call("delete_tag", { tag: "gold", confirm: true })).resolves.toEqual({ + tag: "gold", + contactsUpdated: 2, + }); + expect(mocks.deleteTag).toHaveBeenCalledWith("team-1", "gold"); + + mocks.deleteTag.mockResolvedValue(null); + await expect( + call("delete_tag", { tag: "nope", confirm: true }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("needs contacts:write to rename over OAuth", async () => { + await expect( + call( + "rename_tag", + { tag: "vip", name: "gold" }, + { ...context, authKind: "oauth", scopes: ["contacts:read"] }, + ), + ).resolves.toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "insufficient_scope" } }, + }); + expect(mocks.renameTag).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/tags.ts b/apps/api/src/mcp/tools/tags.ts new file mode 100644 index 00000000..d0369088 --- /dev/null +++ b/apps/api/src/mcp/tools/tags.ts @@ -0,0 +1,92 @@ +import { + deleteTagResultSchema, + renameTagResultSchema, + tagReferencesSchema, + tagSummarySchema, +} from "@sendlit/api-contract"; +import { z } from "zod"; +import { + deleteTag, + getTagReferences, + listTags, + renameTag, +} from "../../contacts/tags-queries"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound, toolError } from "./responses"; + +const tagInput = { tag: z.string().min(1).describe("The tag, exactly as stored") }; + +export const tagTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_tags", + title: "List Tags", + operationId: "tags.list", + risk: "read", + description: + "Lists every tag carried by at least one of the team's contacts, sorted by name, with how many contacts carry it.", + inputSchema: z.object({}), + outputSchema: z.object({ items: z.array(tagSummarySchema) }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context }) { + return { items: await listTags(context.teamId) }; + }, + }), + + dedicatedTool({ + name: "get_tag_references", + title: "Get Tag References", + operationId: "tags.references", + risk: "read", + description: + "Shows how many contacts carry a tag and which segments, broadcasts, sequences (audience filters, tag triggers, email tag actions) and signup forms name it. Check it before deleting or renaming a tag.", + inputSchema: z.object(tagInput), + outputSchema: tagReferencesSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + return await getTagReferences(context.teamId, args.tag); + }, + }), + + dedicatedTool({ + name: "rename_tag", + title: "Rename Tag", + operationId: "tags.rename", + risk: "write", + description: + "Renames a tag on every contact and in the segments, broadcast and sequence filters and tag triggers, sequence email tag actions and signup forms that name it. Renaming onto an existing tag merges the two. Sent broadcasts keep their original filters. Doesn't trigger tag-added or tag-removed sequences.", + inputSchema: z.object({ + ...tagInput, + name: z.string().trim().min(1).describe("The tag's new name"), + }), + outputSchema: renameTagResultSchema, + annotations: { idempotentHint: false, openWorldHint: false }, + async handler({ context, args }) { + const result = await renameTag(context.teamId, args.tag, args.name); + if (result === "tag_not_found") return notFound(); + if (result === "tag_unchanged") { + return toolError("validation_failed", { + reason: "tag_unchanged", + message: "The new name is the same as the current one.", + }); + } + return result; + }, + }), + + dedicatedTool({ + name: "delete_tag", + title: "Delete Tag", + operationId: "tags.remove", + risk: "destructive", + description: + "Removes a tag from every contact of the team. This can't be undone. Segments, filters, triggers, email actions and signup forms that name the tag are left unchanged and returned in `references`: a filter on a deleted tag matches nobody, and a trigger or form can add the tag again. Call get_tag_references first and confirm with the user. Doesn't trigger tag-removed sequences.", + inputSchema: z.object(tagInput), + outputSchema: deleteTagResultSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context, args }) { + const result = await deleteTag(context.teamId, args.tag); + return result ?? notFound(); + }, + }), +]; diff --git a/apps/api/src/mcp/tools/teams.test.ts b/apps/api/src/mcp/tools/teams.test.ts index 4282853b..555cc45e 100644 --- a/apps/api/src/mcp/tools/teams.test.ts +++ b/apps/api/src/mcp/tools/teams.test.ts @@ -1,39 +1,286 @@ import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; -const getTeam = vi.fn(); +const mocks = { + getTeam: vi.fn(), + getTeamByTeamId: vi.fn(), + getTeamMembership: vi.fn(), + listTeamViewsForUser: vi.fn(), + createTeam: vi.fn(), + archiveTeam: vi.fn(), + renameTeam: vi.fn(), + getOrganizationMembership: vi.fn(), + createApiKey: vi.fn(), + deleteApiKey: vi.fn(), + getApiKeysByTeamId: vi.fn(), +}; const usageForOrganization = vi.fn(); const getOrganizationEntitlements = vi.fn(); -mock.module("../../team/queries", () => ({ getTeam })); +// bun shares module mocks across test files in a process: export everything +// any MCP tool test needs from these modules. +mock.module("../../team/queries", () => ({ + getTeam: mocks.getTeam, + getTeamByTeamId: mocks.getTeamByTeamId, + getTeamMembership: mocks.getTeamMembership, + listTeamViewsForUser: mocks.listTeamViewsForUser, + createTeam: mocks.createTeam, + archiveTeam: mocks.archiveTeam, + renameTeam: mocks.renameTeam, +})); +mock.module("../../organization/queries", () => ({ + getOrganizationMembership: mocks.getOrganizationMembership, +})); +mock.module("../../apikey/queries", () => ({ + createApiKey: mocks.createApiKey, + deleteApiKey: mocks.deleteApiKey, + getApiKeysByTeamId: mocks.getApiKeysByTeamId, +})); mock.module("../../billing/usage.js", () => ({ usageForOrganization })); mock.module("../../billing/entitlements.js", () => ({ getOrganizationEntitlements })); const { teamTools } = await import("./teams"); +const { PlanGateError } = await import("../../billing/errors"); const signal = new AbortController().signal; const context = { authKind: "team_key" as const, teamId: "team-internal-1", user: null, - scopes: [], + scopes: [] as string[], requestId: "", - clientIp: "", - replayHeaders: {}, }; -const [tool] = teamTools(); +const owner = { + id: "user-1", + email: "owner@example.com", + name: "Owner", + defaultOrganizationId: "org-1", +}; +const oauth = { + ...context, + authKind: "oauth" as const, + user: owner as any, + scopes: ["teams:read", "teams:write", "api_keys:read", "api_keys:write"], +}; +const tools = new Map(teamTools().map((tool) => [tool.name, tool])); +const planTool = tools.get("get_plan_usage")!; +function call(name: string, args: unknown, ctx: any = oauth): Promise { + return tools.get(name)!.handler({ context: ctx, args, signal }) as Promise; +} beforeEach(() => { - for (const fn of [getTeam, usageForOrganization, getOrganizationEntitlements]) { + for (const fn of [ + ...Object.values(mocks), + usageForOrganization, + getOrganizationEntitlements, + ]) { fn.mockReset(); } }); -describe("MCP get_plan_usage", () => { - it("is the only dedicated team tool", () => { - expect(teamTools().map((t) => t.name)).toEqual(["get_plan_usage"]); +describe("MCP team and API key tools", () => { + it("serves every team tool in order, each with a title", () => { + expect([...tools.keys()]).toEqual([ + "list_teams", + "get_plan_usage", + "create_team", + "rename_team", + "delete_team", + "list_api_keys", + "create_api_key", + "delete_api_key", + ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + for (const name of ["delete_team", "delete_api_key"]) { + expect(tools.get(name)!.risk).toBe("destructive"); + expect((tools.get(name)!.inputSchema as any).shape.confirm).toBeDefined(); + } }); + it("lists the signed-in person's teams, and needs a person", async () => { + await expect(call("list_teams", {}, context)).resolves.toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "user_auth_required" } }, + }); + expect(mocks.listTeamViewsForUser).not.toHaveBeenCalled(); + + mocks.listTeamViewsForUser.mockResolvedValue([ + { + id: "team-1", + teamId: "team-1", + name: "Main", + organizationPublicId: "org-1", + organizationName: "Acme", + ignored: true, + }, + ]); + await expect(call("list_teams", {})).resolves.toEqual({ + items: [ + { + teamId: "team-1", + name: "Main", + organizationId: "org-1", + organizationName: "Acme", + }, + ], + }); + expect(mocks.listTeamViewsForUser).toHaveBeenCalledWith("user-1"); + }); + + it("needs the teams:write scope over OAuth", async () => { + await expect( + call("create_team", { name: "Second" }, { ...oauth, scopes: ["teams:read"] }), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "teams:write" }, + }, + }); + expect(mocks.createTeam).not.toHaveBeenCalled(); + }); + + it("creates teams for an organization admin, keeping plan limits", async () => { + mocks.getOrganizationMembership.mockResolvedValue({ role: "owner" }); + mocks.createTeam.mockResolvedValue({ + id: "internal-team-2", + teamId: "team-2", + name: "Second Team", + }); + await expect(call("create_team", { name: "Second Team" })).resolves.toEqual({ + teamId: "team-2", + name: "Second Team", + }); + expect(mocks.createTeam).toHaveBeenCalledWith({ + organizationId: "org-1", + creatorUserId: "user-1", + name: "Second Team", + }); + + mocks.createTeam.mockRejectedValueOnce( + new PlanGateError("plan_limit_reached", { limit: 1 }), + ); + await expect(call("create_team", { name: "Third" })).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "plan_limit_reached", limit: 1 }, + }, + }); + + mocks.getOrganizationMembership.mockResolvedValue({ role: "member" }); + await expect(call("create_team", { name: "Fourth" })).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "organization_admin_required" }, + }, + }); + await expect( + call("create_team", { name: "Fifth" }, context), + ).resolves.toMatchObject({ + error: { safeDetails: { reason: "user_auth_required" } }, + }); + }); + + it("renames the current team", async () => { + mocks.renameTeam.mockResolvedValue({ teamId: "team-1", name: "Renamed" }); + await expect(call("rename_team", { name: "Renamed" })).resolves.toEqual({ + teamId: "team-1", + name: "Renamed", + }); + expect(mocks.renameTeam).toHaveBeenCalledWith("team-internal-1", "Renamed"); + mocks.renameTeam.mockResolvedValue(null); + await expect(call("rename_team", { name: "X" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("only lets organization admins delete teams", async () => { + mocks.getTeamByTeamId.mockResolvedValue({ + id: "internal-team-2", + teamId: "team-2", + name: "Second Team", + organizationId: "org-1", + }); + mocks.getTeamMembership.mockResolvedValueOnce({ role: "member" }); + mocks.getOrganizationMembership.mockResolvedValueOnce({ role: "member" }); + await expect( + call("delete_team", { teamId: "team-2", confirm: true }), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "organization_admin_required" }, + }, + }); + expect(mocks.archiveTeam).not.toHaveBeenCalled(); + + mocks.getTeamMembership.mockResolvedValueOnce({ role: "admin" }); + mocks.getOrganizationMembership.mockResolvedValueOnce({ role: "owner" }); + await expect( + call("delete_team", { teamId: "team-2", confirm: true }), + ).resolves.toEqual({ + message: "Team archived.", + }); + expect(mocks.archiveTeam).toHaveBeenCalledWith("internal-team-2"); + + mocks.getTeamMembership.mockResolvedValueOnce(null); + await expect( + call("delete_team", { teamId: "team-2", confirm: true }), + ).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("manages API keys over OAuth, never for a team key", async () => { + await expect(call("list_api_keys", {}, context)).resolves.toMatchObject({ + error: { code: "forbidden", safeDetails: { reason: "user_auth_required" } }, + }); + expect(mocks.getApiKeysByTeamId).not.toHaveBeenCalled(); + + mocks.getApiKeysByTeamId.mockResolvedValue([ + { + teamApiKeyId: "key_1", + keyPrefix: "sl_live_ab", + name: "CI", + createdAt: new Date("2026-10-01T00:00:00.000Z"), + }, + ]); + await expect(call("list_api_keys", {})).resolves.toEqual({ + items: [ + { + id: "key_1", + keyPrefix: "sl_live_ab", + name: "CI", + createdAt: "2026-10-01T00:00:00.000Z", + }, + ], + }); + + mocks.createApiKey.mockResolvedValue({ + apiKey: { + teamApiKeyId: "key_2", + keyPrefix: "sl_live_cd", + name: "Zapier", + createdAt: new Date("2026-10-02T00:00:00.000Z"), + }, + secret: "sl_live_cdsecret", + }); + await expect(call("create_api_key", { name: "Zapier" })).resolves.toMatchObject({ + id: "key_2", + key: "sl_live_cdsecret", + }); + expect(mocks.createApiKey).toHaveBeenCalledWith("team-internal-1", "Zapier", { + createdByType: "user", + createdById: "user-1", + }); + + await expect( + call("delete_api_key", { keyId: "key_2", confirm: true }), + ).resolves.toEqual({ + message: "API key deleted.", + }); + expect(mocks.deleteApiKey).toHaveBeenCalledWith("team-internal-1", "key_2"); + }); +}); + +describe("MCP get_plan_usage", () => { it("reports usage and limits for the team's organization", async () => { - getTeam.mockResolvedValue({ id: "team-internal-1", organizationId: "org-1" }); + mocks.getTeam.mockResolvedValue({ id: "team-internal-1", organizationId: "org-1" }); usageForOrganization.mockResolvedValue({ teams: 1, subscribedContacts: 10, @@ -51,21 +298,23 @@ describe("MCP get_plan_usage", () => { subscribedContactsLimit: 1000, monthlySendsLimit: 10000, }); - const result = await tool!.handler({ context, args: {}, signal }); + const result = await planTool.handler({ context, args: {}, signal }); // The context carries the internal team id. - expect(getTeam).toHaveBeenCalledWith("team-internal-1"); + expect(mocks.getTeam).toHaveBeenCalledWith("team-internal-1"); expect(usageForOrganization).toHaveBeenCalledWith("org-1"); expect(result).toMatchObject({ plan: "starter", paidThroughAt: "2026-12-01T00:00:00.000Z", monthlySendsLimit: 10000, }); - expect(tool!.outputSchema!.safeParse(result).success).toBe(true); + expect(planTool.outputSchema!.safeParse(result).success).toBe(true); }); it("returns not_found when the team is gone", async () => { - getTeam.mockResolvedValue(null); - await expect(tool!.handler({ context, args: {}, signal })).resolves.toMatchObject({ + mocks.getTeam.mockResolvedValue(null); + await expect( + planTool.handler({ context, args: {}, signal }), + ).resolves.toMatchObject({ error: { code: "not_found" }, }); }); diff --git a/apps/api/src/mcp/tools/teams.ts b/apps/api/src/mcp/tools/teams.ts index 1d0b4927..cc0a55d4 100644 --- a/apps/api/src/mcp/tools/teams.ts +++ b/apps/api/src/mcp/tools/teams.ts @@ -1,15 +1,67 @@ import { billingPlanSchema } from "@sendlit/api-contract"; import { z } from "zod"; -import { getTeam } from "../../team/queries"; +import { createApiKey, deleteApiKey, getApiKeysByTeamId } from "../../apikey/queries"; +import { getOrganizationMembership } from "../../organization/queries"; +import { + archiveTeam, + createTeam, + getTeam, + getTeamByTeamId, + getTeamMembership, + listTeamViewsForUser, + renameTeam, +} from "../../team/queries"; import type { SendLitMcpTool } from "../context"; import { dedicatedTool } from "../tool"; -import { notFound } from "./responses"; +import { isPlanGateError, notFound, planGateError, toolError } from "./responses"; +import { + apiKeySchema, + createdApiKeySchema, + successMessageSchema, + teamSchema, +} from "./schemas"; + +/** Account-level tools act for a person; a team API key has none. */ +const userAuthRequired = () => + toolError("forbidden", { + reason: "user_auth_required", + message: "This tool needs a signed-in user (OAuth), not a team API key.", + }); + +const organizationAdminRequired = () => + toolError("forbidden", { + reason: "organization_admin_required", + message: "Organization administration is required.", + }); -/** Teams and API keys are reachable through the generic `api.*` tools. */ export const teamTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_teams", + operationId: "teams.list", + title: "List Teams", + risk: "read", + description: "Returns all teams the authenticated user belongs to.", + inputSchema: z.object({}), + outputSchema: z.object({ items: z.array(teamSchema) }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context }) { + if (!context.user) return userAuthRequired(); + const teams = await listTeamViewsForUser(context.user.id); + return { + items: teams.map((t) => ({ + teamId: t.teamId, + name: t.name, + organizationId: t.organizationPublicId, + organizationName: t.organizationName, + })), + }; + }, + }), + dedicatedTool({ name: "get_plan_usage", operationId: "billing.organizationPlanUsage", + title: "Get Plan Usage", risk: "read", description: "Returns plan usage and limits for the current team's organization. When cancelAtPeriodEnd is true the paid plan ends at paidThroughAt.", @@ -54,4 +106,160 @@ export const teamTools = (): SendLitMcpTool[] => [ }; }, }), + + dedicatedTool({ + name: "create_team", + operationId: "teams.create", + title: "Create Team", + risk: "write", + description: + "Creates a new team in the authenticated user's default organization. Subject to the parent organization's plan team limit.", + inputSchema: z.object({ + name: z.string().min(1).describe("Team name"), + }), + outputSchema: teamSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const user = context.user; + if (!user?.defaultOrganizationId) return userAuthRequired(); + try { + const membership = await getOrganizationMembership( + user.defaultOrganizationId, + user.id, + ); + if (!membership || !["owner", "admin"].includes(membership.role)) { + return organizationAdminRequired(); + } + const team = await createTeam({ + organizationId: user.defaultOrganizationId, + creatorUserId: user.id, + name: args.name, + }); + return { teamId: team.teamId, name: team.name }; + } catch (error) { + if (isPlanGateError(error)) return planGateError(error); + throw error; + } + }, + }), + + dedicatedTool({ + name: "rename_team", + operationId: "teams.rename", + title: "Rename Team", + risk: "write", + description: "Renames the current team.", + inputSchema: z.object({ + name: z.string().min(1).describe("New team name"), + }), + outputSchema: teamSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const updated = await renameTeam(context.teamId, args.name); + if (!updated) return notFound(); + return { teamId: updated.teamId, name: updated.name }; + }, + }), + + dedicatedTool({ + name: "delete_team", + operationId: "teams.remove", + title: "Delete Team", + risk: "destructive", + description: "Archives a team. Organization administration is required.", + inputSchema: z.object({ + teamId: z.string().describe("Team ID"), + }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const user = context.user; + if (!user) return userAuthRequired(); + const team = await getTeamByTeamId(args.teamId); + if (!team) return notFound(); + const membership = await getTeamMembership(team.id, user.id); + if (!membership) return notFound(); + const organizationMembership = await getOrganizationMembership( + team.organizationId, + user.id, + ); + if ( + !organizationMembership || + !["owner", "admin"].includes(organizationMembership.role) + ) { + return organizationAdminRequired(); + } + await archiveTeam(team.id); + return { message: "Team archived." }; + }, + }), + + dedicatedTool({ + name: "list_api_keys", + operationId: "teams.listKeys", + title: "List API Keys", + risk: "read", + description: + "Returns all API keys for the current team. Secrets are stored hashed, so only each key's display prefix is included.", + inputSchema: z.object({}), + outputSchema: z.object({ items: z.array(apiKeySchema) }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ context }) { + const keys = await getApiKeysByTeamId(context.teamId); + return { + items: keys.map((k) => ({ + id: k.teamApiKeyId, + keyPrefix: k.keyPrefix, + name: k.name, + createdAt: k.createdAt, + })), + }; + }, + }), + + dedicatedTool({ + name: "create_api_key", + operationId: "teams.createKey", + title: "Create API Key", + risk: "write", + description: + "Creates a new API key for the current team. The full key value is only returned once — store it securely.", + inputSchema: z.object({ + name: z.string().min(1).describe("Human-readable label for this key"), + }), + outputSchema: createdApiKeySchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const user = context.user; + const { apiKey, secret } = await createApiKey(context.teamId, args.name, { + createdByType: user ? "user" : "system", + createdById: user?.id, + }); + return { + id: apiKey.teamApiKeyId, + key: secret, + keyPrefix: apiKey.keyPrefix, + name: apiKey.name, + createdAt: apiKey.createdAt, + }; + }, + }), + + dedicatedTool({ + name: "delete_api_key", + operationId: "teams.removeKey", + title: "Delete API Key", + risk: "destructive", + description: + "Permanently deletes an API key. Any integrations using it will stop working immediately.", + inputSchema: z.object({ + keyId: z.string().describe("Id of the API key to delete (from list_api_keys)"), + }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + await deleteApiKey(context.teamId, args.keyId); + return { message: "API key deleted." }; + }, + }), ]; diff --git a/apps/api/src/mcp/tools/templates.test.ts b/apps/api/src/mcp/tools/templates.test.ts index 8544b57c..05593f44 100644 --- a/apps/api/src/mcp/tools/templates.test.ts +++ b/apps/api/src/mcp/tools/templates.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; const mocks = { createTemplate: vi.fn(), + deleteTemplate: vi.fn(), + duplicateTemplate: vi.fn(), getTemplate: vi.fn(), listTemplates: vi.fn(), updateTemplate: vi.fn(), @@ -9,6 +11,8 @@ const mocks = { mock.module("../../templates/queries", () => ({ createTemplate: mocks.createTemplate, + deleteTemplate: mocks.deleteTemplate, + duplicateTemplate: mocks.duplicateTemplate, getTemplate: mocks.getTemplate, listTemplates: mocks.listTemplates, updateTemplate: mocks.updateTemplate, @@ -23,8 +27,6 @@ const teamKey = { user: null, scopes: [], requestId: "", - clientIp: "", - replayHeaders: {}, }; const tools = new Map(templateTools().map((tool) => [tool.name, tool])); @@ -52,13 +54,68 @@ beforeEach(() => { }); describe("MCP template tools", () => { - it("keeps only the dedicated template tools", () => { + it("has one titled tool per template operation", () => { expect([...tools.keys()]).toEqual([ + "list_system_templates", "list_templates", "get_template", "create_template", "update_template", + "duplicate_template", + "delete_template", ]); + for (const tool of tools.values()) expect(tool.title).toBeTruthy(); + const remove = tools.get("delete_template")!; + expect(remove.risk).toBe("destructive"); + expect((remove.inputSchema as any).shape.confirm).toBeDefined(); + expect(String(tools.get("create_template")!.description)).not.toContain("api."); + }); + + it("lists system starters, optionally by purpose", async () => { + const all: any = await call("list_system_templates", {}); + expect(all.items.length).toBeGreaterThan(0); + const transactional: any = await call("list_system_templates", { + purpose: "transactional", + }); + expect(transactional.items.length).toBeGreaterThan(0); + expect( + transactional.items.every((item: any) => item.purpose === "transactional"), + ).toBe(true); + }); + + it("duplicates and deletes templates of the caller's team", async () => { + mocks.duplicateTemplate.mockResolvedValueOnce({ ...template, templateId: "tpl_2" }); + await expect( + call("duplicate_template", { templateId: "tpl_1" }), + ).resolves.toMatchObject({ templateId: "tpl_2", purpose: "transactional" }); + expect(mocks.duplicateTemplate).toHaveBeenCalledWith({ + teamId: "team-1", + templateId: "tpl_1", + title: undefined, + }); + + mocks.duplicateTemplate.mockResolvedValueOnce(null); + await expect( + call("duplicate_template", { templateId: "tpl_x" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + + mocks.duplicateTemplate.mockRejectedValueOnce( + new TemplateValidationError("marketing_variables_not_allowed", ["contact.email"]), + ); + await expect( + call("duplicate_template", { templateId: "tpl_1" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "marketing_variables_not_allowed" }, + }, + }); + + mocks.deleteTemplate.mockResolvedValueOnce(undefined); + await expect( + call("delete_template", { templateId: "tpl_1", confirm: true }), + ).resolves.toEqual({ message: "Template deleted." }); + expect(mocks.deleteTemplate).toHaveBeenCalledWith("team-1", "tpl_1"); }); it("keeps template ID inputs non-empty", () => { diff --git a/apps/api/src/mcp/tools/templates.ts b/apps/api/src/mcp/tools/templates.ts index 47e78e6a..b431939e 100644 --- a/apps/api/src/mcp/tools/templates.ts +++ b/apps/api/src/mcp/tools/templates.ts @@ -2,16 +2,19 @@ import { emailContentInputSchema, templatePurposeSchema } from "@sendlit/api-con import { z } from "zod"; import { createTemplate, + deleteTemplate, + duplicateTemplate, getTemplate, listTemplates, updateTemplate, } from "../../templates/queries"; +import { SYSTEM_TEMPLATES } from "../../templates/system-templates"; import { TemplateValidationError } from "../../templates/validation"; import { omitInternal } from "../../utils/public"; import type { SendLitMcpTool } from "../context"; import { dedicatedTool } from "../tool"; import { notFound, type ToolError, toolError } from "./responses"; -import { templateSchema } from "./schemas"; +import { successMessageSchema, systemTemplateSchema, templateSchema } from "./schemas"; /** Template content the purpose's rules reject, with the variables at fault * (comma-separated, as safe details are flat). */ @@ -23,8 +26,28 @@ function templateValidationError(error: TemplateValidationError): ToolError { } export const templateTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "list_system_templates", + title: "List System Templates", + operationId: "templates.listSystem", + risk: "read", + description: + "Returns built-in marketing and transactional starting templates. System templates are starters and must be copied into a team-owned template before transactional sending.", + inputSchema: z.object({ purpose: templatePurposeSchema.optional() }), + outputSchema: z.object({ items: z.array(systemTemplateSchema) }), + annotations: { idempotentHint: true, openWorldHint: false }, + async handler({ args }) { + return { + items: args.purpose + ? SYSTEM_TEMPLATES.filter((template) => template.purpose === args.purpose) + : SYSTEM_TEMPLATES, + }; + }, + }), + dedicatedTool({ name: "list_templates", + title: "List Templates", operationId: "templates.list", risk: "read", description: @@ -40,6 +63,7 @@ export const templateTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "get_template", + title: "Get Template", operationId: "templates.get", risk: "read", description: "Returns a single email template by its template ID.", @@ -55,6 +79,7 @@ export const templateTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "create_template", + title: "Create Template", operationId: "templates.create", risk: "write", description: @@ -63,7 +88,7 @@ export const templateTools = (): SendLitMcpTool[] => [ title: z.string().min(1), purpose: templatePurposeSchema, content: emailContentInputSchema.describe( - "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, or footer blocks. The safest valid starting point is a system template: list them with api.read (operation templates.listSystem) and copy one with api.write (templates.duplicate).", + "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, or footer blocks. Use list_system_templates and duplicate_template as the safest valid starting point.", ), }), outputSchema: templateSchema, @@ -88,6 +113,7 @@ export const templateTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "update_template", + title: "Update Template", operationId: "templates.update", risk: "write", description: "Updates a template's title and/or content.", @@ -122,4 +148,51 @@ export const templateTools = (): SendLitMcpTool[] => [ } }, }), + + dedicatedTool({ + name: "duplicate_template", + title: "Duplicate Template", + operationId: "templates.duplicate", + risk: "write", + description: + "Copies a system or team template into a new team-owned template with the same purpose as the source.", + inputSchema: z.object({ + templateId: z.string().min(1), + title: z.string().min(1).optional(), + }), + outputSchema: templateSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const template = await duplicateTemplate({ + teamId: context.teamId, + templateId: args.templateId, + title: args.title, + }); + if (!template) return notFound(); + return omitInternal(template); + } catch (error) { + if (error instanceof TemplateValidationError) { + return templateValidationError(error); + } + throw error; + } + }, + }), + + dedicatedTool({ + name: "delete_template", + title: "Delete Template", + operationId: "templates.remove", + risk: "destructive", + description: + "Permanently deletes an email template. Its images that no other template or email uses are deleted from the media library, unless a transactional email was sent with it.", + inputSchema: z.object({ templateId: z.string().min(1) }), + outputSchema: successMessageSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + await deleteTemplate(context.teamId, args.templateId); + return { message: "Template deleted." }; + }, + }), ]; diff --git a/apps/api/src/mcp/tools/tracking-domain.ts b/apps/api/src/mcp/tools/tracking-domain.ts new file mode 100644 index 00000000..a8e3af70 --- /dev/null +++ b/apps/api/src/mcp/tools/tracking-domain.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; +import { + getTrackingDomain, + setTrackingDomain, + toPublicTrackingDomain, + verifyTrackingDomain, +} from "../../settings/tracking-domain/queries"; +import type { SendLitMcpContext, SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { type ToolError, toolError } from "./responses"; +import { trackingDomainSchema } from "./schemas"; + +/** The tracking-domain codes REST answers with; anything else is thrown. */ +const TRACKING_DOMAIN_ERRORS: Record< + string, + [Parameters[0], string] +> = { + tracking_domain_invalid: [ + "validation_failed", + "Use a subdomain you control, such as track.example.com.", + ], + tracking_domain_taken: ["conflict", "Another team already uses this domain."], + tracking_domain_not_set: ["validation_failed", "Set a tracking domain first."], +}; + +function trackingDomainError(err: unknown): ToolError { + const code = err instanceof Error ? err.message : ""; + const known = TRACKING_DOMAIN_ERRORS[code]; + if (!known) throw err; + return toolError(known[0], { reason: code, message: known[1] }); +} + +async function current(context: SendLitMcpContext) { + return toPublicTrackingDomain(await getTrackingDomain(context.teamId)); +} + +export const trackingDomainTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "get_tracking_domain", + title: "Get Tracking Domain", + operationId: "settings.trackingDomain.get", + risk: "read", + description: + "Returns the team's custom tracking domain: the domain, its status (`pending`, `verified` or `failed`), the CNAME target, and why the last check failed. Open, click, unsubscribe and \"view in browser\" links use the domain once it is verified; otherwise SendLit's own host.", + inputSchema: z.object({}), + outputSchema: trackingDomainSchema, + annotations: { idempotentHint: true, openWorldHint: false }, + handler: ({ context }) => current(context), + }), + + dedicatedTool({ + name: "set_tracking_domain", + title: "Set Tracking Domain", + operationId: "settings.trackingDomain.update", + risk: "write", + description: + "Sets or removes the team's custom tracking domain. Use a subdomain such as `track.example.com` and add a CNAME record pointing to the returned `cnameTarget`; the domain is `pending` until verified (SendLit rechecks hourly, or call verify_tracking_domain). `null` removes it. Links already sent on a previous domain keep working. Errors: `tracking_domain_invalid`, `tracking_domain_taken`.", + inputSchema: z.object({ + domain: z + .string() + .max(253) + .nullable() + .describe("e.g. track.example.com; null removes it"), + }), + outputSchema: trackingDomainSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + return toPublicTrackingDomain( + await setTrackingDomain(context.teamId, args.domain), + ); + } catch (err) { + return trackingDomainError(err); + } + }, + }), + + dedicatedTool({ + name: "verify_tracking_domain", + title: "Verify Tracking Domain", + operationId: "settings.trackingDomain.verify", + risk: "write", + description: + "Checks the team's custom tracking domain now: its CNAME record and that SendLit answers over HTTPS on it. Returns the updated status. Error: `tracking_domain_not_set`.", + inputSchema: z.object({}), + outputSchema: trackingDomainSchema, + annotations: { idempotentHint: true, openWorldHint: true }, + async handler({ context }) { + try { + return toPublicTrackingDomain(await verifyTrackingDomain(context.teamId)); + } catch (err) { + return trackingDomainError(err); + } + }, + }), +]; diff --git a/apps/api/src/mcp/tools/transactional.test.ts b/apps/api/src/mcp/tools/transactional.test.ts index c29be265..f96360a7 100644 --- a/apps/api/src/mcp/tools/transactional.test.ts +++ b/apps/api/src/mcp/tools/transactional.test.ts @@ -26,8 +26,6 @@ const context = { user: null, scopes: [] as string[], requestId: "", - clientIp: "", - replayHeaders: {}, }; const tools = new Map(transactionalTools().map((tool) => [tool.name, tool])); const call = (name: string, args: Record, ctx: any = context) => diff --git a/apps/api/src/mcp/tools/transactional.ts b/apps/api/src/mcp/tools/transactional.ts index a9c9833d..a53f4fa6 100644 --- a/apps/api/src/mcp/tools/transactional.ts +++ b/apps/api/src/mcp/tools/transactional.ts @@ -57,6 +57,7 @@ const SEND_ERRORS: Record[0], string]> = { export const transactionalTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "send_email", + title: "Send Email", operationId: "transactional.send", risk: "write", description: @@ -146,6 +147,7 @@ export const transactionalTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "get_email", + title: "Get Email", operationId: "transactional.get", risk: "read", description: @@ -162,6 +164,7 @@ export const transactionalTools = (): SendLitMcpTool[] => [ dedicatedTool({ name: "list_emails", + title: "List Emails", operationId: "transactional.list", risk: "read", description: diff --git a/apps/api/src/mcp/tools/web.test.ts b/apps/api/src/mcp/tools/web.test.ts new file mode 100644 index 00000000..90f8fa37 --- /dev/null +++ b/apps/api/src/mcp/tools/web.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +class WebPublishingError extends Error { + constructor( + readonly status: 400 | 404 | 409, + code: string, + ) { + super(code); + } +} + +const mocks = { + getIssue: vi.fn(), + getWebSettings: vi.fn(), + listIssues: vi.fn(), + toWebPublishing: vi.fn(), + updateWebPublishing: vi.fn(), + updateWebSettings: vi.fn(), + getSequenceBySequenceId: vi.fn(), +}; + +mock.module("../../web/queries", () => ({ + getIssue: mocks.getIssue, + getWebSettings: mocks.getWebSettings, + listIssues: mocks.listIssues, + toWebPublishing: mocks.toWebPublishing, + updateWebPublishing: mocks.updateWebPublishing, + updateWebSettings: mocks.updateWebSettings, + WebPublishingError, +})); +// bun shares module mocks across test files in one process, so this mock +// exports everything the sequence tools import too. +mock.module("../../sequences/queries", () => ({ + addMailToSequence: vi.fn(), + countSequences: vi.fn(), + createSequence: vi.fn(), + deleteMailFromSequence: vi.fn(), + deleteSequence: vi.fn(), + getEmailSentCount: vi.fn(), + getSequenceBySequenceId: mocks.getSequenceBySequenceId, + getSequenceClickThroughRate: vi.fn(), + getSequenceDeletionImpact: vi.fn(), + getSequenceOpenRate: vi.fn(), + getSequenceSchedule: vi.fn(), + getSubscribers: vi.fn(), + getSubscribersCount: vi.fn(), + getWaitingContacts: vi.fn(), + InvalidCursorError: class InvalidCursorError extends Error {}, + listSequences: vi.fn(), + pauseSequence: vi.fn(), + setBroadcastArchived: vi.fn(), + startSequence: vi.fn(), + updateMailInSequence: vi.fn(), + updateSequence: vi.fn(), +})); + +const { webTools } = await import("./web"); + +const teamKey = { + authKind: "team_key" as const, + teamId: "team-1", + user: null, + scopes: [], + requestId: "", +}; + +const tools = new Map(webTools().map((tool) => [tool.name, tool])); + +function call(name: string, args: Record, context: any = teamKey) { + const tool = tools.get(name); + if (!tool) throw new Error(`unknown tool ${name}`); + return tool.handler({ context, args, signal: new AbortController().signal }); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe("MCP web archive tools", () => { + it("has one titled tool per web operation", () => { + expect([...tools.keys()]).toEqual([ + "get_broadcast_web_publishing", + "update_broadcast_web_publishing", + "get_web_settings", + "update_web_settings", + "list_web_issues", + "get_web_issue", + ]); + for (const tool of tools.values()) { + expect(tool.title).toBeTruthy(); + expect(tool.risk).not.toBe("destructive"); + } + }); + + it("reads and updates a broadcast's web publishing for the caller's team", async () => { + const sequence = { sequenceId: "seq-1", type: "broadcast" }; + mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); + await expect( + call("get_broadcast_web_publishing", { sequenceId: "seq-x" }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + expect(mocks.getSequenceBySequenceId).toHaveBeenCalledWith("team-1", "seq-x"); + + mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); + mocks.toWebPublishing.mockResolvedValueOnce({ published: false, slug: null }); + await expect( + call("get_broadcast_web_publishing", { sequenceId: "seq-1" }), + ).resolves.toEqual({ published: false, slug: null }); + + mocks.getSequenceBySequenceId.mockResolvedValueOnce(sequence); + mocks.updateWebPublishing.mockResolvedValueOnce({ published: true, slug: "hello" }); + await expect( + call("update_broadcast_web_publishing", { + sequenceId: "seq-1", + published: true, + slug: "hello", + }), + ).resolves.toEqual({ published: true, slug: "hello" }); + expect(mocks.updateWebPublishing).toHaveBeenCalledWith(sequence, { + published: true, + slug: "hello", + }); + }); + + it("keeps REST's status and code for publishing refusals", async () => { + mocks.getSequenceBySequenceId.mockResolvedValue({ sequenceId: "seq-1" }); + mocks.updateWebPublishing.mockRejectedValueOnce( + new WebPublishingError(409, "slug_taken"), + ); + await expect( + call("update_broadcast_web_publishing", { sequenceId: "seq-1", slug: "taken" }), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "slug_taken" } }, + }); + + mocks.updateWebSettings.mockRejectedValueOnce( + new WebPublishingError(400, "signup_form_not_found"), + ); + await expect( + call("update_web_settings", { enabled: true, signupFormId: "frm_x" }), + ).resolves.toMatchObject({ + error: { + code: "validation_failed", + safeDetails: { reason: "signup_form_not_found" }, + }, + }); + + mocks.updateWebSettings.mockRejectedValueOnce(new Error("db down")); + await expect(call("update_web_settings", { enabled: true })).rejects.toThrow( + "db down", + ); + }); + + it("reads the team's settings and published issues", async () => { + mocks.getWebSettings.mockResolvedValueOnce({ enabled: true, handle: "news" }); + await expect(call("get_web_settings", {})).resolves.toEqual({ + enabled: true, + handle: "news", + }); + expect(mocks.getWebSettings).toHaveBeenCalledWith("team-1"); + + mocks.listIssues.mockResolvedValueOnce({ items: [], total: 0 }); + await expect(call("list_web_issues", {})).resolves.toEqual({ items: [], total: 0 }); + expect(mocks.listIssues).toHaveBeenCalledWith("team-1", expect.any(Object)); + + mocks.getIssue.mockResolvedValueOnce(null); + await expect(call("get_web_issue", { slug: "nope" })).resolves.toMatchObject({ + error: { code: "not_found" }, + }); + expect(mocks.getIssue).toHaveBeenCalledWith("team-1", "nope"); + }); + + it("refuses an OAuth token without settings:write", async () => { + await expect( + call( + "update_web_settings", + { enabled: true }, + { ...teamKey, authKind: "oauth", scopes: ["settings:read"] }, + ), + ).resolves.toMatchObject({ + error: { + code: "forbidden", + safeDetails: { reason: "insufficient_scope", requiredScope: "settings:write" }, + }, + }); + expect(mocks.updateWebSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/mcp/tools/web.ts b/apps/api/src/mcp/tools/web.ts new file mode 100644 index 00000000..5d2358ea --- /dev/null +++ b/apps/api/src/mcp/tools/web.ts @@ -0,0 +1,158 @@ +import { + listWebIssuesQuerySchema, + updateWebPublishingBodySchema, + updateWebSettingsBodySchema, + webIssueSchema, + webIssueSummarySchema, + webPublishingSchema, + webSettingsSchema, +} from "@sendlit/api-contract"; +import { z } from "zod"; +import { getSequenceBySequenceId } from "../../sequences/queries"; +import { + getIssue, + getWebSettings, + listIssues, + toWebPublishing, + updateWebPublishing, + updateWebSettings, + WebPublishingError, +} from "../../web/queries"; +import type { SendLitMcpTool } from "../context"; +import { dedicatedTool } from "../tool"; +import { notFound, type ToolError, toolError } from "./responses"; + +const readOnly = { idempotentHint: true, openWorldHint: false }; + +const WEB_ERROR_CODES = { + 400: "validation_failed", + 404: "not_found", + 409: "conflict", +} as const; + +/** A web publishing refusal keeps REST's status and code (`slug_taken`, + * `handle_taken`, `only_broadcasts_can_be_published`, ...) as the reason. */ +async function run(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof WebPublishingError) { + return toolError(WEB_ERROR_CODES[error.status], { reason: error.message }); + } + throw error; + } +} + +export const webTools = (): SendLitMcpTool[] => [ + dedicatedTool({ + name: "get_broadcast_web_publishing", + title: "Get Broadcast Web Publishing", + operationId: "sequences.getWeb", + risk: "read", + description: + "Returns whether a broadcast is published as a public web page, its slug, SEO fields and public URL. `live` is true once it's published and sent.", + inputSchema: z.object({ sequenceId: z.string().min(1) }), + outputSchema: webPublishingSchema, + annotations: readOnly, + async handler({ context, args }) { + const sequence = await getSequenceBySequenceId(context.teamId, args.sequenceId); + if (!sequence) return notFound(); + return toWebPublishing(sequence); + }, + }), + + dedicatedTool({ + name: "update_broadcast_web_publishing", + title: "Update Broadcast Web Publishing", + operationId: "sequences.updateWeb", + risk: "write", + description: + "Publishes a broadcast as a public web page (it goes live once sent) or unpublishes it, and sets its slug, SEO title/description, social image, noindex and canonical URL. Only broadcasts can be published. The page has no personal data, tracking or unsubscribe links.", + inputSchema: z.object({ + sequenceId: z.string().min(1), + ...updateWebPublishingBodySchema.shape, + }), + outputSchema: webPublishingSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const { sequenceId, ...input } = args; + const sequence = await getSequenceBySequenceId(context.teamId, sequenceId); + if (!sequence) return notFound(); + return run(() => + updateWebPublishing( + sequence, + input as Parameters[1], + ), + ); + }, + }), + + dedicatedTool({ + name: "get_web_settings", + title: "Get Web Archive Settings", + operationId: "settings.web.get", + risk: "read", + description: + "Returns the team's web archive settings: whether SendLit hosts the archive and issue pages, the handle, title, description, signup form and the archive and RSS URLs.", + inputSchema: z.object({}), + outputSchema: webSettingsSchema, + annotations: readOnly, + async handler({ context }) { + return getWebSettings(context.teamId); + }, + }), + + dedicatedTool({ + name: "update_web_settings", + title: "Update Web Archive Settings", + operationId: "settings.web.update", + risk: "write", + description: + "Updates the team's web archive. Turning `enabled` on hosts the archive at /w/ and a page per published issue; a handle and a signup form are created when missing. Turn it off when a platform renders the issues itself through list_web_issues/get_web_issue.", + inputSchema: updateWebSettingsBodySchema, + outputSchema: webSettingsSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + return run(() => + updateWebSettings( + context.teamId, + args as Parameters[1], + ), + ); + }, + }), + + dedicatedTool({ + name: "list_web_issues", + title: "List Web Issues", + operationId: "web.list", + risk: "read", + description: + "Lists the team's published issues (broadcasts published to the web and sent), newest first, with slug, title, date, excerpt and SEO fields.", + inputSchema: listWebIssuesQuerySchema, + outputSchema: z.object({ + items: z.array(webIssueSummarySchema), + total: z.number(), + }), + annotations: readOnly, + async handler({ context, args }) { + return listIssues(context.teamId, args as Parameters[1]); + }, + }), + + dedicatedTool({ + name: "get_web_issue", + title: "Get Web Issue", + operationId: "web.get", + risk: "read", + description: + "Returns one published issue by slug with its HTML (no personal data, tracking or unsubscribe links), for rendering on another site.", + inputSchema: z.object({ slug: z.string().min(1) }), + outputSchema: webIssueSchema, + annotations: readOnly, + async handler({ context, args }) { + const issue = await getIssue(context.teamId, args.slug); + return issue ?? notFound(); + }, + }), +]; diff --git a/apps/docs/content/docs/activity/suppressions.mdx b/apps/docs/content/docs/activity/suppressions.mdx index 10c5e757..1c987bd4 100644 --- a/apps/docs/content/docs/activity/suppressions.mdx +++ b/apps/docs/content/docs/activity/suppressions.mdx @@ -21,5 +21,5 @@ address later creates an unsubscribed contact. Use **Suppressions → Add suppression** to block an address, for example a role account, a known spam trap, or someone who asked by email not to be contacted. Over the API, `POST /suppressions` with `{ "email": "...", "note": "..." }`; the -MCP clients run `suppressions.create` with `api.write`. A manual suppression can be released later; a +MCP tool is `add_suppression`. A manual suppression can be released later; a stronger suppression already on the address (such as a complaint) is kept. diff --git a/apps/docs/content/docs/developers/authentication.mdx b/apps/docs/content/docs/developers/authentication.mdx index 49eaadcb..66b63191 100644 --- a/apps/docs/content/docs/developers/authentication.mdx +++ b/apps/docs/content/docs/developers/authentication.mdx @@ -39,7 +39,7 @@ API keys look like `sl_live_...` followed by a random secret, and are sent via t Keys are stored hashed (SHA-256), which means: -- The full key is shown **exactly once**, in the response that creates it (`POST /teams/:teamId/keys`, the `teams.createKey` operation through the MCP `api.write` tool, or the provisioning call that first creates a team). Store it securely at that moment — it cannot be retrieved later. +- The full key is shown **exactly once**, in the response that creates it (`POST /teams/:teamId/keys`, the `create_api_key` MCP tool, or the provisioning call that first creates a team). Store it securely at that moment — it cannot be retrieved later. - Key listings only include each key's `keyPrefix` (the first few characters, e.g. `sl_live_a1b2`) so you can tell keys apart. - Keys are revoked by their `id` (`DELETE /teams/:teamId/keys/:keyId`), not by the secret. diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index 89b0d4ae..e9b0e97b 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -44,9 +44,9 @@ OAuth authorization, consent, token, introspection, revocation, and userinfo end Use Authorization Code with S256 PKCE, request only the scopes your client needs, and keep any refresh token in the client's secure credential store. The authorization request must include an explicit, non-empty `scope`; SendLit does not turn a missing value into a full-access grant. Every authorization requires a SendLit user to sign in, select a team when necessary, and approve consent. -Team API keys always select one fixed team and can run every MCP tool and -operation for that team, except API key management (`teams.listKeys`, -`teams.createKey`, `teams.removeKey`), which requires OAuth. +Team API keys always select one fixed team and currently grant the complete +MCP tool set for that team, except the API key management tools +(`list_api_keys`, `create_api_key`, `delete_api_key`), which require OAuth. Organization API keys, dashboard session cookies, and `X-Sendlit-Team-Id` are not accepted as MCP authentication. Use the REST API for organization discovery, shared-delivery setup, and team provisioning; see @@ -58,7 +58,7 @@ OAuth clients (including MCP clients like Claude) have no way to send a custom h ### OAuth scopes -OAuth access is default-deny per REST operation: a dedicated tool and an `api.*` call to the same operation need the same scope, and `api.list_operations` shows which scope each operation needs and whether your token has it. Read and write permissions are separate: +OAuth access is default-deny: every tool needs the scope of the REST operation it performs. Read and write permissions are separate: - `contacts:read`, `contacts:write` - `templates:read`, `templates:write` @@ -75,40 +75,106 @@ OAuth access is default-deny per REST operation: a dedicated tool and an `api.*` ## Available tools -SendLit follows the CodeLit pattern: dedicated tools for the everyday flows, and five generic tools that reach every other team operation of the [REST API](/developers/api). A client loads a short catalog (34 tools), and nothing the REST API offers a team is out of reach. +### Contacts and segments -### Dedicated tools +- `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `delete_contact` +- `preview_delete_contacts`, `delete_contacts` (bulk, by ids or by search/segment/filter; requires the previewed `expectedCount` for a match) and `get_contact_deletion` (progress of a large background deletion). See [Delete contacts](/email-marketing/contacts#delete-contacts). +- `add_contact_tag`, `remove_contact_tag`, `get_contact_deliveries` +- `import_contacts` (up to 1000 rows per call, statuses only get stricter) and `export_contacts` (CSV) +- `list_segments`, `get_segment`, `create_segment`, `update_segment`, `delete_segment` +- `list_tags` (with contact counts), `get_tag_references`, `rename_tag`, `delete_tag` (team-wide; contacts scopes) +- `list_signup_forms`, `get_signup_form`, `create_signup_form`, `update_signup_form`, `delete_signup_form` (public [signup forms](/email-marketing/signup-forms); contacts scopes) -- **Contacts:** `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `add_contact_tag`, `remove_contact_tag`, `import_contacts` (up to 1000 rows per call; statuses only get stricter) -- **Segments:** `list_segments`, `create_segment`. Segments save reusable filters over contact fields, tags, subscription state, signup date and custom fields. -- **Templates:** `list_templates`, `get_template`, `create_template`, `update_template` -- **Broadcasts and sequences:** `list_sequences`, `get_sequence`, `create_sequence`, `update_sequence`, `add_sequence_email`, `update_sequence_email`, `send_sequence_email_test`, `start_sequence`, `pause_sequence`, `get_sequence_stats` -- **Transactional email:** `send_email`, `get_email`, `list_emails` -- **Events and delivery:** `track_event`, `list_delivery_events`, `get_plan_usage` +Segments save reusable filters over contact fields, tags, subscription state, signup date, and custom fields. -Broadcasts use the sequence resource with one email and an audience filter. `update_sequence` sets a sequence's trigger, including `event:custom` with a `triggerFilter`, who can enter (`reentry`, `reentryPeriodDays`), and an `excludeFilter` whose contacts are never sent the broadcast or enrolled; see [Sequences](/email-marketing/sequences#custom-events). Send a test with `send_sequence_email_test` (a `[Test]` copy to you, or up to five addresses) and use `start_sequence` only after the audience, trigger, content, delays and tag actions are correct. +### Custom events -Template and sequence-email content is validated as a complete SendLit email document on every write. The `style` must include `colors`, `typography.header`, `typography.text`, `typography.link`, `interactives` and `structure`; blocks must be one of text, link, image, separator or footer. Metadata supports only optional `previewText` and an optional `utm` object with `source`, `medium` and `campaign` strings. For AI-created content, start from a system template (`templates.listSystem`, then `templates.duplicate` through the `api.*` tools) rather than constructing a document from scratch. +- `track_event` records a [custom event](/developers/events) for a contact and starts the sequences it triggers. It takes the `POST /events` body, including `createContact`, `contact`, `idempotencyKey` and `backfill`. +- `list_event_definitions`, `get_event_definition`, `create_event_definition`, `update_event_definition`, `delete_event_definition` +- `get_contact_events` lists a contact's events, newest first, with the sequences each one started. -`send_email` sends one API-triggered email, accepts only team-owned transactional templates, and reports `template_not_transactional` or the missing variable paths without creating a send. Broadcast and sequence tools accept only marketing templates. `track_event` records a [custom event](/developers/events) and starts the sequences it triggers; send production events through the REST API, which allows far more requests per minute. +Event tools use the contacts scopes: `contacts:read` for reads and `contacts:write` for `track_event` and definition changes. `track_event` is for agents and people trying things out; send production events through the REST API, which allows far more requests per minute. -### Generic API tools +### Templates and media -Everything else a team can do over REST runs through these tools: sending providers (ESPs) and their delivery feedback, suppressions, [sender reputation](/email-marketing/reputation), the [tracking domain](/email-marketing/tracking-domain), general and [web archive](/email-marketing/web-archive) settings, [signup forms](/email-marketing/signup-forms), media, tags, segment and template management, sequence schedules, subscribers and archiving, event definitions, contact exports and deliveries, teams and API keys, and every delete. +- `list_system_templates`, `list_templates`, `get_template`, `create_template`, `update_template`, `duplicate_template`, `delete_template` +- `list_media`, `get_media`, `update_media`, `delete_media`, `list_media_references` -1. `api.list_operations` lists the operations, filtered by `search` or `group`. Each item has its `operationId`, method and path, the tool that runs it, the OAuth scope it needs, and whether this connection may run it. -2. `api.describe_operation` returns an operation's path and query parameters, the JSON Schema of its request body, and the schema of a successful response (from [`/openapi.json`](/developers/api)). -3. `api.read` runs a read operation, `api.write` an operation that creates or changes data or has an external effect (such as testing an ESP), and `api.delete` one that deletes or permanently disables something. Each takes `operationId`, `pathParams`, `query` and, for writes, `body`. `api.delete` also needs `confirm: true`: confirm the target with the person first. +Templates and media are scoped to the selected team. Template list tools accept +an optional `purpose` filter; `create_template` requires a purpose and returns +the computed `requiredVariables`. System templates expose transactional +variable descriptions and examples, but are starters rather than team-owned +sendable templates. Use `duplicate_template` to copy or safely convert one. -A call runs exactly as the REST request would, for the connected team, with the same validation, plan limits and errors. For example, to retire an ESP: find `settings.esps.retire` with `api.list_operations`, check its parameters with `api.describe_operation`, then call `api.delete` with `{ "operationId": "settings.esps.retire", "pathParams": { "espId": "esp_…" }, "confirm": true }`. +Template and sequence-email content is validated as a complete SendLit email +document on every write. The `style` must include `colors`, +`typography.header`, `typography.text`, `typography.link`, `interactives`, and +`structure`; blocks must be one of text, link, image, separator, or footer. +Metadata supports only optional `previewText` and an optional `utm` object with +`source`, `medium`, and `campaign` strings. +For AI-created content, start with `list_system_templates` and +`duplicate_template` rather than constructing a document shape from scratch. -Billing, account deletion, organization administration and team provisioning are not reachable over MCP; use the REST API with a dashboard session or an organization key. +### Broadcasts and sequences -### Errors +Broadcasts use the sequence resource with one email and an audience filter. For sequences, `update_sequence` sets the trigger, including `event:custom` with a `triggerFilter`, and who can enter (`reentry`, `reentryPeriodDays`); see [Sequences](/email-marketing/sequences#custom-events). It also takes an `excludeFilter`; contacts matching it are never sent the broadcast or enrolled in the sequence. Set `viewInBrowser: false` to send without the "View in browser" link and take down the web versions already linked. Sequence tools include: -A failed tool call is a tool result with `isError: true` and `structuredContent` of `{ code, message, details }`. `code` is one of `validation_failed`, `not_found`, `conflict`, `forbidden`, `rate_limited`, `unauthenticated` or `internal_error`. `details.reason` carries SendLit's own error code (for example `plan_limit_reached`, `sending_paused`, `template_not_transactional`, `insufficient_scope` or `broadcast_sent`), `details.message` explains it, and plan limits keep the limit, usage and upgrade link REST returns. +- `list_sequences`, `get_sequence`, `create_sequence`, `update_sequence` +- `add_sequence_email`, `update_sequence_email`, `delete_sequence_email` +- `preview_sequence_deletion`, `delete_sequence` (destructive: pause an active sequence first; its enrollments and stats are deleted). Draft and scheduled broadcasts can be deleted; sent ones can't (`broadcast_sent`) +- `archive_broadcast`, `unarchive_broadcast` hide a sent broadcast from the list and the web archive, keeping its report. `list_sequences` takes `archived` (`exclude` by default, `only`, `include`) +- `send_sequence_email_test` sends a `[Test]` copy of one email to you (or up to five addresses) +- `start_sequence`, `pause_sequence`, `get_sequence_stats`, `get_sequence_schedule`, `list_sequence_email_waiting_contacts`, `get_sequence_subscribers` +- `get_broadcast_web_publishing`, `update_broadcast_web_publishing`, `list_web_issues`, `get_web_issue` ([web archive](/email-marketing/web-archive)) -API key management is refused when the caller authenticates with an API key; connect with OAuth to use it. The full API key secret is returned only when the key is created. Store it immediately; it cannot be recovered from a later listing. Delivery-feedback connections are supported for Resend, Postmark, SendGrid, Mailgun and Amazon SES; see [Amazon SES bounces and complaints](/self-hosting/amazon-ses-feedback). Releasing a SendLit suppression does not remove a provider-native suppression. +Send a test with `send_sequence_email_test` and use `start_sequence` only after the audience, trigger, email content, delays, and optional tag actions are correct. Use `pause_sequence` to stop future sequence processing. + +### Transactional email + +- `send_email` sends one API-triggered email. +- `get_email` retrieves one delivery record. +- `list_emails` lists transactional delivery records. + +Transactional sends are separate from broadcasts and sequences; they do not enroll a contact or use a campaign audience. +`send_email` accepts only team-owned transactional templates and reports +`template_not_transactional` or the missing variable paths without creating a +send. Broadcast and sequence tools accept only marketing templates and report +`template_not_marketing`. + +### Sending configuration + +- `get_general_settings`, `update_general_settings`, `get_web_settings`, `update_web_settings` +- `get_tracking_domain`, `set_tracking_domain`, `verify_tracking_domain` ([tracking domain](/email-marketing/tracking-domain)) +- `get_reputation`, `sync_reputation`, `connect_google_postmaster` (returns a sign-in URL for a person to open), `disconnect_google_postmaster`, `connect_snds`, `disconnect_snds` ([sender reputation](/email-marketing/reputation)) +- `get_esp_config`, `update_esp_config`, `delete_esp_config`, `send_test_email` +- `list_esps`, `create_esp`, `get_esp`, `update_esp`, `delete_esp`, `test_esp` +- `check_esp_domain_authentication` (live SPF, DKIM and DMARC check of the From domain, with the records to add) + +The ESP tools manage the team’s sending providers and default provider. Secrets are accepted for writes but are not returned in read responses. + +### Teams and API keys + +- `list_teams`, `create_team`, `rename_team`, `delete_team` +- `get_plan_usage` returns the current team's parent-organization plan, usage, and limits. It never returns provider IDs or portal links. +- `list_api_keys`, `create_api_key`, `delete_api_key` + +Mutating tools such as `create_team`, `create_contact`, `update_contact`, `send_email`, and `start_sequence` return plan-gate errors as a `forbidden` error whose `details.reason` is the REST code (`plan_limit_reached`, `plan_feature_unavailable`, `sending_paused`). Checkout, portal, and invoice tools are not exposed through MCP. + +API key management tools are refused when the caller authenticates with an API key; connect with OAuth to use them. The full API key secret is returned only by `create_api_key`. Store it immediately; it cannot be recovered from a later listing. + +### Delivery feedback and suppressions + +- `get_esp_feedback_connection`, `upsert_esp_feedback_connection`, `test_esp_feedback_connection`, `delete_esp_feedback_connection` +- `list_delivery_events`, `get_delivery_event` +- `list_suppressions`, `get_suppression`, `add_suppression`, `release_suppression` + +Delivery-feedback connections are supported only for providers with a reviewed asynchronous feedback adapter: Resend, Postmark, SendGrid, Mailgun, and Amazon SES. For SES, pass `expectedTopicArn` (the SNS topic subscribed to the webhook URL) to `upsert_esp_feedback_connection` instead of `credential`; see [Amazon SES bounces and complaints](/self-hosting/amazon-ses-feedback). Releasing a SendLit suppression does not remove a provider-native suppression, and complaint suppressions are not releasable through the owner API. + +## Tool results and errors + +Every tool has a title, and its annotations say whether it only reads (`readOnlyHint`), can delete or permanently change data (`destructiveHint`), or reaches outside your team's workspace (`openWorldHint`, for example sending email). Destructive tools such as `delete_contact`, `delete_sequence` or `retire_esp` take a `confirm` argument and refuse to run unless it is `true`: confirm the target with the person first. + +A failed call is a tool result with `isError: true` and `structuredContent` of `{ code, message, details }`. `code` is one of `validation_failed`, `not_found`, `conflict`, `forbidden`, `rate_limited`, `unauthenticated` or `internal_error`. `details.reason` carries SendLit's own error code (for example `plan_limit_reached`, `sending_paused`, `template_not_transactional`, `insufficient_scope` or `broadcast_sent`) and `details.message` explains it; plan limits keep the limit, usage and upgrade link REST returns. ## Example client configuration @@ -131,4 +197,4 @@ Prefer the client’s secure secret-store or environment-variable mechanism. Do ## Operational limits -The MCP endpoint is rate limited to 60 requests per minute. High-impact tools (`send_email`, `send_sequence_email_test`, `track_event`, `start_sequence`, `api.write` and `api.delete`) are limited to 20 requests per minute, and the REST API's own limits apply to `api.*` calls as well. A missing or invalid credential returns HTTP 401 with a `WWW-Authenticate` challenge, a missing OAuth scope returns a `forbidden` tool error with reason `insufficient_scope`, an unsupported protocol revision is rejected, and a non-JSON request returns HTTP 415. Because requests are stateless, retry only operations that are safe to repeat. +The MCP endpoint is rate limited to 60 requests per minute. High-impact tools such as sending email, tracking events (`track_event`), activating sequences, testing ESPs, managing API keys, deleting teams, deleting contacts in bulk (`delete_contacts`), and releasing suppressions are limited to 20 requests per minute. A missing or invalid credential returns HTTP 401 with a `WWW-Authenticate` challenge, a missing OAuth scope returns a `forbidden` tool error with reason `insufficient_scope`, an unsupported protocol revision is rejected, and a non-JSON request returns HTTP 415. Because requests are stateless, retry only operations that are safe to repeat. diff --git a/apps/docs/content/docs/email-marketing/broadcasts.mdx b/apps/docs/content/docs/email-marketing/broadcasts.mdx index d52c4430..f3f403fa 100644 --- a/apps/docs/content/docs/email-marketing/broadcasts.mdx +++ b/apps/docs/content/docs/email-marketing/broadcasts.mdx @@ -138,5 +138,5 @@ page. With the API, `DELETE /sequences/{sequenceId}` deletes, `POST /sequences/{sequenceId}/archive` and `/unarchive` archive and restore, and `GET /sequences?type=broadcast&archived=only` lists archived broadcasts. -MCP clients run these operations (`sequences.remove`, `sequences.archive`, -`sequences.unarchive`) through the generic `api.*` tools. +MCP clients use `delete_sequence`, `archive_broadcast` and +`unarchive_broadcast`. diff --git a/apps/docs/content/docs/email-marketing/contacts.mdx b/apps/docs/content/docs/email-marketing/contacts.mdx index 817825a8..d56010dd 100644 --- a/apps/docs/content/docs/email-marketing/contacts.mdx +++ b/apps/docs/content/docs/email-marketing/contacts.mdx @@ -57,7 +57,7 @@ each row's source `status` as it appears in the export; the MCP tool is CSV: email, name, status (subscribed, unsubscribed, bounced or complained), tags, created_at, suppression_reason and one column per custom field. Over the API use `GET /contacts/export` (optionally with `segmentId` or `filter`); -MCP clients run `contacts.export` with `api.read`. The file can be imported again as is. +the MCP tool is `export_contacts`. The file can be imported again as is. ## Manage tags @@ -76,7 +76,8 @@ contacts have each one. From there you can: Renaming or deleting a tag doesn't start tag-added or tag-removed sequences. The API endpoints are under `/tags` (the Tags section in the API reference); -MCP clients run the `tags.*` operations through the generic `api.*` tools. +the MCP tools are `list_tags`, `get_tag_references`, `rename_tag` and +`delete_tag`. ## Delete contacts @@ -116,9 +117,8 @@ Over the API, `POST /contacts/bulk-delete/preview` returns the count and contact that `match`es a search (`q`), `segmentId` and `filter`. With `match`, pass the preview's count as `expectedCount`; a different live count returns 409 `count_changed`. A large deletion answers 202 with a `deletionId` to poll -at `GET /contacts/bulk-delete/{deletionId}`. MCP clients run -`contacts.previewBulkDelete`, `contacts.bulkDelete` and `contacts.bulkDeleteStatus` -through the generic `api.*` tools. +at `GET /contacts/bulk-delete/{deletionId}`. The MCP tools are +`preview_delete_contacts`, `delete_contacts` and `get_contact_deletion`. ## Segments diff --git a/apps/docs/content/docs/email-marketing/domain-authentication.mdx b/apps/docs/content/docs/email-marketing/domain-authentication.mdx index a33e93d4..0456b92e 100644 --- a/apps/docs/content/docs/email-marketing/domain-authentication.mdx +++ b/apps/docs/content/docs/email-marketing/domain-authentication.mdx @@ -35,7 +35,8 @@ show up. If your provider signs with its own DKIM selector (the part before provider's selectors and common ones. The same check is available as `GET /settings/esps/:espId/domain-authentication` -in the [API](/api-reference) and over MCP through `api.read`. +in the [API](/api-reference) and as the `check_esp_domain_authentication` MCP +tool. ## What each provider needs diff --git a/apps/docs/content/docs/email-marketing/sequences.mdx b/apps/docs/content/docs/email-marketing/sequences.mdx index 1a20e90e..ff45140c 100644 --- a/apps/docs/content/docs/email-marketing/sequences.mdx +++ b/apps/docs/content/docs/email-marketing/sequences.mdx @@ -109,9 +109,9 @@ sequence and haven't had their next email yet. Each email in the list shows how many contacts it goes to next, so a delayed email shows its contacts waiting rather than sent. Click the count to see who they are and when each one is due, soonest first, and search them by email or name: a delay counts from each -contact's own entry (or previous email), so their times differ. `GET /sequences/{sequenceId}/schedule` (`sequences.schedule` over MCP `api.read`) +contact's own entry (or previous email), so their times differ. `GET /sequences/{sequenceId}/schedule` (MCP `get_sequence_schedule`) returns the counts, and `GET /sequences/{sequenceId}/emails/{emailId}/waiting` -(`sequences.waitingContacts` over MCP `api.read`) lists the contacts, with `q` to +(MCP `list_sequence_email_waiting_contacts`) lists the contacts, with `q` to search and `nextCursor` to page. Sequences are lifecycle mail, such as onboarding or end-of-trial emails, so a @@ -157,4 +157,4 @@ To stop a sequence but keep its history, pause it instead. With the API, `GET /sequences/{sequenceId}/deletion-preview` returns the enrolled-contact and sent-email counts, then `DELETE /sequences/{sequenceId}` -deletes it. MCP clients run these operations through the generic `api.*` tools. +deletes it. MCP clients use `preview_sequence_deletion` and `delete_sequence`. diff --git a/apps/docs/content/docs/email-marketing/tracking-domain.mdx b/apps/docs/content/docs/email-marketing/tracking-domain.mdx index 35ede8da..f8ffeb4f 100644 --- a/apps/docs/content/docs/email-marketing/tracking-domain.mdx +++ b/apps/docs/content/docs/email-marketing/tracking-domain.mdx @@ -29,8 +29,8 @@ Once the domain shows **Verified**, new emails use it. Emails already sent keep their links. The API has the same settings: `GET`, `PUT /settings/tracking-domain` and -`POST /settings/tracking-domain/verify`, and the same operations -over MCP through the generic `api.*` tools. +`POST /settings/tracking-domain/verify`, and the MCP tools +`get_tracking_domain`, `set_tracking_domain` and `verify_tracking_domain`. ## Keep the record in place diff --git a/apps/docs/content/docs/email-marketing/warm-up.mdx b/apps/docs/content/docs/email-marketing/warm-up.mdx index 2090e42c..d1f15795 100644 --- a/apps/docs/content/docs/email-marketing/warm-up.mdx +++ b/apps/docs/content/docs/email-marketing/warm-up.mdx @@ -70,8 +70,8 @@ Transactional email is never held back. If you also set a daily send limit, the lower of the two applies. Changing the target keeps the warm-up's progress; turning it off removes the cap. Over -the API, set `warmupTargetDailyVolume` with `PATCH /settings/esps/{espId}` (over MCP, `settings.esps.update` -through `api.write`; `null` turns it off); the ESP's `warmup` field +the API, set `warmupTargetDailyVolume` with `PATCH /settings/esps/{espId}` or +the MCP `update_esp` tool (`null` turns it off); the ESP's `warmup` field shows the day and today's limit. ## Set a daily send limit @@ -85,8 +85,8 @@ never held back. The ESP list shows today's count against the limit. Raise the limit every day or two as long as bounces and complaints stay low, and clear it once you're sending your full volume. You can also set it with -`PATCH /settings/esps/{espId}` (`dailySendLimit`, or `null` to remove it) (over MCP, -`settings.esps.update` through `api.write`). +`PATCH /settings/esps/{espId}` (`dailySendLimit`, or `null` to remove it) or +the MCP `update_esp` tool. ## A sample manual ramp diff --git a/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx b/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx index ff973e08..69f4a813 100644 --- a/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx +++ b/apps/docs/content/docs/self-hosting/amazon-ses-feedback.mdx @@ -56,7 +56,7 @@ Do this **before** subscribing. SendLit rejects messages from topics it doesn't 2. Paste the topic ARN into **SNS topic ARN** and save. 3. Copy the **Webhook URL** the dialog shows. It looks like `https://mail.example.com/webhooks/esp/ses/whc_…`. -You can also do this through the API (`PUT /settings/esps/{espId}/feedback` with `{"expectedTopicArn": "arn:aws:sns:…"}`) or, over MCP, the `feedback.upsert` operation through `api.write`. +You can also do this through the API (`PUT /settings/esps/{espId}/feedback` with `{"expectedTopicArn": "arn:aws:sns:…"}`) or the `upsert_esp_feedback_connection` MCP tool. ## 3. Subscribe the webhook URL to the topic diff --git a/docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md b/docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md deleted file mode 100644 index 5bd681be..00000000 --- a/docs/decisions/0010-mcp-on-the-platform-kit-with-generic-api-tools.md +++ /dev/null @@ -1,62 +0,0 @@ -# ADR 0010: MCP on the Platform kit, with dedicated and generic tools - -Status: accepted -Date: 2026-11-01 - -## Context - -SendLit served MCP with its own server on the MCP SDK 2: one dedicated tool -per REST operation, 118 tools in all. A client loads every tool's name, -description and schemas into the model's context before the first call, so -the catalog was large and most of it was rarely used (ESP lifecycle, -reputation sources, tracking domain, web settings, API keys). - -`@codelitdev/mcp-server-kit` (ADR 0001) now serves MCP 2026-07-28 and 2025-era -Streamable HTTP statelessly, with platform tool errors, risk annotations and -`confirm: true` for destructive tools. CourseLit runs on it with a few -dedicated tools for its main flows and generic `api.*` tools that run any -other REST operation through its REST dispatcher. - -## Decision - -- `/mcp` runs on `createMcpServerKit`. SendLit keeps its own credential and - team resolution (`resolveAuth`, `resolveUserTeam`, shared with REST), the - OAuth discovery routes, the `WWW-Authenticate` challenge on 401, per-tool - rate limits and request logs. `x-sendlit-apikey` keeps working beside the - kit's `X-API-Key`. -- 29 dedicated tools cover the everyday flows: contacts and their tags and - imports, segments, templates, sequences and broadcasts (including test - sends, start, pause and stats), transactional email, event tracking, - delivery events and plan usage. Their names and schemas are unchanged. -- Every other team-scoped REST operation is reached through - `api.list_operations`, `api.describe_operation`, `api.read`, `api.write` and - `api.delete`, the names CourseLit uses. A call is injected in-process into - SendLit's Express app with the caller's own credential, IP and request id, - so validation, plan limits, errors and REST's own refusals are exactly the - REST API's. Operation schemas come from `/openapi.json`. -- The parity manifest decides what MCP can reach: an operation maps either to - its dedicated tool or to the `api.*` tool for its risk. REST-only - operations (billing, account deletion, organization administration, - provisioning) stay unreachable. -- Authorization is per operation: `apps/api/src/mcp/policy.ts` maps each - MCP-reachable operation to its OAuth scope, and both dedicated and generic - tools check it, so an operation needs the same scope however it is reached. - Team API keys still can't manage API keys. -- Tool failures are platform errors (`not_found`, `conflict`, - `validation_failed`, `forbidden`, …) whose `details.reason` keeps SendLit's - error code (`plan_limit_reached`, `template_not_transactional`, …) and - `details.message` the explanation. - -## Consequences - -- The catalog drops from 118 tools to 34. Prompts or agents that call a - removed tool by name (for example `get_esp_config`, `delete_contact` or - `retire_esp`) must switch to the `api.*` tools; deletes now need - `confirm: true`. This is a breaking change for MCP clients, so the server - version moves to 3.0.0. -- MCP-only aliases over the team's default ESP (`get_esp_config`, - `update_esp_config`, `delete_esp_config`, `send_test_email`) are gone; the - per-ESP operations cover them. -- A new REST operation is reachable over MCP as soon as the parity manifest - routes it to an `api.*` tool and `policy.ts` gives it a scope; a dedicated - tool is only worth adding for a common flow. diff --git a/docs/decisions/0010-mcp-on-the-platform-kit.md b/docs/decisions/0010-mcp-on-the-platform-kit.md new file mode 100644 index 00000000..7f7c1052 --- /dev/null +++ b/docs/decisions/0010-mcp-on-the-platform-kit.md @@ -0,0 +1,61 @@ +# ADR 0010: MCP on the Platform kit, one tool per operation + +Status: accepted +Date: 2026-11-01 + +## Context + +SendLit served MCP with its own server on the MCP SDK 2. Each REST operation +reachable over MCP had a dedicated tool (115 tools), with SendLit's own +registration, scope checks, error results and transport wiring. + +`@codelitdev/mcp-server-kit` (ADR 0001) now serves MCP 2026-07-28 and 2025-era +Streamable HTTP statelessly, with platform tool errors, risk annotations, tool +titles and output schemas, and `confirm: true` for destructive tools. CourseLit +runs on it with a few dedicated tools and generic `api.*` tools that run any +other REST operation after discovering it. + +We tried CourseLit's generic tools for SendLit (`api.list_operations`, +`api.describe_operation`, `api.read`, `api.write`, `api.delete`) and dropped +them before release. The ChatGPT Plugins Directory requires each +model-callable operation to be its own tool and rejects "discovery, operation +selection, or schema fetching with a generic executor" +([plugin guidelines](https://developers.openai.com/apps-sdk/app-submission-guidelines)). +The Claude Connectors Directory accepts split read and write executors, but +SendLit wants to be listed in both +([pre-submission checklist](https://claude.com/docs/connectors/building/review-criteria)). + +## Decision + +- `/mcp` runs on `createMcpServerKit`. SendLit keeps its own credential and + team resolution (`resolveAuth`, and `resolveUserTeam`, now shared with + REST's `requireTeam`), the OAuth discovery routes, the `WWW-Authenticate` + challenge on 401, per-tool rate limits and request logs. + `x-sendlit-apikey` keeps working beside the kit's `X-API-Key`. +- Every MCP-reachable REST operation keeps its own tool, with the same names + and schemas as before. `dedicatedTool()` (`apps/api/src/mcp/tool.ts`) builds + each one from its REST operation id. +- Authorization is per operation: `apps/api/src/mcp/policy.ts` maps each + MCP-reachable operation to its OAuth scope, and a tool is authorized through + its operation. Team API keys still can't manage API keys. +- What both directories review is enforced in code and tests: every tool has a + `title`, a name of at most 64 characters, an explicit `openWorldHint`, and + `readOnlyHint`/`destructiveHint` from its risk. Destructive tools advertise + `confirm`, which the kit requires. +- Tool failures are platform errors (`not_found`, `conflict`, + `validation_failed`, `forbidden`, …). `details.reason` keeps SendLit's error + code (`plan_limit_reached`, `template_not_transactional`, …), and + `details.message` gives the explanation. Unexpected errors are reported and + returned as `internal_error`, never with their raw message. + +## Consequences + +- Breaking for MCP clients, so the server version moves to 3.0.0. Tool names + are unchanged, but destructive tools now need `confirm: true`, and error + results have the platform shape instead of free text. +- A new REST operation reaches MCP through a new dedicated tool, a parity + manifest entry and a scope in `policy.ts`. The parity and catalog tests fail + until all three exist. +- The catalog stays large. Clients that defer or search large tool lists + handle it; a smaller catalog would need the directories to accept generic + executors. diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 8ef7728a..93561198 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -1,7 +1,6 @@ export * from "./contact-import"; export { type Contract, contract } from "./contract"; export { - MCP_API_TOOLS, type McpParityExemption, type McpParityManifestEntry, type McpParityRisk, diff --git a/packages/api-contract/src/mcp-parity.ts b/packages/api-contract/src/mcp-parity.ts index 3652a57a..0c8115cd 100644 --- a/packages/api-contract/src/mcp-parity.ts +++ b/packages/api-contract/src/mcp-parity.ts @@ -85,24 +85,10 @@ const ESP_LIFECYCLE = "ESP suspend and resume are operational lifecycle controls kept REST/dashboard-only; MCP exposes create, activate, test, retire and delete."; const DELIVERY_SELECTION = "Team delivery-source selection (shared organization delivery vs user ESPs) is REST/dashboard-only; MCP manages user ESP configurations directly."; -const API_DISCOVERY = - "MCP-only discovery for the generic api.* tools: it lists and describes the REST operations themselves, so it has no REST operation of its own."; - -/** - * The generic MCP tools (ADR 0010). Common flows have dedicated tools; every - * other MCP-reachable REST operation runs through the api.* tool for its risk, - * after api.list_operations and api.describe_operation. - */ -export const MCP_API_TOOLS = { - read: "api.read", - write: "api.write", - destructive: "api.delete", -} as const; +const DEFAULT_ESP_ALIAS = + "MCP-only convenience alias over the team's default user ESP; REST exposes the same capability through the per-ESP settings.esps.* operations."; export const mcpParityManifest: readonly McpParityManifestEntry[] = [ - mcpOnly("api.list_operations", API_DISCOVERY), - mcpOnly("api.describe_operation", API_DISCOVERY), - // Contacts required("contacts.create", "create_contact", "write"), required("contacts.list", "list_contacts", "read"), @@ -110,83 +96,87 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ required("contacts.update", "update_contact", "write"), required("contacts.addTag", "add_contact_tag", "write"), required("contacts.removeTag", "remove_contact_tag", "write"), - required("contacts.deliveries", MCP_API_TOOLS.read, "read"), - required("contacts.events", MCP_API_TOOLS.read, "read"), - required("contacts.remove", MCP_API_TOOLS.destructive, "destructive"), - required("contacts.previewBulkDelete", MCP_API_TOOLS.read, "read"), - required("contacts.bulkDelete", MCP_API_TOOLS.destructive, "destructive"), - required("contacts.bulkDeleteStatus", MCP_API_TOOLS.read, "read"), + required("contacts.deliveries", "get_contact_deliveries", "read"), + required("contacts.events", "get_contact_events", "read"), + required("contacts.remove", "delete_contact", "destructive"), + required("contacts.previewBulkDelete", "preview_delete_contacts", "read"), + required("contacts.bulkDelete", "delete_contacts", "destructive"), + required("contacts.bulkDeleteStatus", "get_contact_deletion", "read"), // Tags - required("tags.list", MCP_API_TOOLS.read, "read"), - required("tags.references", MCP_API_TOOLS.read, "read"), - required("tags.rename", MCP_API_TOOLS.write, "write"), - required("tags.remove", MCP_API_TOOLS.destructive, "destructive"), + required("tags.list", "list_tags", "read"), + required("tags.references", "get_tag_references", "read"), + required("tags.rename", "rename_tag", "write"), + required("tags.remove", "delete_tag", "destructive"), // Segments required("segments.create", "create_segment", "write"), required("segments.list", "list_segments", "read"), - required("segments.get", MCP_API_TOOLS.read, "read"), - required("segments.update", MCP_API_TOOLS.write, "write"), - required("segments.remove", MCP_API_TOOLS.destructive, "destructive"), + required("segments.get", "get_segment", "read"), + required("segments.update", "update_segment", "write"), + required("segments.remove", "delete_segment", "destructive"), // Media - required("media.list", MCP_API_TOOLS.read, "read"), + required("media.list", "list_media", "read"), restOnly( "media.presigned", "REST-only upload helper: issues a MediaLit browser upload signature for the dashboard's direct file upload, which has no MCP transport.", ), - required("media.get", MCP_API_TOOLS.read, "read"), - required("media.update", MCP_API_TOOLS.write, "write"), - required("media.remove", MCP_API_TOOLS.destructive, "destructive"), - required("media.references", MCP_API_TOOLS.read, "read"), + required("media.get", "get_media", "read"), + required("media.update", "update_media", "write"), + required("media.remove", "delete_media", "destructive"), + required("media.references", "list_media_references", "read"), // Templates - required("templates.listSystem", MCP_API_TOOLS.read, "read"), + required("templates.listSystem", "list_system_templates", "read"), required("templates.create", "create_template", "write"), required("templates.list", "list_templates", "read"), required("templates.get", "get_template", "read"), required("templates.update", "update_template", "write"), - required("templates.duplicate", MCP_API_TOOLS.write, "write"), - required("templates.remove", MCP_API_TOOLS.destructive, "destructive"), + required("templates.duplicate", "duplicate_template", "write"), + required("templates.remove", "delete_template", "destructive"), // Broadcasts and sequences required("sequences.create", "create_sequence", "write"), required("sequences.list", "list_sequences", "read"), required("sequences.get", "get_sequence", "read"), required("sequences.update", "update_sequence", "write"), - required("sequences.remove", MCP_API_TOOLS.destructive, "destructive"), - required("sequences.deletionPreview", MCP_API_TOOLS.read, "read"), - required("sequences.archive", MCP_API_TOOLS.write, "write"), - required("sequences.unarchive", MCP_API_TOOLS.write, "write"), + required("sequences.remove", "delete_sequence", "destructive"), + required("sequences.deletionPreview", "preview_sequence_deletion", "read"), + required("sequences.archive", "archive_broadcast", "write"), + required("sequences.unarchive", "unarchive_broadcast", "write"), required("sequences.addEmail", "add_sequence_email", "write"), required("sequences.updateEmail", "update_sequence_email", "write"), required("contacts.import", "import_contacts", "write"), - required("suppressions.create", MCP_API_TOOLS.write, "write"), - required("forms.list", MCP_API_TOOLS.read, "read"), - required("forms.create", MCP_API_TOOLS.write, "write"), - required("forms.get", MCP_API_TOOLS.read, "read"), - required("forms.update", MCP_API_TOOLS.write, "write"), - required("forms.remove", MCP_API_TOOLS.destructive, "destructive"), - required("contacts.export", MCP_API_TOOLS.read, "read"), + required("suppressions.create", "add_suppression", "write"), + required("forms.list", "list_signup_forms", "read"), + required("forms.create", "create_signup_form", "write"), + required("forms.get", "get_signup_form", "read"), + required("forms.update", "update_signup_form", "write"), + required("forms.remove", "delete_signup_form", "destructive"), + required("contacts.export", "export_contacts", "read"), required("sequences.sendEmailTest", "send_sequence_email_test", "write"), - required("sequences.removeEmail", MCP_API_TOOLS.destructive, "destructive"), + required("sequences.removeEmail", "delete_sequence_email", "destructive"), required("sequences.start", "start_sequence", "write"), required("sequences.pause", "pause_sequence", "write"), required("sequences.stats", "get_sequence_stats", "read"), - required("sequences.schedule", MCP_API_TOOLS.read, "read"), - required("sequences.waitingContacts", MCP_API_TOOLS.read, "read"), - required("sequences.getWeb", MCP_API_TOOLS.read, "read"), - required("sequences.updateWeb", MCP_API_TOOLS.write, "write"), - required("web.list", MCP_API_TOOLS.read, "read"), - required("web.get", MCP_API_TOOLS.read, "read"), - required("reputation.get", MCP_API_TOOLS.read, "read"), - required("reputation.connectGoogle", MCP_API_TOOLS.write, "write"), - required("reputation.disconnectGoogle", MCP_API_TOOLS.destructive, "destructive"), - required("reputation.connectSnds", MCP_API_TOOLS.write, "write"), - required("reputation.disconnectSnds", MCP_API_TOOLS.destructive, "destructive"), - required("reputation.sync", MCP_API_TOOLS.write, "write"), - required("sequences.subscribers", MCP_API_TOOLS.read, "read"), + required("sequences.schedule", "get_sequence_schedule", "read"), + required("sequences.waitingContacts", "list_sequence_email_waiting_contacts", "read"), + required("sequences.getWeb", "get_broadcast_web_publishing", "read"), + required("sequences.updateWeb", "update_broadcast_web_publishing", "write"), + required("web.list", "list_web_issues", "read"), + required("web.get", "get_web_issue", "read"), + required("reputation.get", "get_reputation", "read"), + required("reputation.connectGoogle", "connect_google_postmaster", "write"), + required( + "reputation.disconnectGoogle", + "disconnect_google_postmaster", + "destructive", + ), + required("reputation.connectSnds", "connect_snds", "write"), + required("reputation.disconnectSnds", "disconnect_snds", "destructive"), + required("reputation.sync", "sync_reputation", "write"), + required("sequences.subscribers", "get_sequence_subscribers", "read"), // Transactional email required("transactional.send", "send_email", "write"), @@ -195,42 +185,50 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ // Custom events required("events.track", "track_event", "write"), - required("events.definitions", MCP_API_TOOLS.read, "read"), - required("events.definition", MCP_API_TOOLS.read, "read"), - required("events.createDefinition", MCP_API_TOOLS.write, "write"), - required("events.updateDefinition", MCP_API_TOOLS.write, "write"), - required("events.deleteDefinition", MCP_API_TOOLS.destructive, "destructive"), + required("events.definitions", "list_event_definitions", "read"), + required("events.definition", "get_event_definition", "read"), + required("events.createDefinition", "create_event_definition", "write"), + required("events.updateDefinition", "update_event_definition", "write"), + required("events.deleteDefinition", "delete_event_definition", "destructive"), // User-managed ESPs - required("settings.esps.list", MCP_API_TOOLS.read, "read"), - required("settings.esps.create", MCP_API_TOOLS.write, "write"), - required("settings.esps.get", MCP_API_TOOLS.read, "read"), - required("settings.esps.update", MCP_API_TOOLS.write, "write"), - required("settings.esps.remove", MCP_API_TOOLS.destructive, "destructive"), - required("settings.esps.test", MCP_API_TOOLS.write, "write"), - required("settings.esps.domainAuthentication", MCP_API_TOOLS.read, "read"), - required("settings.esps.activate", MCP_API_TOOLS.write, "write"), + required("settings.esps.list", "list_esps", "read"), + required("settings.esps.create", "create_esp", "write"), + required("settings.esps.get", "get_esp", "read"), + required("settings.esps.update", "update_esp", "write"), + required("settings.esps.remove", "delete_esp", "destructive"), + required("settings.esps.test", "test_esp", "write"), + required( + "settings.esps.domainAuthentication", + "check_esp_domain_authentication", + "read", + ), + required("settings.esps.activate", "activate_esp", "write"), restOnly("settings.esps.suspend", ESP_LIFECYCLE), restOnly("settings.esps.resume", ESP_LIFECYCLE), - required("settings.esps.retire", MCP_API_TOOLS.destructive, "destructive"), + required("settings.esps.retire", "retire_esp", "destructive"), + mcpOnly("get_esp_config", DEFAULT_ESP_ALIAS), + mcpOnly("update_esp_config", DEFAULT_ESP_ALIAS), + mcpOnly("delete_esp_config", DEFAULT_ESP_ALIAS), + mcpOnly("send_test_email", DEFAULT_ESP_ALIAS), // General settings - required("settings.general.get", MCP_API_TOOLS.read, "read"), - required("settings.general.update", MCP_API_TOOLS.write, "write"), - required("settings.trackingDomain.get", MCP_API_TOOLS.read, "read"), - required("settings.trackingDomain.update", MCP_API_TOOLS.write, "write"), - required("settings.trackingDomain.verify", MCP_API_TOOLS.write, "write"), - required("settings.web.get", MCP_API_TOOLS.read, "read"), - required("settings.web.update", MCP_API_TOOLS.write, "write"), + required("settings.general.get", "get_general_settings", "read"), + required("settings.general.update", "update_general_settings", "write"), + required("settings.trackingDomain.get", "get_tracking_domain", "read"), + required("settings.trackingDomain.update", "set_tracking_domain", "write"), + required("settings.trackingDomain.verify", "verify_tracking_domain", "write"), + required("settings.web.get", "get_web_settings", "read"), + required("settings.web.update", "update_web_settings", "write"), // Teams and team API keys - required("teams.list", MCP_API_TOOLS.read, "read"), - required("teams.create", MCP_API_TOOLS.write, "write"), - required("teams.rename", MCP_API_TOOLS.write, "write"), - required("teams.remove", MCP_API_TOOLS.destructive, "destructive"), - required("teams.listKeys", MCP_API_TOOLS.read, "read"), - required("teams.createKey", MCP_API_TOOLS.write, "write"), - required("teams.removeKey", MCP_API_TOOLS.destructive, "destructive"), + required("teams.list", "list_teams", "read"), + required("teams.create", "create_team", "write"), + required("teams.rename", "rename_team", "write"), + required("teams.remove", "delete_team", "destructive"), + required("teams.listKeys", "list_api_keys", "read"), + required("teams.createKey", "create_api_key", "write"), + required("teams.removeKey", "delete_api_key", "destructive"), // Organization administration restOnly("account.getDeletion", ACCOUNT_DELETION), @@ -306,21 +304,21 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ ), // User ESP delivery feedback - required("feedback.get", MCP_API_TOOLS.read, "read"), - required("feedback.upsert", MCP_API_TOOLS.write, "write"), + required("feedback.get", "get_esp_feedback_connection", "read"), + required("feedback.upsert", "upsert_esp_feedback_connection", "write"), restOnly( "feedback.rotate", "REST-only rotate endpoint; over MCP, calling upsert_esp_feedback_connection on an existing connection rotates its credential.", ), - required("feedback.test", MCP_API_TOOLS.write, "write"), - required("feedback.remove", MCP_API_TOOLS.destructive, "destructive"), + required("feedback.test", "test_esp_feedback_connection", "write"), + required("feedback.remove", "delete_esp_feedback_connection", "destructive"), // Delivery events and suppressions required("deliveryEvents.list", "list_delivery_events", "read"), - required("deliveryEvents.get", MCP_API_TOOLS.read, "read"), - required("suppressions.list", MCP_API_TOOLS.read, "read"), - required("suppressions.get", MCP_API_TOOLS.read, "read"), - required("suppressions.release", MCP_API_TOOLS.write, "write"), + required("deliveryEvents.get", "get_delivery_event", "read"), + required("suppressions.list", "list_suppressions", "read"), + required("suppressions.get", "get_suppression", "read"), + required("suppressions.release", "release_suppression", "write"), // Billing required("billing.organizationPlanUsage", "get_plan_usage", "read"), From a5fa9bf9fdd4e3eebda3daa0e10da42c368bb073 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 17:00:33 +0530 Subject: [PATCH 5/6] chore(deps): bump @codelitdev packages to 0.8.0 0.8.0's mcp-server-kit advertises every tool hint as an explicit boolean, as the ChatGPT Plugins Directory requires. --- apps/api/package.json | 12 ++++++------ apps/docs/package.json | 2 +- apps/web/package.json | 4 ++-- bun.lock | 32 ++++++++++++++++---------------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 3e339243..94b3f306 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,11 +30,11 @@ "@anthropic-ai/sdk": "^0.135.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/billing": "0.7.0", - "@codelitdev/mcp-server-kit": "0.7.0", - "@codelitdev/oauth-server-kit": "0.7.0", - "@codelitdev/observability": "0.7.0", - "@codelitdev/platform": "0.7.0", + "@codelitdev/billing": "0.8.0", + "@codelitdev/mcp-server-kit": "0.8.0", + "@codelitdev/oauth-server-kit": "0.8.0", + "@codelitdev/observability": "0.8.0", + "@codelitdev/platform": "0.8.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@sendlit/api-contract": "workspace:*", @@ -64,7 +64,7 @@ }, "devDependencies": { "@better-auth/core": "1.7.7", - "@codelitdev/platform-conformance": "0.7.0", + "@codelitdev/platform-conformance": "0.8.0", "@electric-sql/pglite": "0.5.8", "@modelcontextprotocol/client": "^2.0.0", "@sendlit/test-utils": "workspace:*", diff --git a/apps/docs/package.json b/apps/docs/package.json index 4f9d26ba..5a158ecc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,7 +11,7 @@ "postinstall": "fumadocs-mdx" }, "dependencies": { - "@codelitdev/design-system": "0.7.0", + "@codelitdev/design-system": "0.8.0", "@sendlit/api-contract": "workspace:*", "@sendlit/email-blocks": "workspace:*", "@sendlit/email-editor": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index fd430137..282e8b96 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@codelitdev/design-system": "0.7.0", - "@codelitdev/observability": "0.7.0", + "@codelitdev/design-system": "0.8.0", + "@codelitdev/observability": "0.8.0", "@radix-ui/react-alert-dialog": "^1.1.18", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-label": "^2.1.11", diff --git a/bun.lock b/bun.lock index 05bbcf4c..5c6e4ea3 100644 --- a/bun.lock +++ b/bun.lock @@ -19,11 +19,11 @@ "@anthropic-ai/sdk": "^0.135.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/billing": "0.7.0", - "@codelitdev/mcp-server-kit": "0.7.0", - "@codelitdev/oauth-server-kit": "0.7.0", - "@codelitdev/observability": "0.7.0", - "@codelitdev/platform": "0.7.0", + "@codelitdev/billing": "0.8.0", + "@codelitdev/mcp-server-kit": "0.8.0", + "@codelitdev/oauth-server-kit": "0.8.0", + "@codelitdev/observability": "0.8.0", + "@codelitdev/platform": "0.8.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@sendlit/api-contract": "workspace:*", @@ -53,7 +53,7 @@ }, "devDependencies": { "@better-auth/core": "1.7.7", - "@codelitdev/platform-conformance": "0.7.0", + "@codelitdev/platform-conformance": "0.8.0", "@electric-sql/pglite": "0.5.8", "@modelcontextprotocol/client": "^2.0.0", "@sendlit/test-utils": "workspace:*", @@ -73,7 +73,7 @@ "name": "@sendlit/docs", "version": "0.1.0", "dependencies": { - "@codelitdev/design-system": "0.7.0", + "@codelitdev/design-system": "0.8.0", "@sendlit/api-contract": "workspace:*", "@sendlit/email-blocks": "workspace:*", "@sendlit/email-editor": "workspace:*", @@ -102,8 +102,8 @@ "name": "@sendlit/web", "version": "0.1.0", "dependencies": { - "@codelitdev/design-system": "0.7.0", - "@codelitdev/observability": "0.7.0", + "@codelitdev/design-system": "0.8.0", + "@codelitdev/observability": "0.8.0", "@radix-ui/react-alert-dialog": "^1.1.18", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-label": "^2.1.11", @@ -416,19 +416,19 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@codelitdev/billing": ["@codelitdev/billing@0.7.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-6HzcGDUdvlofS8AVmDRX+qQ83aGQ3m5nzMn9nOPxj0BYx00p/Jpa/6mi0pcDIoaxVpqh8C+eFEev63hYuRKKcg=="], + "@codelitdev/billing": ["@codelitdev/billing@0.8.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-xq6AzksmRG2oVyzgwqxRUSxU8QaZcj4WDv9rOHXfebQhqFjkMV9X46QGDTP9s0LxSTgriLY03JPQMT9+aOsqZg=="], - "@codelitdev/design-system": ["@codelitdev/design-system@0.7.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Xc02p/FBqd/3+XWlf6vVKaXiqq1+Bn6UX5UF8TNGuSI3pxaAsrx3b55zUWAZ6fW+vfEnAHzB5lBU+WIJimo5qg=="], + "@codelitdev/design-system": ["@codelitdev/design-system@0.8.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-WBVWlqKK88dbg5BeME/GLETJbzsOLhDdkhv41TIrO0VkWVXqOhCZ7wrQvB5+sNx+jezItysmhF9L+99zYhXHsA=="], - "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.7.0", "", { "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "@codelitdev/platform": ">=0.7.0" } }, "sha512-fvUVfq5e0iNQUPsUKk2WKa6xj6KV1UtETEa+MK2SlPCKYWcDBGEkm3rVJRQzEHXqGCLV12VWVoXQTbT72gexUw=="], + "@codelitdev/mcp-server-kit": ["@codelitdev/mcp-server-kit@0.8.0", "", { "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "@codelitdev/platform": ">=0.8.0" } }, "sha512-k3c/lP2njh4HxxYgLLWcpPf41N1QU7w5eddcf6ZP5vRPxVQskZDrU8XKclTXbNcEuONvuq96hMrwoQsWW2tgFw=="], - "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.7.0", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-aAQJUFTruJyX0I3KB+e2idA2WTRbJm/92GnpiUsL5qWwpk+ydON0CduFrddMi1RY4IkHAH5aA5Py0zYEtGDMzQ=="], + "@codelitdev/oauth-server-kit": ["@codelitdev/oauth-server-kit@0.8.0", "", { "peerDependencies": { "@better-auth/oauth-provider": "^1.7.2", "better-auth": "^1.7.2", "express": "^4.21.2" } }, "sha512-hEVG5G0tAacDHv44evzKPM+AJQmu2bND2AtspAJFlRuE9QLJteu/1N7aLgGcPlDdA1+YI5+s0J0fbXpBT8rPsQ=="], - "@codelitdev/observability": ["@codelitdev/observability@0.7.0", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-aj0yYsidVSw9iG57sVF3S5AVGiQC2kGapHTTqPafoXNr6OeDK3eQyuPoCGqAtLWzvrFQibD40ffgRbjWtayInQ=="], + "@codelitdev/observability": ["@codelitdev/observability@0.8.0", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-BX7STE1SqwPvuTBSyIpADkw7Ef3haWgTGoKFuOMxO5t+0Ow3bp/8hmk/dcpjNKK+oV2q2sDKbcVDEgWrmdUVCg=="], - "@codelitdev/platform": ["@codelitdev/platform@0.7.0", "", { "peerDependencies": { "@codelitdev/billing": ">=0.7.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-oSV3+Uf68QrPZBNOb02Tbb16joxw6ktQqrnwq0BCfuxoFqUk9mOR7FH+WQiBZFli8Q4O6sblKkUW7f47dM6L3Q=="], + "@codelitdev/platform": ["@codelitdev/platform@0.8.0", "", { "peerDependencies": { "@codelitdev/billing": ">=0.8.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-RqPgDtZRvJ81IbVyeZFDG8Y+qcNTKuCt3O7mq6gvMoln/og0Ma/P9MCJ6zx09KLLXjFYhzUADP/evThwF62h7g=="], - "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.7.0", "", { "peerDependencies": { "@codelitdev/platform": ">=0.7.0" } }, "sha512-EHjBx2rIvN8OzL4cKUg+FIo1YdRFa3SKfOmmcGx8YrZUEV3ub6ylVfg0ycFP/qRPUgzYMMRuc7Oujj9SSbGrtw=="], + "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.8.0", "", { "peerDependencies": { "@codelitdev/platform": ">=0.8.0" } }, "sha512-gmbwoPs23W/Zm2WuRKWewQ7rm1dB7dMyY3F2pNxnMi8vhBKprNuQNK3qb9++rwiawTjhsDlpuyLD6BPTKgseAw=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], From fa5e3fdf105b98b951546791f4807c8a5f8506b5 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 17:00:34 +0530 Subject: [PATCH 6/6] chore(platform): sync managed files with platform-cli 0.8.0 --- .codelit-platform.json | 4 ++-- .github/workflows/platform-conformance.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codelit-platform.json b/.codelit-platform.json index 57555c3f..1e55718a 100644 --- a/.codelit-platform.json +++ b/.codelit-platform.json @@ -4,7 +4,7 @@ "name": "SendLit", "slug": "sendlit" }, - "cliVersion": "0.7.0", + "cliVersion": "0.8.0", "capabilities": [ "auth", "mcp", @@ -12,7 +12,7 @@ "billing" ], "managedFiles": { - ".github/workflows/platform-conformance.yml": "247203888c4698fd82db6671daf40489d13161fa60b89c259107fdb257b443d1" + ".github/workflows/platform-conformance.yml": "50a2ddaa53da139f1f34cad7f8caa06e29893ed99b13b7ca4e89566aff4e55df" }, "productOwnedGlobs": [ "apps/**", diff --git a/.github/workflows/platform-conformance.yml b/.github/workflows/platform-conformance.yml index 41d63abc..07823942 100644 --- a/.github/workflows/platform-conformance.yml +++ b/.github/workflows/platform-conformance.yml @@ -23,7 +23,7 @@ jobs: if: ${{ !hashFiles('bun.lock') }} - run: bun install --frozen-lockfile # Pinned to the CLI release that wrote this file. `sync` moves it. - - run: bunx @codelitdev/platform-cli@0.7.0 doctor + - run: bunx @codelitdev/platform-cli@0.8.0 doctor - run: bun run --filter "*/api" check:drift # Fails when the API package has no `test:conformance` script. - run: bun run --filter "*/api" test:conformance