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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/mcp/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ const toolPolicies = {
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,
start_sequence: MCP_SCOPES.sequencesWrite,
pause_sequence: MCP_SCOPES.sequencesWrite,

Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ describe("MCP server", () => {
expect(client.getProtocolEra()).toBe("modern");

const result = await client.listTools();
expect(result.tools).toHaveLength(109);
expect(result.tools).toHaveLength(111);
expect(result.tools.map((tool) => tool.name).sort()).toEqual(
Object.keys(listMcpToolPolicies()).sort(),
);
expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(109);
expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(111);
expect(result.ttlMs).toBe(300_000);
expect(result.cacheScope).toBe("private");
for (const tool of result.tools) {
Expand Down
59 changes: 58 additions & 1 deletion apps/api/src/mcp/tools/sequences.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import {
contactFilterSchema,
emailContentInputSchema,
sequenceDeletionPreviewSchema,
sequenceEmailTestResultSchema,
updateSequenceBodySchema,
} from "@sendlit/api-contract";
import { z } from "zod";
import { broadcastPacing } from "../../abuse/pacing-status";
import { sequenceDeliveryRates } from "../../automation/sending-health";
import { emailActionTypes, mailTypes } from "../../config/constants";
import { sequenceDeleteBlockerMessage } from "../../sequences/delete-blockers";
import type { HydratedSequence } from "../../sequences/queries";
import {
addMailToSequence,
countSequences,
createSequence,
deleteMailFromSequence,
deleteSequence,
getEmailSentCount,
getSequenceBySequenceId,
getSequenceClickThroughRate,
getSequenceDeletionImpact,
getSequenceOpenRate,
getSubscribers,
getSubscribersCount,
Expand All @@ -39,7 +43,12 @@ import {
NOT_FOUND,
planGateResult,
} from "./responses";
import { sequenceListSchema, sequenceSchema, sequenceStatsSchema } from "./schemas";
import {
sequenceListSchema,
sequenceSchema,
sequenceStatsSchema,
successMessageSchema,
} from "./schemas";

// `sequenceEmails.sequenceId` stores its *parent* sequence's internal `id`
// (see the file-level doc comment in `db/schema.ts`) — dropped here rather
Expand Down Expand Up @@ -391,6 +400,54 @@ export function registerSequenceTools(server: McpToolRegistrar): void {
},
);

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).",
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 one must be paused first (pause_sequence). 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);
}
},
);

server.registerTool(
"start_sequence",
{
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/mcp/tools/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const mocks = {
countSelection: vi.fn(),

createSequence: vi.fn(),
deleteSequence: vi.fn(),
getSequenceDeletionImpact: vi.fn(),
getSequenceBySequenceId: vi.fn(),
getEmailSentCount: vi.fn(),
getSequenceOpenRate: vi.fn(),
Expand Down Expand Up @@ -147,6 +149,8 @@ mock.module("../../sequences/queries", () => ({
addMailToSequence: mocks.addMailToSequence,
updateMailInSequence: mocks.updateMailInSequence,
deleteMailFromSequence: mocks.deleteMailFromSequence,
deleteSequence: mocks.deleteSequence,
getSequenceDeletionImpact: mocks.getSequenceDeletionImpact,
startSequence: mocks.startSequence,
pauseSequence: mocks.pauseSequence,
}));
Expand Down Expand Up @@ -496,6 +500,54 @@ describe("MCP contact tools", () => {
});

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("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);

Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/observability/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ const PROPERTY_ALLOWLIST = new Set([
"suppressed_count",
// ESP deletion (settings/esp, organization ESPs): public esp_ id.
"esp_id",
// Sequence deletion (sequences/queries.ts deleteSequence): counts only.
"enrolled_count",
"emails_sent_count",
]);

/** Allowlisted keys that hold opaque record ids. They skip the package's
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/sequences/delete-blockers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Why a broadcast or sequence can't be deleted. Each code tells the caller
* what to do instead, so the REST API, MCP and dashboard explain it the same
* way. Broadcast-only blockers (#147) belong here too.
*/
export const sequenceDeleteBlockerMessages = {
// Contacts are moving through it (or a broadcast is sending), so deleting
// it would drop them mid-flow. Pausing stops new sends first.
sequence_active:
"This sequence is active. Pause it before deleting it, so no contact is dropped mid-flow.",
} as const;

export type SequenceDeleteBlocker = keyof typeof sequenceDeleteBlockerMessages;

export function isSequenceDeleteBlocker(code: unknown): code is SequenceDeleteBlocker {
return typeof code === "string" && Object.hasOwn(sequenceDeleteBlockerMessages, code);
}

/** The explanation for a delete blocker, or `undefined` for any other error. */
export function sequenceDeleteBlockerMessage(code: unknown): string | undefined {
return isSequenceDeleteBlocker(code)
? sequenceDeleteBlockerMessages[code]
: undefined;
}

/** The blocker for a sequence in this state, or `null` when it may be deleted. */
export function sequenceDeleteBlocker(sequence: {
status: string;
}): SequenceDeleteBlocker | null {
return sequence.status === "active" ? "sequence_active" : null;
}
Loading
Loading