From e565cca5a4a9f3900633c493d44aaa7a5443cc70 Mon Sep 17 00:00:00 2001 From: coo1estguy <70365929+coolestcodeguy@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:41:08 +0530 Subject: [PATCH] feat(mcp): add template preview/render, multichannel campaign, workflow search, and user filtering --- packages/mcp/src/client.ts | 8 + packages/mcp/src/index.ts | 23 +- packages/mcp/src/tools.ts | 766 +++++++++++++++++++++++++++++++-- src/client.ts | 60 ++- src/repositories/index.ts | 64 ++- src/services/api/handlers.ts | 91 +++- src/templates/render.ts | 33 +- tests/api-suppressions.test.ts | 32 +- tests/client.test.ts | 53 ++- tests/mcp-tools.test.ts | 329 ++++++++++++++ tsconfig.json | 6 +- 11 files changed, 1370 insertions(+), 95 deletions(-) create mode 100644 tests/mcp-tools.test.ts diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index d94cb86..2d36336 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -98,4 +98,12 @@ export class NotifkitApi { put(path: string, body?: unknown): Promise { return this.request("PUT", path, { body }); } + + patch(path: string, body?: unknown): Promise { + return this.request("PATCH", path, { body }); + } + + delete(path: string): Promise { + return this.request("DELETE", path); + } } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 4af45f9..90128d5 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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 { +export async function runServer(): Promise { 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 { 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); + }); +} diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index e155aee..fa84326 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -47,6 +47,79 @@ function handler(fn: (args: T) => Promise) { }; } +function escapeHtml(unsafe: string): string { + return String(unsafe) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +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, + 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, + 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 = {}; + for (const [key, value] of Object.entries(node as Record)) { + 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(); 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; + 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>("/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; + data?: Record; + 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; + }>(`/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}.`; + }), + ); } diff --git a/src/client.ts b/src/client.ts index 5505d30..a2ed6b1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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"); } diff --git a/src/repositories/index.ts b/src/repositories/index.ts index b2e7ec9..615608c 100644 --- a/src/repositories/index.ts +++ b/src/repositories/index.ts @@ -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 { diff --git a/src/services/api/handlers.ts b/src/services/api/handlers.ts index 60885fb..76d57d9 100644 --- a/src/services/api/handlers.ts +++ b/src/services/api/handlers.ts @@ -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 { 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 { - 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} // ── 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 { - 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`count(distinct ${messageLogs.taskId})`, @@ -1423,11 +1470,17 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px} lastActivityAt: sql`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} res: ServerResponse, ctx: RouteContext, ): Promise { - 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() diff --git a/src/templates/render.ts b/src/templates/render.ts index fd476bd..a23e9b9 100644 --- a/src/templates/render.ts +++ b/src/templates/render.ts @@ -19,10 +19,14 @@ export function interpolate( variables: Record, 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, 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); + }); } /** diff --git a/tests/api-suppressions.test.ts b/tests/api-suppressions.test.ts index ac4332a..1a7bf9a 100644 --- a/tests/api-suppressions.test.ts +++ b/tests/api-suppressions.test.ts @@ -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", () => { diff --git a/tests/client.test.ts b/tests/client.test.ts index 539037b..736d178 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -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 () => { diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts new file mode 100644 index 0000000..1a73fa4 --- /dev/null +++ b/tests/mcp-tools.test.ts @@ -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, 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", + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d2ece7c..0bd72a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }