mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
feat(mcp): add template preview/render, multichannel campaign, workflow search, and user filtering
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -47,6 +47,79 @@ function handler<T>(fn: (args: T) => Promise<unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe: string): string {
|
||||
return String(unsafe)
|
||||
.replace(/&/g, "&")
|
||||
.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<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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user