feat(mcp): add template preview/render, multichannel campaign, workflow search, and user filtering

This commit is contained in:
coo1estguy
2026-08-20 15:41:08 +05:30
parent f86776ec75
commit e565cca5a4
11 changed files with 1370 additions and 95 deletions
+8
View File
@@ -98,4 +98,12 @@ export class NotifkitApi {
put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PUT", path, { body });
}
patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PATCH", path, { body });
}
delete<T>(path: string): Promise<T> {
return this.request<T>("DELETE", path);
}
}
+18 -5
View File
@@ -13,6 +13,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { NotifkitApi } from "./client.js";
import { registerTools } from "./tools.js";
import { fileURLToPath } from "node:url";
const DEFAULT_URL = "http://localhost:3000";
export function createServer(options: {
@@ -45,7 +47,7 @@ export function createServer(options: {
return server;
}
async function main(): Promise<void> {
export async function runServer(): Promise<void> {
const baseUrl = process.env["NOTIFKIT_URL"] ?? DEFAULT_URL;
const apiKey = process.env["NOTIFKIT_API_KEY"];
const projectId = process.env["NOTIFKIT_PROJECT_ID"];
@@ -65,7 +67,18 @@ async function main(): Promise<void> {
console.error(`notifkit-mcp: connected, talking to ${baseUrl}`);
}
main().catch((error: unknown) => {
console.error("notifkit-mcp: fatal:", error instanceof Error ? error.message : error);
process.exit(1);
});
const entryFile = process.argv[1];
const isDirectRun =
Boolean(entryFile) &&
!process.env["VITEST"] &&
(fileURLToPath(import.meta.url) === entryFile ||
entryFile?.endsWith("notifkit-mcp") ||
entryFile?.endsWith("dist/index.js") ||
entryFile?.endsWith("src/index.ts"));
if (isDirectRun) {
runServer().catch((error: unknown) => {
console.error("notifkit-mcp: fatal:", error instanceof Error ? error.message : error);
process.exit(1);
});
}
+735 -31
View File
@@ -47,6 +47,79 @@ function handler<T>(fn: (args: T) => Promise<unknown>) {
};
}
function escapeHtml(unsafe: string): string {
return String(unsafe)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function escapeHeader(unsafe: string): string {
return String(unsafe)
.replace(/[\r\n]+/g, " ")
.trim();
}
type EscapeMode = "html" | "header" | "text";
const HTML_FIELDS = new Set(["html", "htmlbody", "bodyhtml", "htmlcontent"]);
const HEADER_FIELDS = new Set([
"subject",
"title",
"from",
"replyto",
"cc",
"bcc",
"preheader",
"preview",
]);
function escapeModeFor(key: string, inherited: EscapeMode): EscapeMode {
const k = key.toLowerCase().replace(/[-_]/g, "");
if (HTML_FIELDS.has(k)) return "html";
if (HEADER_FIELDS.has(k)) return "header";
return inherited;
}
function interpolateValue(
tmpl: string,
variables: Record<string, unknown>,
mode: EscapeMode,
): string {
return tmpl
.replace(/\{\{\{(\w+)\}\}\}/g, (_, k: string) => {
const raw = variables[k];
if (raw === undefined || raw === null) return "";
return typeof raw === "string" ? raw : JSON.stringify(raw);
})
.replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
const raw = variables[k];
if (raw === undefined || raw === null) return "";
const val = typeof raw === "string" ? raw : JSON.stringify(raw);
if (mode === "html") return escapeHtml(val);
if (mode === "header") return escapeHeader(val);
return val;
});
}
function renderContentNode(
node: unknown,
variables: Record<string, unknown>,
mode: EscapeMode,
): unknown {
if (typeof node === "string") return interpolateValue(node, variables, mode);
if (Array.isArray(node)) return node.map((item) => renderContentNode(item, variables, mode));
if (node && typeof node === "object") {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
out[key] = renderContentNode(value, variables, escapeModeFor(key, mode));
}
return out;
}
return node;
}
export function registerTools(server: McpServer, api: NotifkitApi): void {
// ─── Sending ────────────────────────────────────────────────────────────────
@@ -136,12 +209,12 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
{
title: "Send a campaign to a list of addresses",
description:
"Send one template to a list of email addresses given directly — the tool creates or updates a " +
"user record for each address, then sends, and tags every message with a campaign label so the " +
"results can be read back later with get_campaign_stats. Use this when the person supplies the " +
"recipients themselves (a pasted list, a spreadsheet column). Use send_notification instead when " +
"targeting existing users, a segment, or a topic. Always report the returned campaign label back " +
"to the user — it is how they ask about results later. Suppressed addresses (previous " +
"Send one template to a list of recipient destinations (email, SMS/phone, push tokens, or webhook URLs) " +
"given directly — the tool creates or updates a user record for each address, then sends, and " +
"tags every message with a campaign label so the results can be read back later with get_campaign_stats. " +
"Supports 'email', 'sms', 'push', and 'webhook' channels. Use this when the person supplies the " +
"recipients themselves (a pasted list, a spreadsheet column). Always report the returned campaign label " +
"back to the user — it is how they ask about results later. Suppressed addresses (previous " +
"unsubscribes, complaints, hard bounces) are dropped automatically at send time, so the delivered " +
"count is normally lower than the list size.",
inputSchema: {
@@ -154,11 +227,28 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
"label merges the two into one set of statistics.",
),
template: z.string().min(1).describe("Template id to render."),
channel: z
.enum(CONTACT_CHANNELS)
.optional()
.describe(
"Channel to send on ('email', 'sms', 'push', or 'webhook'). Defaults to 'email'.",
),
recipients: z
.array(z.string().min(1))
.min(1)
.max(10000)
.optional()
.describe(
"Recipient destination addresses: email addresses for email, E.164 phone numbers for sms, device tokens for push, or URLs for webhook.",
),
emails: z
.array(z.string().email())
.min(1)
.max(10000)
.describe("Recipient email addresses."),
.optional()
.describe(
"Recipient email addresses (alias for recipients when sending on email channel).",
),
data: z
.record(z.unknown())
.optional()
@@ -178,31 +268,46 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
annotations: { title: "Send a campaign", readOnlyHint: false, idempotentHint: false },
},
handler(async (args) => {
// The API needs a stable user id per address. Deriving it from the
// address itself means re-sending to the same list updates those people
// rather than creating a second copy of each.
const channel = args.channel ?? "email";
const rawList = args.recipients ?? args.emails;
if (!rawList || rawList.length === 0) {
throw new Error("Provide a list of `recipients` (or `emails`) to send to.");
}
const seen = new Set<string>();
const recipients = [];
for (const raw of args.emails) {
const email = raw.trim().toLowerCase();
if (seen.has(email)) continue;
seen.add(email);
recipients.push({ id: `email:${email}`, email });
for (const raw of rawList) {
const target = channel === "email" ? raw.trim().toLowerCase() : raw.trim();
if (seen.has(target)) continue;
seen.add(target);
let userRecord: Record<string, unknown>;
if (channel === "email") {
userRecord = { id: `email:${target}`, email: target };
} else if (channel === "sms") {
userRecord = { id: `phone:${target}`, phone: target };
} else if (channel === "push") {
userRecord = { id: `push:${target}`, pushToken: target };
} else {
userRecord = { id: `webhook:${target}` };
}
recipients.push(userRecord);
}
const result = await api.post<Record<string, unknown>>("/v1/notify", {
user: recipients,
template: args.template,
campaign: args.campaign,
channels: ["email"],
channels: [channel],
...(args.data ? { data: args.data } : {}),
...(args.priority ? { priority: args.priority } : {}),
...(args.sendAt ? { sendAt: args.sendAt } : {}),
});
const duplicates = args.emails.length - recipients.length;
const duplicates = rawList.length - recipients.length;
return {
campaign: args.campaign,
channel,
queued: recipients.length,
...(duplicates > 0 ? { duplicatesRemoved: duplicates } : {}),
note:
@@ -220,7 +325,8 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
description:
"List recent campaign labels with their size and when they last saw activity, newest first. Call " +
"this when the user refers to a past send without naming its exact label ('the one I sent " +
"yesterday') to find the label to pass to get_campaign_stats.",
"yesterday') to find the label to pass to get_campaign_stats. Supports filtering by search query, " +
"channel, date range, or minimum message count.",
inputSchema: {
limit: z
.number()
@@ -228,7 +334,35 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.min(1)
.max(100)
.optional()
.describe("How many campaigns to return."),
.describe("How many campaigns to return. Defaults to 20."),
search: z
.string()
.optional()
.describe("Filter campaigns whose label contains this string (case-insensitive)."),
channel: z
.enum(CHANNELS)
.optional()
.describe("Only list campaigns that sent messages on this channel."),
since: z
.string()
.datetime()
.optional()
.describe(
"ISO 8601 timestamp; only return campaigns with activity on or after this date.",
),
until: z
.string()
.datetime()
.optional()
.describe(
"ISO 8601 timestamp; only return campaigns with activity on or before this date.",
),
minMessages: z
.number()
.int()
.min(1)
.optional()
.describe("Minimum number of distinct messages/recipients for a campaign."),
},
annotations: { title: "List campaigns", readOnlyHint: true },
},
@@ -269,6 +403,10 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.enum(["unsubscribed", "complained", "bounced", "manual"])
.optional()
.describe("Only suppressions with this reason."),
target: z
.string()
.optional()
.describe("Filter suppressions matching this email, phone, or push token."),
},
annotations: { title: "List suppressed addresses", readOnlyHint: true },
},
@@ -343,11 +481,27 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
title: "List scheduled sends",
description:
"List notifications queued for future delivery — those given a `sendAt`, plus any deferred by a " +
"recipient's quiet hours. Use this to confirm a scheduled send landed, or to review what is " +
"pending before scheduling more.",
"recipient's quiet hours. Supports filtering by channel and pagination.",
inputSchema: {
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("How many rows to return. Defaults to 50."),
cursor: z
.string()
.optional()
.describe("Pagination cursor (taskId) from a previous call's `nextCursor`."),
channel: z
.enum(CHANNELS)
.optional()
.describe("Only list scheduled messages intended for this channel."),
},
annotations: { title: "List scheduled sends", readOnlyHint: true },
},
handler(async () => api.get("/v1/notifications/scheduled")),
handler(async (args) => api.get("/v1/notifications/scheduled", args)),
);
server.registerTool(
@@ -357,7 +511,7 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
description:
"Query the durable delivery log: what was sent, on which channel, and whether it succeeded. Call " +
"this to answer 'did it go out?' after a send, or to investigate failures. Filter by template, " +
"channel, status, or workflow instance. Results are paginated — pass the returned `nextCursor` " +
"channel, status, campaign, or task ID. Results are paginated — pass the returned `nextCursor` " +
"back as `cursor` for the next page.",
inputSchema: {
limit: z
@@ -380,13 +534,68 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
status: z
.string()
.optional()
.describe("Delivery status to filter on, e.g. 'sent' or 'failed'."),
.describe("Delivery status to filter on, e.g. 'sent', 'delivered', 'failed', 'opened'."),
campaign: z
.string()
.optional()
.describe("Only log entries tagged with this campaign label."),
taskId: z
.string()
.optional()
.describe("Only log entries for this specific notification task ID."),
search: z
.string()
.optional()
.describe(
"Search keyword across task IDs, template IDs, provider message IDs, or campaign labels.",
),
},
annotations: { title: "Get delivery logs", readOnlyHint: true },
},
handler(async (args) => api.get("/v1/notifications/logs", args)),
);
server.registerTool(
"get_notification",
{
title: "Get notification status",
description:
"Fetch the delivery history and latest state of a specific notification by its task ID. " +
"Shows each attempt, provider status, timestamps, and error messages.",
inputSchema: {
taskId: z
.string()
.min(1)
.describe("Task ID returned from send_notification or send_campaign."),
},
annotations: { title: "Get notification status", readOnlyHint: true },
},
handler(async (args) => api.get(`/v1/notifications/${encodeURIComponent(args.taskId)}`)),
);
server.registerTool(
"cancel_notification",
{
title: "Cancel a scheduled notification",
description:
"Cancel a pending or quiet-hours deferred notification before it is dispatched to the provider. " +
"Takes the taskId from send_notification or list_scheduled.",
inputSchema: {
taskId: z.string().min(1).describe("Task ID of the scheduled notification to cancel."),
},
annotations: {
title: "Cancel a scheduled notification",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
handler(async (args) => {
await api.request("DELETE", `/v1/notifications/${encodeURIComponent(args.taskId)}`);
return `Cancelled scheduled notification ${args.taskId}.`;
}),
);
// ─── Templates ──────────────────────────────────────────────────────────────
server.registerTool(
@@ -394,11 +603,25 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
{
title: "List templates",
description:
"List every registered template with its channel and content. Call this to find the template id " +
"for send_notification, or to check what a template renders before sending with it.",
"List registered templates with their channel and content. Supports filtering by channel, " +
"topic opt-out category, or limiting result count.",
inputSchema: {
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Maximum number of templates to return."),
channel: z
.enum(CHANNELS)
.optional()
.describe("Only list templates that render for this channel."),
topic: z.string().optional().describe("Only list templates tagged with this topic."),
},
annotations: { title: "List templates", readOnlyHint: true },
},
handler(async () => api.get("/v1/templates")),
handler(async (args) => api.get("/v1/templates", args)),
);
server.registerTool(
@@ -446,6 +669,133 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
handler(async (args) => api.put("/v1/templates", { templates: [args] })),
);
server.registerTool(
"delete_template",
{
title: "Delete a template",
description: "Permanently delete a registered template by its ID.",
inputSchema: {
id: z.string().min(1).describe("Template id to delete."),
},
annotations: {
title: "Delete a template",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
handler(async (args) => {
await api.delete(`/v1/templates/${encodeURIComponent(args.id)}`);
return `Deleted template ${args.id}.`;
}),
);
const previewTemplateHandler = async (args: {
id?: string;
content?: Record<string, unknown>;
data?: Record<string, unknown>;
channel?: (typeof CHANNELS)[number];
}) => {
if (!args.id && !args.content) {
throw new Error("Provide either a template `id` or raw `content` to preview.");
}
let rawContent = args.content;
let templateChannel = args.channel;
if (args.id) {
const tmpl = await api.get<{
id: string;
channel?: (typeof CHANNELS)[number];
content: Record<string, unknown>;
}>(`/v1/templates/${encodeURIComponent(args.id)}`);
if (!rawContent) {
rawContent = tmpl.content;
}
if (!templateChannel && tmpl.channel) {
templateChannel = tmpl.channel;
}
}
const vars = args.data ?? {};
const rendered = renderContentNode(rawContent ?? {}, vars, "text");
const jsonStr = JSON.stringify(rawContent ?? {});
const placeholderMatches = Array.from(jsonStr.matchAll(/\{\{\{?(\w+)\}?\}\}/g)).map(
(m) => m[1]!,
);
const placeholders = Array.from(new Set(placeholderMatches));
const resolvedVariables = placeholders.filter((p) => p in vars);
const unresolvedVariables = placeholders.filter((p) => !(p in vars));
return {
...(args.id ? { templateId: args.id } : {}),
channel: templateChannel ?? "email",
rendered,
resolvedVariables,
unresolvedVariables: unresolvedVariables.length > 0 ? unresolvedVariables : undefined,
};
};
server.registerTool(
"preview_template",
{
title: "Preview a template",
description:
"Dry-run render a template with sample variable data before sending. Pass an existing template " +
"`id` or supply raw `content` directly. Interpolates {{variable}} placeholders and returns the " +
"rendered output alongside resolved/unresolved variable names.",
inputSchema: {
id: z.string().optional().describe("Template id to fetch and preview."),
content: z
.record(z.unknown())
.optional()
.describe(
"Raw template content object (e.g. { subject, body }) to render without saving.",
),
data: z
.record(z.unknown())
.optional()
.describe("Sample variable data to interpolate into the template's placeholders."),
channel: z
.enum(CHANNELS)
.optional()
.describe(
"Target channel (e.g. 'email', 'sms', 'push'). Defaults to the template's channel.",
),
},
annotations: { title: "Preview a template", readOnlyHint: true },
},
handler(previewTemplateHandler),
);
server.registerTool(
"render_template",
{
title: "Render template content",
description:
"Render template content with variable data without dispatching any notifications. " +
"Accepts either an existing template `id` or raw `content` object.",
inputSchema: {
id: z.string().optional().describe("Template id to fetch and render."),
content: z
.record(z.unknown())
.optional()
.describe("Raw template content object to render."),
data: z
.record(z.unknown())
.optional()
.describe("Variables to interpolate into placeholders."),
channel: z
.enum(CHANNELS)
.optional()
.describe("Target channel. Defaults to template channel."),
},
annotations: { title: "Render template content", readOnlyHint: true },
},
handler(previewTemplateHandler),
);
// ─── Users ──────────────────────────────────────────────────────────────────
server.registerTool(
@@ -453,14 +803,35 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
{
title: "List users",
description:
"Page through the project's users. Results are paginated — pass the returned `nextCursor` back as " +
"`cursor`. For one known user prefer get_user, which also returns contacts and preferences.",
"Page through the project's users with comprehensive filtering. Supports filtering by search " +
"keyword (external ID or email), segment tag, language, timezone, or channel contact. " +
"Results are paginated — pass the returned `nextCursor` back as `cursor`. For one known user " +
"prefer get_user, which also returns contacts and preferences.",
inputSchema: {
limit: z.number().int().min(1).max(200).optional().describe("Users per page."),
cursor: z
.string()
.optional()
.describe("Pagination cursor from a previous call's `nextCursor`."),
search: z
.string()
.optional()
.describe("Filter users whose external ID or email contains this search keyword."),
segment: z.string().optional().describe("Only list users assigned to this segment tag."),
language: z
.string()
.optional()
.describe("Filter users by language tag, e.g. 'en', 'es', 'fr'."),
timezone: z
.string()
.optional()
.describe("Filter users by IANA timezone, e.g. 'Europe/London', 'America/New_York'."),
channel: z
.enum(CONTACT_CHANNELS)
.optional()
.describe(
"Only list users who have a registered contact for this channel (email, sms, push, webhook).",
),
},
annotations: { title: "List users", readOnlyHint: true },
},
@@ -539,6 +910,98 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
handler(async (args) => api.post("/v1/users", args)),
);
server.registerTool(
"update_user",
{
title: "Update a user",
description:
"Partially update an existing user profile (e.g. language, timezone, segment tags, preferences, or append new contacts) without replacing unspecified fields.",
inputSchema: {
id: z.string().min(1).describe("User id."),
email: z
.union([z.string().min(1), z.array(z.string().min(1))])
.optional()
.describe("One email address or several."),
phone: z
.union([z.string().min(1), z.array(z.string().min(1))])
.optional()
.describe("One phone number or several, in E.164 form."),
pushToken: z
.union([z.string().min(1), z.array(z.string().min(1))])
.optional()
.describe("One device push token or several."),
segments: z
.array(z.string().min(1))
.optional()
.describe("Segment tags to assign to this user."),
language: z
.string()
.optional()
.describe("BCP 47 language tag used to pick localised content."),
timezone: z
.string()
.optional()
.describe("IANA timezone, e.g. 'Europe/London'. Drives quiet hours."),
preferences: z
.object({
channels: z
.record(z.boolean())
.optional()
.describe("Per-channel opt-in map, e.g. { email: true, sms: false }."),
topics: z.record(z.boolean()).optional().describe("Per-topic opt-in map."),
quietHours: z
.array(
z.object({
start: z.string().describe("HH:MM, 24h, UTC."),
end: z.string().describe("HH:MM, 24h, UTC."),
}),
)
.optional()
.describe("Windows during which non-critical sends are deferred."),
})
.optional()
.describe("Consent and quiet-hours settings."),
},
annotations: { title: "Update a user", readOnlyHint: false, idempotentHint: true },
},
handler(async ({ id, ...body }) => api.patch(`/v1/users/${encodeURIComponent(id)}`, body)),
);
server.registerTool(
"delete_user",
{
title: "Delete a user",
description:
"Permanently delete a user profile and all associated contacts and preferences by user id.",
inputSchema: {
id: z.string().min(1).describe("User id to delete."),
},
annotations: {
title: "Delete a user",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
handler(async (args) => {
await api.delete(`/v1/users/${encodeURIComponent(args.id)}`);
return `Deleted user ${args.id}.`;
}),
);
server.registerTool(
"get_user_contacts",
{
title: "Get user contacts",
description: "Fetch all registered contact addresses and tokens for a specific user.",
inputSchema: {
userId: z.string().min(1).describe("User id."),
},
annotations: { title: "Get user contacts", readOnlyHint: true },
},
handler(async (args) => api.get(`/v1/users/${encodeURIComponent(args.userId)}/contacts`)),
);
server.registerTool(
"add_user_contact",
{
@@ -559,6 +1022,75 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
),
);
server.registerTool(
"delete_user_contact",
{
title: "Delete a user contact",
description:
"Remove one contact destination (email, phone, push token, or webhook URL) from a user profile.",
inputSchema: {
userId: z.string().min(1).describe("User id."),
channel: z.enum(CONTACT_CHANNELS).describe("Channel of the contact to delete."),
target: z.string().min(1).describe("The address/number/token/URL to remove."),
},
annotations: {
title: "Delete a user contact",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
handler(async (args) => {
await api.delete(
`/v1/users/${encodeURIComponent(args.userId)}/contacts/${encodeURIComponent(args.channel)}/${encodeURIComponent(args.target)}`,
);
return `Deleted ${args.channel} contact '${args.target}' for user ${args.userId}.`;
}),
);
server.registerTool(
"get_user_preferences",
{
title: "Get user preferences",
description:
"Fetch notification channel/topic opt-in preferences and quiet hours for a user.",
inputSchema: {
userId: z.string().min(1).describe("User id."),
},
annotations: { title: "Get user preferences", readOnlyHint: true },
},
handler(async (args) => api.get(`/v1/users/${encodeURIComponent(args.userId)}/preferences`)),
);
server.registerTool(
"update_user_preferences",
{
title: "Update user preferences",
description: "Update notification channel/topic opt-ins and quiet hours settings for a user.",
inputSchema: {
userId: z.string().min(1).describe("User id."),
channels: z
.record(z.boolean())
.optional()
.describe("Per-channel opt-in map, e.g. { email: true, sms: false }."),
topics: z.record(z.boolean()).optional().describe("Per-topic opt-in map."),
quietHours: z
.array(
z.object({
start: z.string().describe("HH:MM, 24h, UTC."),
end: z.string().describe("HH:MM, 24h, UTC."),
}),
)
.optional()
.describe("Windows during which non-critical sends are deferred."),
},
annotations: { title: "Update user preferences", readOnlyHint: false, idempotentHint: true },
},
handler(async ({ userId, ...preferences }) =>
api.patch(`/v1/users/${encodeURIComponent(userId)}/preferences`, preferences),
),
);
// ─── Workflows ──────────────────────────────────────────────────────────────
server.registerTool(
@@ -613,10 +1145,23 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
title: "List workflows",
description:
"List registered workflow definitions and their steps. Call this to find the name to pass to " +
"trigger_workflow, or to check a sequence before triggering it.",
"trigger_workflow, or to check a sequence before triggering it. Supports filtering by search keyword.",
inputSchema: {
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Maximum number of workflow definitions to return."),
search: z
.string()
.optional()
.describe("Filter workflow definitions whose name contains this search keyword."),
},
annotations: { title: "List workflows", readOnlyHint: true },
},
handler(async () => api.get("/v1/workflows")),
handler(async (args) => api.get("/v1/workflows", args)),
);
server.registerTool(
@@ -744,4 +1289,163 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
},
handler(async (args) => api.post("/v1/dlq/replay", { id: args.id })),
);
server.registerTool(
"delete_dead_letter",
{
title: "Delete a dead-lettered message",
description: "Permanently delete/dismiss a dead-letter message from the DLQ by its entry ID.",
inputSchema: {
id: z.string().min(1).describe("Dead-letter entry id from get_dead_letters."),
},
annotations: {
title: "Delete a dead-letter message",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
handler(async (args) => {
await api.delete(`/v1/dlq/${encodeURIComponent(args.id)}`);
return `Deleted dead letter message ${args.id}.`;
}),
);
server.registerTool(
"get_system_metrics",
{
title: "Get system stream metrics",
description:
"Report queue depths across all Redis streams and delivery stats for the project.",
annotations: { title: "Get system stream metrics", readOnlyHint: true },
},
handler(async () => api.get("/v1/system/metrics")),
);
// ─── Projects ───────────────────────────────────────────────────────────────
server.registerTool(
"list_projects",
{
title: "List projects",
description: "List all projects configured in this Notifkit deployment.",
annotations: { title: "List projects", readOnlyHint: true },
},
handler(async () => api.get("/v1/projects")),
);
server.registerTool(
"create_project",
{
title: "Create a project",
description:
"Create a new tenant project with a name and generate its initial admin API key.",
inputSchema: {
name: z.string().min(1).describe("Project name."),
},
annotations: { title: "Create a project", readOnlyHint: false },
},
handler(async (args) => api.post("/v1/projects", { name: args.name })),
);
server.registerTool(
"update_project",
{
title: "Update a project",
description: "Update project settings including rate limits and throttle limits.",
inputSchema: {
id: z.string().min(1).describe("Project id."),
rateLimitRpm: z.number().nullable().optional().describe("Requests per minute limit."),
throttleLimit: z
.number()
.nullable()
.optional()
.describe("Max notifications per throttle window."),
throttleWindowHours: z.number().nullable().optional().describe("Throttle window in hours."),
},
annotations: { title: "Update a project", readOnlyHint: false, idempotentHint: true },
},
handler(async ({ id, ...settings }) =>
api.patch(`/v1/projects/${encodeURIComponent(id)}`, settings),
),
);
server.registerTool(
"delete_project",
{
title: "Delete a project",
description: "Permanently delete a project and its associated resources.",
inputSchema: {
id: z.string().min(1).describe("Project id to delete."),
},
annotations: {
title: "Delete a project",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
handler(async (args) => {
await api.delete(`/v1/projects/${encodeURIComponent(args.id)}`);
return `Deleted project ${args.id}.`;
}),
);
server.registerTool(
"list_project_keys",
{
title: "List project API keys",
description: "List all API keys created for a specific project.",
inputSchema: {
projectId: z.string().min(1).describe("Project id."),
},
annotations: { title: "List project API keys", readOnlyHint: true },
},
handler(async (args) => api.get(`/v1/projects/${encodeURIComponent(args.projectId)}/keys`)),
);
server.registerTool(
"create_project_key",
{
title: "Create a project API key",
description: "Generate a new API key with 'admin' or 'read_only' role for a project.",
inputSchema: {
projectId: z.string().min(1).describe("Project id."),
role: z
.enum(["admin", "read_only"])
.optional()
.describe("Role for this API key. Defaults to 'admin'."),
},
annotations: { title: "Create a project API key", readOnlyHint: false },
},
handler(async (args) =>
api.post(`/v1/projects/${encodeURIComponent(args.projectId)}/keys`, {
role: args.role ?? "admin",
}),
),
);
server.registerTool(
"delete_project_key",
{
title: "Delete a project API key",
description: "Revoke and delete an API key for a project.",
inputSchema: {
projectId: z.string().min(1).describe("Project id."),
keyId: z.string().min(1).describe("Key id to revoke."),
},
annotations: {
title: "Delete a project API key",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
handler(async (args) => {
await api.delete(
`/v1/projects/${encodeURIComponent(args.projectId)}/keys/${encodeURIComponent(args.keyId)}`,
);
return `Deleted API key ${args.keyId} for project ${args.projectId}.`;
}),
);
}
+55 -5
View File
@@ -121,8 +121,15 @@ export class NotifkitClient {
// ─── Missing Endpoints additions ─────────────────────────────────────────────
/** List registered workflow definitions. */
async listWorkflows(): Promise<{ workflows: any[] }> {
return this.request("/v1/workflows", "GET");
async listWorkflows(options?: {
limit?: number;
search?: string;
}): Promise<{ workflows: any[] }> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", options.limit.toString());
if (options?.search) params.set("search", options.search);
const qs = params.toString();
return this.request(`/v1/workflows${qs ? `?${qs}` : ""}`, "GET");
}
/** Get a workflow instance by ID. */
@@ -143,6 +150,9 @@ export class NotifkitClient {
workflowInstanceId?: string;
channel?: string;
status?: string;
taskId?: string;
campaign?: string;
search?: string;
}): Promise<{ logs: any[]; nextCursor: string | null }> {
let url = "/v1/notifications/logs";
if (options) {
@@ -154,6 +164,9 @@ export class NotifkitClient {
params.append("workflowInstanceId", options.workflowInstanceId);
if (options.channel) params.append("channel", options.channel);
if (options.status) params.append("status", options.status);
if (options.taskId) params.append("taskId", options.taskId);
if (options.campaign) params.append("campaign", options.campaign);
if (options.search) params.append("search", options.search);
const str = params.toString();
if (str) url += `?${str}`;
}
@@ -164,10 +177,20 @@ export class NotifkitClient {
async listUsers(options?: {
limit?: number;
cursor?: string;
search?: string;
segment?: string;
language?: string;
timezone?: string;
channel?: string;
}): Promise<{ users: any[]; nextCursor: string | null }> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", options.limit.toString());
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.search) params.set("search", options.search);
if (options?.segment) params.set("segment", options.segment);
if (options?.language) params.set("language", options.language);
if (options?.timezone) params.set("timezone", options.timezone);
if (options?.channel) params.set("channel", options.channel);
const qs = params.toString();
return this.request(`/v1/users${qs ? `?${qs}` : ""}`, "GET");
}
@@ -223,7 +246,14 @@ export class NotifkitClient {
// ─── Campaigns ───────────────────────────────────────────────────────────────
/** List campaign labels seen in the delivery log, most recent activity first. */
async listCampaigns(options?: { limit?: number }): Promise<{
async listCampaigns(options?: {
limit?: number;
search?: string;
channel?: string;
since?: string | Date;
until?: string | Date;
minMessages?: number;
}): Promise<{
campaigns: {
campaign: string;
messages: number;
@@ -231,8 +261,26 @@ export class NotifkitClient {
lastActivityAt: string;
}[];
}> {
const qs = options?.limit ? `?limit=${options.limit}` : "";
return this.request(`/v1/campaigns${qs}`, "GET");
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
if (options?.search) params.set("search", options.search);
if (options?.channel) params.set("channel", options.channel);
if (options?.since) {
params.set(
"since",
options.since instanceof Date ? options.since.toISOString() : options.since,
);
}
if (options?.until) {
params.set(
"until",
options.until instanceof Date ? options.until.toISOString() : options.until,
);
}
if (options?.minMessages) params.set("minMessages", String(options.minMessages));
const qs = params.toString();
return this.request(`/v1/campaigns${qs ? `?${qs}` : ""}`, "GET");
}
/** Delivery and engagement funnel for one campaign. */
@@ -253,11 +301,13 @@ export class NotifkitClient {
limit?: number;
channel?: string;
reason?: string;
target?: string;
}): Promise<{ suppressions: any[] }> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", options.limit.toString());
if (options?.channel) params.set("channel", options.channel);
if (options?.reason) params.set("reason", options.reason);
if (options?.target) params.set("target", options.target);
const qs = params.toString();
return this.request(`/v1/suppressions${qs ? `?${qs}` : ""}`, "GET");
}
+45 -19
View File
@@ -596,31 +596,57 @@ export class UserRepository {
projectId: string,
limit: number,
cursor?: string,
filters?: {
search?: string;
segment?: string;
language?: string;
timezone?: string;
channel?: string;
},
): Promise<{ users: UserProfile[]; nextCursor: string | null }> {
let query = this.db
const conditions = [eq(users.projectId, projectId)];
if (cursor) {
const cursorDate = new Date(parseInt(cursor, 10));
if (!isNaN(cursorDate.getTime())) {
conditions.push(drizzleSql`${users.createdAt} < ${cursorDate.toISOString()}`);
}
}
if (filters?.language) {
conditions.push(drizzleSql`(${users.attributes}->>'language') = ${filters.language}`);
}
if (filters?.timezone) {
conditions.push(drizzleSql`(${users.attributes}->>'timezone') = ${filters.timezone}`);
}
if (filters?.search) {
const term = `%${filters.search.trim()}%`;
conditions.push(
drizzleSql`(${users.externalId} ILIKE ${term} OR (${users.attributes}->>'email') ILIKE ${term})`,
);
}
if (filters?.segment) {
conditions.push(
drizzleSql`EXISTS (SELECT 1 FROM ${userSegments} WHERE ${userSegments.userId} = ${users.id} AND ${userSegments.segment} = ${filters.segment})`,
);
}
if (filters?.channel) {
conditions.push(
drizzleSql`EXISTS (SELECT 1 FROM ${userContacts} WHERE ${userContacts.userId} = ${users.id} AND ${userContacts.channel} = ${filters.channel})`,
);
}
const rows = await this.db
.select()
.from(users)
.where(eq(users.projectId, projectId))
.where(and(...conditions))
.orderBy(desc(users.createdAt))
.limit(limit);
if (cursor) {
// Decode cursor (timestamp)
const cursorDate = new Date(parseInt(cursor, 10));
query = this.db
.select()
.from(users)
.where(
and(
eq(users.projectId, projectId),
drizzleSql`${users.createdAt} < ${cursorDate.toISOString()}`,
),
)
.orderBy(desc(users.createdAt))
.limit(limit) as any;
}
const rows = await query;
const items = rows.map((r) => {
const attrs = r.attributes as any;
return {
+73 -18
View File
@@ -32,7 +32,7 @@ import { readJsonBody, sendJson, sendNoContent, sendValidationError } from "./ht
import type { RouteContext } from "./router.js";
import { globalEmitter, getPriorityBucket, normaliseTarget } from "@/shared/index.js";
import { metrics } from "@/metrics/index.js";
import { eq, desc, and, lt, sql, like, or, isNotNull } from "drizzle-orm";
import { eq, desc, and, lt, lte, gte, sql, like, or, isNotNull } from "drizzle-orm";
import {
messageLogs,
workflowDefinitions,
@@ -547,12 +547,13 @@ export function createHandlers(deps: Deps) {
const limit = isNaN(limitParam) ? 50 : Math.min(limitParam, 100);
const cursor = ctx.query.get("cursor");
const templateId = ctx.query.get("templateId");
const workflowInstanceId = ctx.query.get("workflowInstanceId");
const channel = ctx.query.get("channel");
const status = ctx.query.get("status");
const taskId = ctx.query.get("taskId");
const search = ctx.query.get("search");
const templateId = getQueryParam(ctx, "templateId");
const workflowInstanceId = getQueryParam(ctx, "workflowInstanceId");
const channel = getQueryParam(ctx, "channel");
const status = getQueryParam(ctx, "status");
const taskId = getQueryParam(ctx, "taskId");
const campaign = getQueryParam(ctx, "campaign") || getQueryParam(ctx, "campaignId");
const search = getQueryParam(ctx, "search");
const conditions = [eq(messageLogs.projectId, ctx.projectId!)];
if (templateId) conditions.push(eq(messageLogs.templateId, templateId));
@@ -560,12 +561,14 @@ export function createHandlers(deps: Deps) {
if (channel) conditions.push(eq(messageLogs.channel, channel as any));
if (status) conditions.push(eq(messageLogs.status, status as any));
if (taskId) conditions.push(eq(messageLogs.taskId, taskId));
if (campaign) conditions.push(eq(messageLogs.campaignId, campaign));
if (search) {
conditions.push(
or(
like(messageLogs.taskId, `%${search}%`),
like(messageLogs.templateId, `%${search}%`),
like(messageLogs.providerMessageId, `%${search}%`),
like(messageLogs.campaignId, `%${search}%`),
)!,
);
}
@@ -767,7 +770,11 @@ export function createHandlers(deps: Deps) {
): Promise<void> {
const limitParam = getQueryParam(ctx, "limit");
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : undefined;
const search = getQueryParam(ctx, "search")?.trim().toLowerCase();
let workflows = await deps.workflowRepo.listDefinitions(ctx.projectId!);
if (search) {
workflows = workflows.filter((w: any) => w.name?.toLowerCase().includes(search));
}
if (limit) {
workflows = workflows.slice(0, limit);
}
@@ -807,11 +814,21 @@ export function createHandlers(deps: Deps) {
res: ServerResponse,
ctx: RouteContext,
): Promise<void> {
const limitParam = parseInt(ctx.query.get("limit") || "50", 10);
const limitParam = parseInt(getQueryParam(ctx, "limit") || "50", 10);
const limit = isNaN(limitParam) ? 50 : Math.min(limitParam, 100);
const cursor = ctx.query.get("cursor") ?? undefined;
const cursor = getQueryParam(ctx, "cursor");
const search = getQueryParam(ctx, "search");
const segment = getQueryParam(ctx, "segment");
const language = getQueryParam(ctx, "language");
const timezone = getQueryParam(ctx, "timezone");
const channel = getQueryParam(ctx, "channel");
const result = await deps.userRepo.list(ctx.projectId!, limit, cursor);
const filters =
search || segment || language || timezone || channel
? { search, segment, language, timezone, channel }
: undefined;
const result = await deps.userRepo.list(ctx.projectId!, limit, cursor, filters);
sendJson(res, 200, result);
}
@@ -1405,17 +1422,47 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
// ── GET /v1/campaigns — listCampaigns ─────────────────────────────────────
//
// One row per campaign label, newest activity first, so "the one I sent
// yesterday" is findable without knowing its name exactly.
// One row per campaign label, newest activity first, with support for filtering
// by search keyword, channel, date range, and minimum message counts.
async function listCampaigns(
_req: IncomingMessage,
res: ServerResponse,
ctx: RouteContext,
): Promise<void> {
const limitParam = parseInt(ctx.query.get("limit") || "20", 10);
const limitParam = parseInt(getQueryParam(ctx, "limit") || "20", 10);
const limit = isNaN(limitParam) ? 20 : Math.min(Math.max(limitParam, 1), 100);
const search = getQueryParam(ctx, "search")?.trim();
const channel = getQueryParam(ctx, "channel")?.trim();
const since = getQueryParam(ctx, "since")?.trim();
const until = getQueryParam(ctx, "until")?.trim();
const minMessagesParam = parseInt(getQueryParam(ctx, "minMessages") || "", 10);
const minMessages = isNaN(minMessagesParam) ? undefined : Math.max(minMessagesParam, 1);
const rows = await deps.db
const conditions = [
eq(messageLogs.projectId, ctx.projectId!),
isNotNull(messageLogs.campaignId),
];
if (search) {
conditions.push(like(messageLogs.campaignId, `%${search}%`));
}
if (channel) {
conditions.push(eq(messageLogs.channel, channel as any));
}
if (since) {
const sinceDate = new Date(since);
if (!isNaN(sinceDate.getTime())) {
conditions.push(gte(messageLogs.timestamp, sinceDate));
}
}
if (until) {
const untilDate = new Date(until);
if (!isNaN(untilDate.getTime())) {
conditions.push(lte(messageLogs.timestamp, untilDate));
}
}
let query = deps.db
.select({
campaign: messageLogs.campaignId,
messages: sql<number>`count(distinct ${messageLogs.taskId})`,
@@ -1423,11 +1470,17 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
lastActivityAt: sql<string>`max(${messageLogs.timestamp})`,
})
.from(messageLogs)
.where(and(eq(messageLogs.projectId, ctx.projectId!), isNotNull(messageLogs.campaignId)))
.where(and(...conditions))
.groupBy(messageLogs.campaignId)
.orderBy(desc(sql`max(${messageLogs.timestamp})`))
.limit(limit);
if (minMessages !== undefined) {
query = query.having(gte(sql`count(distinct ${messageLogs.taskId})`, minMessages)) as any;
}
const rows = await query;
sendJson(res, 200, {
campaigns: rows.map((r: any) => ({
campaign: r.campaign,
@@ -1553,14 +1606,16 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
res: ServerResponse,
ctx: RouteContext,
): Promise<void> {
const limitParam = parseInt(ctx.query.get("limit") || "100", 10);
const limitParam = parseInt(getQueryParam(ctx, "limit") || "100", 10);
const limit = isNaN(limitParam) ? 100 : Math.min(Math.max(limitParam, 1), 500);
const channel = ctx.query.get("channel");
const reason = ctx.query.get("reason");
const channel = getQueryParam(ctx, "channel");
const reason = getQueryParam(ctx, "reason");
const target = getQueryParam(ctx, "target");
const conditions = [eq(suppressions.projectId, ctx.projectId!)];
if (channel) conditions.push(eq(suppressions.channel, channel as any));
if (reason) conditions.push(eq(suppressions.reason, reason));
if (target) conditions.push(like(suppressions.target, `%${target.toLowerCase().trim()}%`));
const rows = await deps.db
.select()
+21 -12
View File
@@ -19,10 +19,14 @@ export function interpolate(
variables: Record<string, unknown>,
sanitize = true,
): string {
return tmpl.replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
const val = String(variables[k] ?? "");
return sanitize ? escapeHtml(val) : val;
});
return tmpl
.replace(/\{\{\{(\w+)\}\}\}/g, (_, k: string) => {
return String(variables[k] ?? "");
})
.replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
const val = String(variables[k] ?? "");
return sanitize ? escapeHtml(val) : val;
});
}
/**
@@ -62,20 +66,25 @@ function applyEscape(value: string, mode: EscapeMode): string {
/**
* Interpolate `{{var}}` placeholders in a single leaf string.
*
* Escaping is applied to the SUBSTITUTED VALUE only — never to the surrounding
* template — so template authors keep their own markup while caller-supplied
* data cannot break out of it.
* `{{{var}}}` (triple braces) interpolates raw unescaped values.
* `{{var}}` (double braces) applies contextual escaping to the substituted value.
*/
function interpolateLeaf(
tmpl: string,
variables: Record<string, unknown>,
mode: EscapeMode,
): string {
return tmpl.replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
const raw = variables[k];
if (raw === undefined || raw === null) return "";
return applyEscape(typeof raw === "string" ? raw : JSON.stringify(raw), mode);
});
return tmpl
.replace(/\{\{\{(\w+)\}\}\}/g, (_, k: string) => {
const raw = variables[k];
if (raw === undefined || raw === null) return "";
return typeof raw === "string" ? raw : JSON.stringify(raw);
})
.replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
const raw = variables[k];
if (raw === undefined || raw === null) return "";
return applyEscape(typeof raw === "string" ? raw : JSON.stringify(raw), mode);
});
}
/**
+30 -2
View File
@@ -145,12 +145,18 @@ describe("API compliance handlers", () => {
expect(mockDb.selects[0]!.limit).toBe(100);
});
it("narrows the query when channel and reason are given", async () => {
it("narrows the query when channel, reason, and target are given", async () => {
mockDb.queueSelect([]);
await handlers.listSuppressions(
createMockReq(),
createMockRes(),
ctx({ query: new URLSearchParams({ channel: "email", reason: "complained" }) }),
ctx({
query: new URLSearchParams({
channel: "email",
reason: "complained",
target: "user@example.com",
}),
}),
);
// where + orderBy + limit, all against the suppressions table.
@@ -335,6 +341,28 @@ describe("API compliance handlers", () => {
);
expect(mockDb.selects[1]!.limit).toBe(100);
});
it("applies search, channel, date range, and minMessages filters", async () => {
mockDb.queueSelect([]);
const res = createMockRes();
await handlers.listCampaigns(
createMockReq(),
res,
ctx({
query: new URLSearchParams({
search: "spring",
channel: "email",
since: "2026-05-01T00:00:00.000Z",
until: "2026-05-31T23:59:59.000Z",
minMessages: "10",
}),
}),
);
expect(res.statusCode).toBe(200);
expect(mockDb.selects[0]!.chain).toContain("where");
expect(mockDb.selects[0]!.chain).toContain("having");
});
});
describe("getCampaignStats", () => {
+52 -1
View File
@@ -324,6 +324,9 @@ describe("NotifkitClient", () => {
workflowInstanceId: "wf_1",
channel: "email",
status: "delivered",
taskId: "task_1",
campaign: "camp_1",
search: "keyword",
});
const { url } = callArgs(fetchMock);
@@ -333,6 +336,27 @@ describe("NotifkitClient", () => {
expect(url).toContain("workflowInstanceId=wf_1");
expect(url).toContain("channel=email");
expect(url).toContain("status=delivered");
expect(url).toContain("taskId=task_1");
expect(url).toContain("campaign=camp_1");
expect(url).toContain("search=keyword");
});
it("getNotificationStatus GETs /v1/notifications/:taskId", async () => {
const fetchMock = stubFetch({ status: "delivered", logs: [] });
await client().getNotificationStatus("task-123");
const { url, init } = callArgs(fetchMock);
expect(url).toBe("https://api.test/v1/notifications/task-123");
expect(init.method).toBe("GET");
});
it("cancelNotification DELETEs /v1/notifications/:taskId", async () => {
const fetchMock = stubFetch({ success: true });
await client().cancelNotification("task-123");
const { url, init } = callArgs(fetchMock);
expect(url).toBe("https://api.test/v1/notifications/task-123");
expect(init.method).toBe("DELETE");
});
});
@@ -535,6 +559,27 @@ describe("NotifkitClient", () => {
expect(callArgs(fetchMock).url).toBe("https://api.test/v1/campaigns?limit=15");
});
it("listCampaigns serializes search, channel, date ranges, and minMessages filters", async () => {
const fetchMock = stubFetch({ campaigns: [] });
await client().listCampaigns({
limit: 10,
search: "invoice",
channel: "email",
since: "2026-08-01T00:00:00.000Z",
until: "2026-08-20T00:00:00.000Z",
minMessages: 5,
});
const url = new URL(callArgs(fetchMock).url);
expect(url.pathname).toBe("/v1/campaigns");
expect(url.searchParams.get("limit")).toBe("10");
expect(url.searchParams.get("search")).toBe("invoice");
expect(url.searchParams.get("channel")).toBe("email");
expect(url.searchParams.get("since")).toBe("2026-08-01T00:00:00.000Z");
expect(url.searchParams.get("until")).toBe("2026-08-20T00:00:00.000Z");
expect(url.searchParams.get("minMessages")).toBe("5");
});
it("getCampaignStats GETs /v1/campaigns/:campaign/stats and encodes special characters", async () => {
const fetchMock = stubFetch({ campaign: "spring promo / sale", totals: {}, byChannel: {} });
await client().getCampaignStats("spring promo / sale");
@@ -555,12 +600,18 @@ describe("NotifkitClient", () => {
it("listSuppressions appends all query parameters when provided", async () => {
const fetchMock = stubFetch({ suppressions: [] });
await client().listSuppressions({ limit: 20, channel: "email", reason: "unsubscribed" });
await client().listSuppressions({
limit: 20,
channel: "email",
reason: "unsubscribed",
target: "user@example.com",
});
const { url } = callArgs(fetchMock);
expect(url).toContain("limit=20");
expect(url).toContain("channel=email");
expect(url).toContain("reason=unsubscribed");
expect(url).toContain("target=user%40example.com");
});
it("createSuppression POSTs to /v1/suppressions", async () => {
+329
View File
@@ -0,0 +1,329 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { NotifkitApi, NotifkitApiError } from "../packages/mcp/src/client.js";
import { createServer } from "../packages/mcp/src/index.js";
import { registerTools } from "../packages/mcp/src/tools.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
function stubFetch(body: unknown, status = 200) {
const fn = vi.fn().mockResolvedValue({
status,
ok: status >= 200 && status < 300,
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
json: async () => body,
});
vi.stubGlobal("fetch", fn);
return fn;
}
function callArgs(fn: ReturnType<typeof stubFetch>, n = 0) {
const [url, init] = fn.mock.calls[n]!;
return { url: url as string, init: init as RequestInit };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("NotifkitApi (MCP Client)", () => {
const api = new NotifkitApi({
baseUrl: "https://api.test",
apiKey: "nk_test_123",
projectId: "proj_1",
});
it("handles GET requests with query params", async () => {
const fetchMock = stubFetch({ templates: [] });
const res = await api.get("/v1/templates", { channel: "email", limit: 10 });
expect(res).toEqual({ templates: [] });
const args = callArgs(fetchMock);
expect(args.url).toBe("https://api.test/v1/templates?channel=email&limit=10");
expect(args.init.method).toBe("GET");
expect(args.init.headers).toMatchObject({
Authorization: "Bearer nk_test_123",
"x-project-id": "proj_1",
});
});
it("handles POST requests with JSON payload", async () => {
const fetchMock = stubFetch({ id: "proj_new" });
const res = await api.post("/v1/projects", { name: "Test Project" });
expect(res).toEqual({ id: "proj_new" });
const args = callArgs(fetchMock);
expect(args.url).toBe("https://api.test/v1/projects");
expect(args.init.method).toBe("POST");
expect(args.init.body).toBe(JSON.stringify({ name: "Test Project" }));
});
it("handles PATCH requests", async () => {
const fetchMock = stubFetch({ id: "usr_1" });
const res = await api.patch("/v1/users/usr_1", { timezone: "Europe/London" });
expect(res).toEqual({ id: "usr_1" });
const args = callArgs(fetchMock);
expect(args.url).toBe("https://api.test/v1/users/usr_1");
expect(args.init.method).toBe("PATCH");
expect(args.init.body).toBe(JSON.stringify({ timezone: "Europe/London" }));
});
it("handles DELETE requests (204 No Content)", async () => {
const fetchMock = stubFetch("", 204);
const res = await api.delete("/v1/templates/t_1");
expect(res).toBeUndefined();
const args = callArgs(fetchMock);
expect(args.url).toBe("https://api.test/v1/templates/t_1");
expect(args.init.method).toBe("DELETE");
});
it("throws NotifkitApiError on HTTP failure", async () => {
stubFetch({ error: "not_found", message: "Template not found" }, 404);
await expect(api.get("/v1/templates/t_missing")).rejects.toThrow(NotifkitApiError);
});
});
describe("MCP Server Tool Registration", () => {
it("registers all existing and newly implemented tools", () => {
const server = new McpServer({ name: "notifkit-test", version: "0.1.0" });
const api = new NotifkitApi({ baseUrl: "https://api.test", apiKey: "nk_key" });
registerTools(server, api);
// Access registered tool names from the server instance
const registeredTools = Object.keys((server as any)._registeredTools || {});
// Core existing tools
expect(registeredTools).toContain("send_notification");
expect(registeredTools).toContain("send_campaign");
expect(registeredTools).toContain("list_campaigns");
expect(registeredTools).toContain("get_campaign_stats");
expect(registeredTools).toContain("list_templates");
expect(registeredTools).toContain("get_template");
expect(registeredTools).toContain("upsert_template");
expect(registeredTools).toContain("list_users");
expect(registeredTools).toContain("get_user");
expect(registeredTools).toContain("upsert_user");
expect(registeredTools).toContain("preview_template");
expect(registeredTools).toContain("render_template");
expect(registeredTools).toContain("delete_template");
expect(registeredTools).toContain("update_user");
expect(registeredTools).toContain("delete_user");
expect(registeredTools).toContain("get_user_contacts");
expect(registeredTools).toContain("delete_user_contact");
expect(registeredTools).toContain("get_user_preferences");
expect(registeredTools).toContain("update_user_preferences");
expect(registeredTools).toContain("delete_dead_letter");
expect(registeredTools).toContain("get_system_metrics");
expect(registeredTools).toContain("list_projects");
expect(registeredTools).toContain("create_project");
expect(registeredTools).toContain("update_project");
expect(registeredTools).toContain("delete_project");
expect(registeredTools).toContain("list_project_keys");
expect(registeredTools).toContain("create_project_key");
expect(registeredTools).toContain("delete_project_key");
});
it("executes preview_template and render_template tool handlers", async () => {
stubFetch({
id: "tmpl_welcome",
channel: "email",
content: {
subject: "Welcome, {{name}}!",
body: "Hello {{name}}, your plan is {{{plan}}}.",
},
});
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
// preview by id
const previewRes = await tools["preview_template"].handler({
id: "tmpl_welcome",
data: { name: "Alice", plan: "Pro" },
});
expect(previewRes.isError).toBeFalsy();
const previewData = JSON.parse(previewRes.content[0].text);
expect(previewData.rendered.subject).toBe("Welcome, Alice!");
expect(previewData.rendered.body).toBe("Hello Alice, your plan is Pro.");
expect(previewData.resolvedVariables).toEqual(["name", "plan"]);
// render with raw content
const renderRes = await tools["render_template"].handler({
content: { subject: "Order #{{orderId}}", text: "Total: {{total}}" },
data: { orderId: "123", total: "$50" },
});
expect(renderRes.isError).toBeFalsy();
const renderData = JSON.parse(renderRes.content[0].text);
expect(renderData.rendered.subject).toBe("Order #123");
expect(renderData.rendered.text).toBe("Total: $50");
});
it("executes send_campaign with multichannel support", async () => {
const fetchMock = stubFetch({ messageId: "msg_1" }, 202);
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
// SMS campaign with recipients
const smsRes = await tools["send_campaign"].handler({
campaign: "sms-promo",
template: "tmpl_sms",
channel: "sms",
recipients: ["+15551234567", "+15557654321"],
});
expect(smsRes.isError).toBeFalsy();
const smsData = JSON.parse(smsRes.content[0].text);
expect(smsData.channel).toBe("sms");
expect(smsData.queued).toBe(2);
const call = callArgs(fetchMock);
expect(call.url).toBe("https://api.test/v1/notify");
const body = JSON.parse(call.init.body as string);
expect(body.channels).toEqual(["sms"]);
expect(body.user).toEqual([
{ id: "phone:+15551234567", phone: "+15551234567" },
{ id: "phone:+15557654321", phone: "+15557654321" },
]);
});
it("executes delete_template tool handler", async () => {
const fetchMock = stubFetch("", 204);
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
const result = await tools["delete_template"].handler({ id: "tmpl_abc" });
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("Deleted template tmpl_abc");
expect(callArgs(fetchMock).url).toBe("https://api.test/v1/templates/tmpl_abc");
expect(callArgs(fetchMock).init.method).toBe("DELETE");
});
it("executes update_user tool handler", async () => {
const fetchMock = stubFetch({ id: "usr_1" });
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
const result = await tools["update_user"].handler({
id: "usr_1",
timezone: "America/New_York",
});
expect(result.isError).toBeFalsy();
expect(callArgs(fetchMock).url).toBe("https://api.test/v1/users/usr_1");
expect(callArgs(fetchMock).init.method).toBe("PATCH");
});
it("executes delete_user tool handler", async () => {
const fetchMock = stubFetch("", 204);
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
const result = await tools["delete_user"].handler({ id: "usr_1" });
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("Deleted user usr_1");
expect(callArgs(fetchMock).url).toBe("https://api.test/v1/users/usr_1");
expect(callArgs(fetchMock).init.method).toBe("DELETE");
});
it("executes delete_user_contact tool handler", async () => {
const fetchMock = stubFetch("", 204);
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
const result = await tools["delete_user_contact"].handler({
userId: "usr_1",
channel: "email",
target: "old@example.com",
});
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain(
"Deleted email contact 'old@example.com' for user usr_1",
);
expect(callArgs(fetchMock).url).toBe(
"https://api.test/v1/users/usr_1/contacts/email/old%40example.com",
);
expect(callArgs(fetchMock).init.method).toBe("DELETE");
});
it("executes project tools handlers", async () => {
stubFetch({ projects: [{ id: "proj_1", name: "Main" }] });
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
// list_projects
const listRes = await tools["list_projects"].handler({});
expect(listRes.isError).toBeFalsy();
expect(JSON.parse(listRes.content[0].text)).toEqual({
projects: [{ id: "proj_1", name: "Main" }],
});
// create_project
stubFetch({ id: "proj_2", apiKey: "nk_live_123" });
const createRes = await tools["create_project"].handler({ name: "Staging" });
expect(createRes.isError).toBeFalsy();
expect(JSON.parse(createRes.content[0].text)).toEqual({ id: "proj_2", apiKey: "nk_live_123" });
// delete_project
stubFetch("", 204);
const deleteRes = await tools["delete_project"].handler({ id: "proj_2" });
expect(deleteRes.isError).toBeFalsy();
expect(deleteRes.content[0].text).toContain("Deleted project proj_2");
});
it("executes dead-letter delete and metrics handlers", async () => {
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
// delete_dead_letter
const fetchMock = stubFetch("", 204);
const delRes = await tools["delete_dead_letter"].handler({ id: "dlq_msg_123" });
expect(delRes.isError).toBeFalsy();
expect(delRes.content[0].text).toContain("Deleted dead letter message dlq_msg_123");
expect(callArgs(fetchMock).url).toBe("https://api.test/v1/dlq/dlq_msg_123");
// get_system_metrics
stubFetch({ streams: { DEAD_LETTER: 0 }, deliveryStats: { total: 10, delivered: 10 } });
const metricsRes = await tools["get_system_metrics"].handler({});
expect(metricsRes.isError).toBeFalsy();
expect(JSON.parse(metricsRes.content[0].text).deliveryStats.delivered).toBe(10);
});
it("passes query filters correctly for list_templates, list_scheduled, list_workflows, and list_users", async () => {
const server = createServer({ baseUrl: "https://api.test", apiKey: "nk_key" });
const tools = (server as any)._registeredTools;
// list_templates with channel, topic, limit
const fetchTemplates = stubFetch({ templates: [{ id: "tmpl_1" }] });
await tools["list_templates"].handler({ channel: "email", topic: "marketing", limit: 25 });
expect(callArgs(fetchTemplates).url).toBe(
"https://api.test/v1/templates?channel=email&topic=marketing&limit=25",
);
// list_scheduled with channel, cursor, limit
const fetchScheduled = stubFetch({ scheduled: [] });
await tools["list_scheduled"].handler({ channel: "sms", cursor: "task_99", limit: 10 });
expect(callArgs(fetchScheduled).url).toBe(
"https://api.test/v1/notifications/scheduled?channel=sms&cursor=task_99&limit=10",
);
// list_workflows with search and limit
const fetchWorkflows = stubFetch({ workflows: [] });
await tools["list_workflows"].handler({ search: "onboarding", limit: 5 });
expect(callArgs(fetchWorkflows).url).toBe(
"https://api.test/v1/workflows?search=onboarding&limit=5",
);
// list_users with search, segment, language, timezone, channel
const fetchUsers = stubFetch({ users: [] });
await tools["list_users"].handler({
search: "alice",
segment: "vip",
language: "en",
timezone: "Europe/London",
channel: "sms",
limit: 50,
});
expect(callArgs(fetchUsers).url).toBe(
"https://api.test/v1/users?search=alice&segment=vip&language=en&timezone=Europe%2FLondon&channel=sms&limit=50",
);
});
});
+4 -2
View File
@@ -1,15 +1,17 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"moduleResolution": "node",
"moduleResolution": "Bundler",
"outDir": "./dist"
},
"include": [
"src/**/*",
"tests/**/*",
"packages/*/src/**/*",
"vitest.config.ts",
"tsdown.config.ts",
"tsup.config.ts",
"drizzle.config.ts"
]
],
"exclude": ["node_modules", "dist", "coverage", "packages/*/dist", "**/*.d.ts"]
}