diff --git a/src/tools/channels.ts b/src/tools/channels.ts new file mode 100644 index 0000000..83b87d5 --- /dev/null +++ b/src/tools/channels.ts @@ -0,0 +1,417 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; +import { PaginationV1Schema } from "../schemas/common.js"; + +// --------------------------------------------------------------------------- +// Channel Connections + Channel Endpoints tools +// --------------------------------------------------------------------------- + +export function registerChannelsTools(server: McpServer): void { + // ========================================================================== + // Channel Connections + // ========================================================================== + + // ---- List Channel Connections -------------------------------------------- + server.registerTool( + "novu_list_channel_connections", + { + title: "List Channel Connections", + description: + "List all channel connections configured in the current environment. " + + "Supports offset-based pagination with limit and offset parameters.", + inputSchema: { + ...PaginationV1Schema, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/channel-connections", + "GET", + undefined, + { + limit: params.limit, + offset: params.offset, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Create Channel Connection ------------------------------------------- + server.registerTool( + "novu_create_channel_connection", + { + title: "Create Channel Connection", + description: + "Create a new channel connection by specifying a provider and channel type. " + + "Optionally include credentials and a human-readable name.", + inputSchema: { + provider_id: z + .string() + .describe("The provider identifier for the connection (e.g. 'sendgrid', 'twilio')"), + channel: z + .string() + .describe("The channel type (e.g. 'email', 'sms', 'push', 'chat', 'in_app')"), + credentials: z + .record(z.unknown()) + .optional() + .describe("Provider-specific credentials object"), + name: z + .string() + .optional() + .describe("Human-readable name for the connection"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/channel-connections", + "POST", + { + providerId: params.provider_id, + channel: params.channel, + credentials: params.credentials, + name: params.name, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Channel Connection ---------------------------------------------- + server.registerTool( + "novu_get_channel_connection", + { + title: "Get Channel Connection", + description: + "Retrieve a single channel connection by its ID, including provider, channel, credentials, and status.", + inputSchema: { + connection_id: z + .string() + .describe("The unique ID of the channel connection to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/channel-connections/${encodeURIComponent(params.connection_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Update Channel Connection ------------------------------------------- + server.registerTool( + "novu_update_channel_connection", + { + title: "Update Channel Connection", + description: + "Update an existing channel connection. Can modify credentials, name, or active status.", + inputSchema: { + connection_id: z + .string() + .describe("The unique ID of the channel connection to update"), + credentials: z + .record(z.unknown()) + .optional() + .describe("Updated provider-specific credentials object"), + name: z + .string() + .optional() + .describe("Updated human-readable name"), + active: z + .boolean() + .optional() + .describe("Whether the connection is active"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const body: Record = {}; + if (params.credentials !== undefined) body.credentials = params.credentials; + if (params.name !== undefined) body.name = params.name; + if (params.active !== undefined) body.active = params.active; + + const result = await novuRequest( + `/v1/channel-connections/${encodeURIComponent(params.connection_id)}`, + "PATCH", + body + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Channel Connection ------------------------------------------- + server.registerTool( + "novu_delete_channel_connection", + { + title: "Delete Channel Connection", + description: + "Permanently delete a channel connection by its ID. This cannot be undone.", + inputSchema: { + connection_id: z + .string() + .describe("The unique ID of the channel connection to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + await novuRequest( + `/v1/channel-connections/${encodeURIComponent(params.connection_id)}`, + "DELETE" + ); + return toolResult( + `Channel connection "${params.connection_id}" has been successfully deleted.` + ); + } catch (error) { + return toolError(error); + } + } + ); + + // ========================================================================== + // Channel Endpoints + // ========================================================================== + + // ---- List Channel Endpoints ---------------------------------------------- + server.registerTool( + "novu_list_channel_endpoints", + { + title: "List Channel Endpoints", + description: + "List all channel endpoints configured in the current environment. " + + "Supports offset-based pagination with limit and offset parameters.", + inputSchema: { + ...PaginationV1Schema, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/channel-endpoints", + "GET", + undefined, + { + limit: params.limit, + offset: params.offset, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Create Channel Endpoint --------------------------------------------- + server.registerTool( + "novu_create_channel_endpoint", + { + title: "Create Channel Endpoint", + description: + "Create a new channel endpoint by specifying a channel type and URL. " + + "Optionally include a name and description.", + inputSchema: { + channel: z + .string() + .describe("The channel type (e.g. 'email', 'sms', 'push', 'chat', 'in_app')"), + url: z + .string() + .describe("The endpoint URL to receive channel callbacks"), + name: z + .string() + .optional() + .describe("Human-readable name for the endpoint"), + description: z + .string() + .optional() + .describe("Description of what this endpoint does"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/channel-endpoints", + "POST", + { + channel: params.channel, + url: params.url, + name: params.name, + description: params.description, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Channel Endpoint ------------------------------------------------ + server.registerTool( + "novu_get_channel_endpoint", + { + title: "Get Channel Endpoint", + description: + "Retrieve a single channel endpoint by its ID, including channel type, URL, and status.", + inputSchema: { + endpoint_id: z + .string() + .describe("The unique ID of the channel endpoint to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/channel-endpoints/${encodeURIComponent(params.endpoint_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Update Channel Endpoint --------------------------------------------- + server.registerTool( + "novu_update_channel_endpoint", + { + title: "Update Channel Endpoint", + description: + "Update an existing channel endpoint. Can modify the URL, name, description, or active status.", + inputSchema: { + endpoint_id: z + .string() + .describe("The unique ID of the channel endpoint to update"), + url: z + .string() + .optional() + .describe("Updated endpoint URL"), + name: z + .string() + .optional() + .describe("Updated human-readable name"), + description: z + .string() + .optional() + .describe("Updated description"), + active: z + .boolean() + .optional() + .describe("Whether the endpoint is active"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const body: Record = {}; + if (params.url !== undefined) body.url = params.url; + if (params.name !== undefined) body.name = params.name; + if (params.description !== undefined) body.description = params.description; + if (params.active !== undefined) body.active = params.active; + + const result = await novuRequest( + `/v1/channel-endpoints/${encodeURIComponent(params.endpoint_id)}`, + "PATCH", + body + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Channel Endpoint --------------------------------------------- + server.registerTool( + "novu_delete_channel_endpoint", + { + title: "Delete Channel Endpoint", + description: + "Permanently delete a channel endpoint by its ID. This cannot be undone.", + inputSchema: { + endpoint_id: z + .string() + .describe("The unique ID of the channel endpoint to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + await novuRequest( + `/v1/channel-endpoints/${encodeURIComponent(params.endpoint_id)}`, + "DELETE" + ); + return toolResult( + `Channel endpoint "${params.endpoint_id}" has been successfully deleted.` + ); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/contexts.ts b/src/tools/contexts.ts new file mode 100644 index 0000000..52416f0 --- /dev/null +++ b/src/tools/contexts.ts @@ -0,0 +1,204 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; +import { PaginationV1Schema } from "../schemas/common.js"; +import type { PaginatedListV1 } from "../types.js"; + +// --------------------------------------------------------------------------- +// Contexts tools +// --------------------------------------------------------------------------- + +interface Context { + _id: string; + name: string; + description?: string; + data?: Record; + createdAt?: string; + updatedAt?: string; +} + +export function registerContextsTools(server: McpServer): void { + // ---- List Contexts ------------------------------------------------------ + server.registerTool( + "novu_list_contexts", + { + title: "List Contexts", + description: + "List contexts in the current environment. " + + "Supports offset-based pagination with limit and offset parameters.", + inputSchema: { + ...PaginationV1Schema, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest>( + "/v1/contexts", + "GET", + undefined, + { + limit: params.limit, + offset: params.offset, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Create Context ----------------------------------------------------- + server.registerTool( + "novu_create_context", + { + title: "Create Context", + description: + "Create a new context. A context holds arbitrary data that can be referenced by workflows and notifications.", + inputSchema: { + name: z.string().describe("Name of the context"), + description: z + .string() + .optional() + .describe("Human-readable description of the context"), + data: z + .record(z.unknown()) + .optional() + .describe("Arbitrary key-value data to store in the context"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }, + async (params) => { + try { + const result = await novuRequest("/v1/contexts", "POST", { + name: params.name, + description: params.description, + data: params.data, + }); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Context -------------------------------------------------------- + server.registerTool( + "novu_get_context", + { + title: "Get Context", + description: + "Retrieve a context by its ID, including its name, description, data, and timestamps.", + inputSchema: { + context_id: z + .string() + .describe("The unique ID of the context to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/contexts/${encodeURIComponent(params.context_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Update Context ----------------------------------------------------- + server.registerTool( + "novu_update_context", + { + title: "Update Context", + description: + "Update an existing context. Only provided fields will be changed.", + inputSchema: { + context_id: z + .string() + .describe("The unique ID of the context to update"), + name: z.string().optional().describe("New name for the context"), + description: z + .string() + .optional() + .describe("New description for the context"), + data: z + .record(z.unknown()) + .optional() + .describe("New key-value data for the context"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const body: Record = {}; + if (params.name !== undefined) body.name = params.name; + if (params.description !== undefined) + body.description = params.description; + if (params.data !== undefined) body.data = params.data; + + const result = await novuRequest( + `/v1/contexts/${encodeURIComponent(params.context_id)}`, + "PATCH", + body + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Context ----------------------------------------------------- + server.registerTool( + "novu_delete_context", + { + title: "Delete Context", + description: + "Permanently delete a context by its ID. This cannot be undone.", + inputSchema: { + context_id: z + .string() + .describe("The unique ID of the context to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + await novuRequest( + `/v1/contexts/${encodeURIComponent(params.context_id)}`, + "DELETE" + ); + return toolResult( + `Context "${params.context_id}" has been successfully deleted.` + ); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/environments.ts b/src/tools/environments.ts new file mode 100644 index 0000000..0f69447 --- /dev/null +++ b/src/tools/environments.ts @@ -0,0 +1,192 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; + +// --------------------------------------------------------------------------- +// Environments tools +// --------------------------------------------------------------------------- + +export function registerEnvironmentsTools(server: McpServer): void { + // ---- List Environments -------------------------------------------------- + server.registerTool( + "novu_list_environments", + { + title: "List Environments", + description: + "List all environments for the current organization. " + + "Returns environment details including name, identifier, and configuration.", + inputSchema: {}, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + try { + const result = await novuRequest("/v1/environments", "GET"); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Create Environment ------------------------------------------------- + server.registerTool( + "novu_create_environment", + { + title: "Create Environment", + description: + "Create a new environment within the current organization. " + + "Each environment has its own set of API keys, subscribers, and workflows.", + inputSchema: { + name: z.string().describe("Name for the new environment"), + parent_id: z + .string() + .optional() + .describe("Optional parent environment ID to inherit settings from"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/environments", + "POST", + { + name: params.name, + parentId: params.parent_id, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Update Environment ------------------------------------------------- + server.registerTool( + "novu_update_environment", + { + title: "Update Environment", + description: + "Update an existing environment's name, identifier, or DNS configuration.", + inputSchema: { + environment_id: z + .string() + .describe("The ID of the environment to update"), + name: z + .string() + .optional() + .describe("New name for the environment"), + identifier: z + .string() + .optional() + .describe("New identifier for the environment"), + dns: z + .object({ + mxRecordConfigured: z + .boolean() + .optional() + .describe("Whether MX record is configured"), + inboundParseDomain: z + .string() + .optional() + .describe("Inbound parse domain for email processing"), + }) + .optional() + .describe("DNS configuration for the environment"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const body: Record = {}; + if (params.name !== undefined) body.name = params.name; + if (params.identifier !== undefined) + body.identifier = params.identifier; + if (params.dns !== undefined) body.dns = params.dns; + + const result = await novuRequest( + `/v1/environments/${encodeURIComponent(params.environment_id)}`, + "PUT", + body + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Environment ------------------------------------------------- + server.registerTool( + "novu_delete_environment", + { + title: "Delete Environment", + description: + "Permanently delete an environment. This cannot be undone and will remove all associated data including workflows, subscribers, and API keys.", + inputSchema: { + environment_id: z + .string() + .describe("The ID of the environment to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + await novuRequest( + `/v1/environments/${encodeURIComponent(params.environment_id)}`, + "DELETE" + ); + return toolResult( + `Environment "${params.environment_id}" has been successfully deleted.` + ); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- List Environment Tags ---------------------------------------------- + server.registerTool( + "novu_list_environment_tags", + { + title: "List Environment Tags", + description: + "List all tags used across workflows in the current environment. " + + "Useful for discovering available tags for filtering workflows.", + inputSchema: {}, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + try { + const result = await novuRequest( + "/v1/environments/tags", + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/events.ts b/src/tools/events.ts new file mode 100644 index 0000000..acb8ff8 --- /dev/null +++ b/src/tools/events.ts @@ -0,0 +1,271 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + novuRequest, + toolResult, + toolError, +} from "../services/api-client.js"; +import type { TriggerResponse } from "../types.js"; + +// ── Shared sub-schemas ────────────────────────────────────────────── + +const TriggerEventBodySchema = z + .object({ + name: z.string().describe("Workflow trigger identifier (from the workflow settings)"), + to: z + .union([z.string(), z.array(z.string()).min(1)]) + .describe("Subscriber ID or array of subscriber IDs to notify"), + payload: z + .record(z.unknown()) + .optional() + .describe("Key-value data passed to the workflow template variables"), + overrides: z + .record(z.unknown()) + .optional() + .describe("Provider-specific overrides (e.g. email subject, push title)"), + transactionId: z + .string() + .optional() + .describe("Unique ID for idempotent triggering and later cancellation"), + actor: z + .string() + .optional() + .describe("Subscriber ID of the user who performed the action"), + tenant: z + .string() + .optional() + .describe("Tenant identifier for multi-tenant setups"), + }) + .strict(); + +const BulkEventItemSchema = z + .object({ + name: z.string().describe("Workflow trigger identifier"), + to: z + .union([z.string(), z.array(z.string()).min(1)]) + .describe("Subscriber ID or array of subscriber IDs"), + payload: z.record(z.unknown()).optional().describe("Template variable data"), + overrides: z.record(z.unknown()).optional().describe("Provider-specific overrides"), + transactionId: z + .string() + .optional() + .describe("Unique ID for idempotent triggering"), + }) + .strict(); + +// ── Tool registration ─────────────────────────────────────────────── + +export function registerEventsTools(server: McpServer): void { + // ── 1. Trigger Event ──────────────────────────────────────────── + + server.registerTool( + "novu_trigger_event", + { + title: "Trigger Novu Event", + description: `Trigger a notification workflow for one or more subscribers. + +Args: + name: Workflow trigger identifier (required) + to: Subscriber ID (string) or array of subscriber IDs (required) + payload: Key-value data for template variables (optional) + overrides: Provider-specific overrides for email, push, etc. (optional) + transactionId: Unique ID for deduplication / later cancellation (optional) + actor: Subscriber ID of the user who performed the action (optional) + tenant: Tenant identifier for multi-tenant setups (optional) + +Returns: + JSON object with acknowledged, status, transactionId, and optional error array. + +Examples: + - Simple trigger: { "name": "welcome-email", "to": "subscriber-123" } + - Multiple subscribers: { "name": "order-update", "to": ["sub-1", "sub-2"], "payload": { "orderId": "ORD-99" } } + - With overrides: { "name": "alert", "to": "sub-1", "overrides": { "email": { "from": "alerts@example.com" } } }`, + inputSchema: TriggerEventBodySchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ name, to, payload, overrides, transactionId, actor, tenant }) => { + try { + const result = await novuRequest( + "/v1/events/trigger", + "POST", + { name, to, payload, overrides, transactionId, actor, tenant } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 2. Bulk Trigger Events ────────────────────────────────────── + + server.registerTool( + "novu_bulk_trigger_event", + { + title: "Bulk Trigger Novu Events", + description: `Trigger multiple notification workflows in a single API call (max 100). + +Args: + events: Array of event objects, each containing: + - name: Workflow trigger identifier (required) + - to: Subscriber ID or array of subscriber IDs (required) + - payload: Template variable data (optional) + - overrides: Provider-specific overrides (optional) + - transactionId: Unique ID for deduplication (optional) + +Returns: + JSON array of trigger responses, one per event. + +Examples: + - Two events: { "events": [ + { "name": "welcome-email", "to": "sub-1" }, + { "name": "order-shipped", "to": "sub-2", "payload": { "trackingId": "TRK-42" } } + ]}`, + inputSchema: z + .object({ + events: z + .array(BulkEventItemSchema) + .min(1) + .max(100) + .describe("Array of trigger event objects (1-100)"), + }) + .strict(), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ events }) => { + try { + const result = await novuRequest( + "/v1/events/trigger/bulk", + "POST", + { events } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 3. Broadcast Event ────────────────────────────────────────── + + server.registerTool( + "novu_broadcast_event", + { + title: "Broadcast Novu Event", + description: `Broadcast a notification workflow to ALL subscribers in the environment. + +Args: + name: Workflow trigger identifier (required) + payload: Key-value data for template variables (required) + overrides: Provider-specific overrides (optional) + transactionId: Unique ID for deduplication / later cancellation (optional) + actor: Subscriber ID of the user who performed the action (optional) + tenant: Tenant identifier for multi-tenant setups (optional) + +Returns: + JSON object with acknowledged, status, transactionId, and optional error array. + +Examples: + - Broadcast maintenance alert: { "name": "maintenance-notice", "payload": { "downtime": "2h", "startTime": "2025-04-01T02:00:00Z" } } + - With transaction ID: { "name": "product-launch", "payload": { "productName": "Widget Pro" }, "transactionId": "launch-2025-04" }`, + inputSchema: z + .object({ + name: z.string().describe("Workflow trigger identifier"), + payload: z + .record(z.unknown()) + .describe("Key-value data passed to the workflow template variables"), + overrides: z + .record(z.unknown()) + .optional() + .describe("Provider-specific overrides"), + transactionId: z + .string() + .optional() + .describe("Unique ID for deduplication / later cancellation"), + actor: z + .string() + .optional() + .describe("Subscriber ID of the user who performed the action"), + tenant: z + .string() + .optional() + .describe("Tenant identifier for multi-tenant setups"), + }) + .strict(), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ name, payload, overrides, transactionId, actor, tenant }) => { + try { + const result = await novuRequest( + "/v1/events/trigger/broadcast", + "POST", + { name, payload, overrides, transactionId, actor, tenant } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 4. Cancel Event ───────────────────────────────────────────── + + server.registerTool( + "novu_cancel_event", + { + title: "Cancel Novu Event", + description: `Cancel a previously triggered event using its transaction ID. +Only pending (not yet delivered) notifications will be cancelled. + +Args: + transaction_id: The transactionId that was returned or provided when the event was triggered (required) + +Returns: + JSON confirmation of the cancellation. + +Examples: + - Cancel by transaction ID: { "transaction_id": "order-123-notif" }`, + inputSchema: z + .object({ + transaction_id: z + .string() + .describe( + "The transactionId returned when the event was triggered" + ), + }) + .strict(), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ transaction_id }) => { + try { + const result = await novuRequest( + `/v1/events/trigger/${encodeURIComponent(transaction_id)}`, + "DELETE" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/integrations.ts b/src/tools/integrations.ts new file mode 100644 index 0000000..261d361 --- /dev/null +++ b/src/tools/integrations.ts @@ -0,0 +1,256 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; + +// Reusable schema fragments for integration optional fields (shared by create & update) +const IntegrationOptionalFields = { + credentials: z + .record(z.unknown()) + .optional() + .describe("Provider-specific credentials object (e.g., apiKey, secretKey, domain)"), + active: z.boolean().optional().describe("Whether the integration is active"), + name: z.string().optional().describe("Display name for the integration"), + identifier: z + .string() + .optional() + .describe("Unique identifier for the integration instance"), + environment_id: z + .string() + .optional() + .describe("Environment ID to associate the integration with"), +}; + +/** Map snake_case optional integration params to camelCase API body. */ +function mapIntegrationOptionalFields(params: { + credentials?: Record; + active?: boolean; + name?: string; + identifier?: string; + environment_id?: string; +}): Record { + const body: Record = {}; + if (params.credentials !== undefined) body.credentials = params.credentials; + if (params.active !== undefined) body.active = params.active; + if (params.name !== undefined) body.name = params.name; + if (params.identifier !== undefined) body.identifier = params.identifier; + if (params.environment_id !== undefined) + body._environmentId = params.environment_id; + return body; +} + +export function registerIntegrationsTools(server: McpServer): void { + // ── 1. List Integrations ────────────────────────────────────────────── + server.registerTool( + "novu_list_integrations", + { + title: "List Novu Integrations", + description: + "List all configured integrations for the current environment.", + inputSchema: {}, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async () => { + try { + const result = await novuRequest("/v1/integrations", "GET"); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 2. List Active Integrations ─────────────────────────────────────── + server.registerTool( + "novu_list_active_integrations", + { + title: "List Active Novu Integrations", + description: + "List only the active integrations for the current environment.", + inputSchema: {}, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async () => { + try { + const result = await novuRequest("/v1/integrations/active", "GET"); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 3. Create Integration ───────────────────────────────────────────── + server.registerTool( + "novu_create_integration", + { + title: "Create Novu Integration", + description: + "Create a new integration for a notification provider (e.g., email, SMS, push, chat).", + inputSchema: { + provider_id: z + .string() + .describe("Provider identifier (e.g., 'sendgrid', 'twilio', 'fcm')"), + channel: z + .string() + .describe( + "Notification channel type (e.g., 'email', 'sms', 'push', 'chat', 'in_app')" + ), + ...IntegrationOptionalFields, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest("/v1/integrations", "POST", { + providerId: params.provider_id, + channel: params.channel, + ...mapIntegrationOptionalFields(params), + }); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 4. Update Integration ───────────────────────────────────────────── + server.registerTool( + "novu_update_integration", + { + title: "Update Novu Integration", + description: + "Update an existing integration's configuration. Only provided fields are changed.", + inputSchema: { + integration_id: z.string().describe("Integration ID to update"), + ...IntegrationOptionalFields, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/integrations/${encodeURIComponent(params.integration_id)}`, + "PUT", + mapIntegrationOptionalFields(params) + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 5. Delete Integration ───────────────────────────────────────────── + server.registerTool( + "novu_delete_integration", + { + title: "Delete Novu Integration", + description: + "Permanently delete an integration. This action cannot be undone.", + inputSchema: { + integration_id: z.string().describe("Integration ID to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/integrations/${encodeURIComponent(params.integration_id)}`, + "DELETE" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 6. Set Primary Integration ──────────────────────────────────────── + server.registerTool( + "novu_set_primary_integration", + { + title: "Set Primary Novu Integration", + description: + "Mark an integration as the primary provider for its channel.", + inputSchema: { + integration_id: z + .string() + .describe("Integration ID to set as primary"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/integrations/${encodeURIComponent(params.integration_id)}/set-primary`, + "POST" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 7. Generate Chat OAuth URL ──────────────────────────────────────── + server.registerTool( + "novu_generate_chat_oauth_url", + { + title: "Generate Chat OAuth URL", + description: + "Generate an OAuth URL for a chat integration (e.g., Slack, Discord).", + inputSchema: { + integration_id: z + .string() + .describe("Chat integration ID to generate an OAuth URL for"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/integrations/${encodeURIComponent(params.integration_id)}/oauth/url`, + "POST" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/messages.ts b/src/tools/messages.ts new file mode 100644 index 0000000..bbadf39 --- /dev/null +++ b/src/tools/messages.ts @@ -0,0 +1,124 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; + +export function registerMessagesTools(server: McpServer): void { + // ── 1. List Messages ──────────────────────────────────────────────────── + server.registerTool( + "novu_list_messages", + { + title: "List Novu Messages", + description: + "Retrieve a paginated list of messages, optionally filtered by channel, subscriber, or transaction.", + inputSchema: { + channel: z + .string() + .optional() + .describe("Filter by channel type (e.g., 'in_app', 'email', 'sms', 'push', 'chat')"), + subscriber_id: z + .string() + .optional() + .describe("Filter by subscriber identifier"), + transaction_id: z + .string() + .optional() + .describe("Filter by transaction identifier"), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum number of results to return (1-100)"), + offset: z + .number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest("/v1/messages", "GET", undefined, { + channel: params.channel, + subscriberId: params.subscriber_id, + transactionId: params.transaction_id, + limit: params.limit, + offset: params.offset, + }); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 2. Delete Message ─────────────────────────────────────────────────── + server.registerTool( + "novu_delete_message", + { + title: "Delete Novu Message", + description: + "Permanently delete a message by its ID. This action cannot be undone.", + inputSchema: { + message_id: z.string().describe("The ID of the message to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/messages/${encodeURIComponent(params.message_id)}`, + "DELETE" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 3. Delete Messages by Transaction ─────────────────────────────────── + server.registerTool( + "novu_delete_messages_by_transaction", + { + title: "Delete Novu Messages by Transaction", + description: + "Permanently delete all messages associated with a transaction ID. This action cannot be undone.", + inputSchema: { + transaction_id: z + .string() + .describe("The transaction ID whose messages should be deleted"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/messages/transaction/${encodeURIComponent(params.transaction_id)}`, + "DELETE" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/notifications.ts b/src/tools/notifications.ts new file mode 100644 index 0000000..a4d76db --- /dev/null +++ b/src/tools/notifications.ts @@ -0,0 +1,102 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; + +export function registerNotificationsTools(server: McpServer): void { + // ── 1. List Notification Events ───────────────────────────────────────── + server.registerTool( + "novu_list_notification_events", + { + title: "List Novu Notification Events", + description: + "List notification events with optional filtering by channels, templates, emails, subscriber IDs, or transaction ID. Returns a paginated list.", + inputSchema: { + channels: z + .array(z.string()) + .optional() + .describe("Filter by channel types (e.g., 'email', 'sms', 'in_app')"), + templates: z + .array(z.string()) + .optional() + .describe("Filter by workflow/template IDs"), + emails: z + .array(z.string()) + .optional() + .describe("Filter by recipient email addresses"), + subscriber_ids: z + .array(z.string()) + .optional() + .describe("Filter by subscriber identifiers"), + transaction_id: z + .string() + .optional() + .describe("Filter by transaction identifier"), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum number of results to return (1-100)"), + offset: z + .number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest("/v1/notifications", "GET", undefined, { + channels: params.channels, + templates: params.templates, + emails: params.emails, + subscriberIds: params.subscriber_ids, + transactionId: params.transaction_id, + limit: params.limit, + offset: params.offset, + }); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 2. Get Notification Event ─────────────────────────────────────────── + server.registerTool( + "novu_get_notification_event", + { + title: "Get Novu Notification Event", + description: + "Retrieve details of a specific notification event by its ID.", + inputSchema: { + notification_id: z.string().describe("Unique notification event identifier"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/notifications/${encodeURIComponent(params.notification_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/subscribers.ts b/src/tools/subscribers.ts new file mode 100644 index 0000000..5d7699f --- /dev/null +++ b/src/tools/subscribers.ts @@ -0,0 +1,652 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; +import { PaginationV1Schema } from "../schemas/common.js"; + +// Reusable schema fragments for subscriber fields +const SubscriberOptionalFields = { + first_name: z.string().optional().describe("Subscriber's first name"), + last_name: z.string().optional().describe("Subscriber's last name"), + email: z.string().optional().describe("Subscriber's email address"), + phone: z.string().optional().describe("Subscriber's phone number"), + avatar: z.string().optional().describe("URL to subscriber's avatar image"), + locale: z.string().optional().describe("Subscriber's locale (e.g., 'en-US')"), + timezone: z.string().optional().describe("Subscriber's timezone (e.g., 'America/New_York')"), + data: z + .record(z.unknown()) + .optional() + .describe("Custom data object for the subscriber"), +}; + +/** Map snake_case optional subscriber params to camelCase API body. */ +function mapSubscriberFields(params: { + first_name?: string; + last_name?: string; + email?: string; + phone?: string; + avatar?: string; + locale?: string; + timezone?: string; + data?: Record; +}): Record { + const body: Record = {}; + if (params.first_name !== undefined) body.firstName = params.first_name; + if (params.last_name !== undefined) body.lastName = params.last_name; + if (params.email !== undefined) body.email = params.email; + if (params.phone !== undefined) body.phone = params.phone; + if (params.avatar !== undefined) body.avatar = params.avatar; + if (params.locale !== undefined) body.locale = params.locale; + if (params.timezone !== undefined) body.timezone = params.timezone; + if (params.data !== undefined) body.data = params.data; + return body; +} + +export function registerSubscribersTools(server: McpServer): void { + // ── 1. Create Subscriber ────────────────────────────────────────────── + server.registerTool( + "novu_create_subscriber", + { + title: "Create Novu Subscriber", + description: + "Create a new subscriber in Novu with an identifier and optional profile fields.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + ...SubscriberOptionalFields, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest("/v1/subscribers", "POST", { + subscriberId: params.subscriber_id, + ...mapSubscriberFields(params), + }); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 2. Get Subscriber ───────────────────────────────────────────────── + server.registerTool( + "novu_get_subscriber", + { + title: "Get Novu Subscriber", + description: "Retrieve a subscriber by their unique identifier.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 3. Update Subscriber ────────────────────────────────────────────── + server.registerTool( + "novu_update_subscriber", + { + title: "Update Novu Subscriber", + description: + "Update an existing subscriber's profile fields. Only provided fields are changed.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + ...SubscriberOptionalFields, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}`, + "PATCH", + mapSubscriberFields(params) + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 4. Delete Subscriber ────────────────────────────────────────────── + server.registerTool( + "novu_delete_subscriber", + { + title: "Delete Novu Subscriber", + description: + "Permanently delete a subscriber and all associated data. This action cannot be undone.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}`, + "DELETE" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 5. Search Subscribers ───────────────────────────────────────────── + server.registerTool( + "novu_search_subscribers", + { + title: "Search Novu Subscribers", + description: + "List subscribers with optional pagination. Returns a paginated list.", + inputSchema: { + ...PaginationV1Schema, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/subscribers", + "GET", + undefined, + { limit: params.limit, offset: params.offset } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 6. Bulk Create Subscribers ──────────────────────────────────────── + server.registerTool( + "novu_bulk_create_subscribers", + { + title: "Bulk Create Novu Subscribers", + description: + "Create multiple subscribers in a single request. Each subscriber object must include a subscriberId.", + inputSchema: { + subscribers: z + .array( + z.object({ + subscriberId: z.string().describe("Unique subscriber identifier"), + firstName: z.string().optional().describe("First name"), + lastName: z.string().optional().describe("Last name"), + email: z.string().optional().describe("Email address"), + phone: z.string().optional().describe("Phone number"), + avatar: z.string().optional().describe("Avatar URL"), + locale: z.string().optional().describe("Locale"), + timezone: z.string().optional().describe("Timezone"), + data: z + .record(z.unknown()) + .optional() + .describe("Custom data"), + }) + ) + .describe("Array of subscriber objects to create"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/subscribers/bulk", + "POST", + { subscribers: params.subscribers } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 7. Get Subscriber Preferences ───────────────────────────────────── + server.registerTool( + "novu_get_subscriber_preferences", + { + title: "Get Subscriber Preferences", + description: + "Retrieve notification preferences for a subscriber.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/preferences`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 8. Update Subscriber Preferences ────────────────────────────────── + server.registerTool( + "novu_update_subscriber_preferences", + { + title: "Update Subscriber Preferences", + description: + "Update notification preferences for a subscriber.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + channel: z + .object({ + type: z + .enum(["in_app", "email", "sms", "chat", "push"]) + .describe("Channel type"), + enabled: z.boolean().describe("Whether the channel is enabled"), + }) + .optional() + .describe("Channel preference to update"), + enabled: z + .boolean() + .optional() + .describe("Global preference enabled state"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const body: Record = {}; + if (params.channel !== undefined) body.channel = params.channel; + if (params.enabled !== undefined) body.enabled = params.enabled; + + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/preferences`, + "PATCH", + body + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 9. Bulk Update Subscriber Preferences ───────────────────────────── + server.registerTool( + "novu_bulk_update_subscriber_preferences", + { + title: "Bulk Update Subscriber Preferences", + description: + "Update multiple notification preferences for a subscriber at once.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + preferences: z + .array( + z.object({ + workflowId: z + .string() + .optional() + .describe("Workflow identifier to update preferences for"), + channel: z + .object({ + type: z + .enum(["in_app", "email", "sms", "chat", "push"]) + .describe("Channel type"), + enabled: z + .boolean() + .describe("Whether the channel is enabled"), + }) + .optional() + .describe("Channel preference"), + enabled: z + .boolean() + .optional() + .describe("Global preference enabled state"), + }) + ) + .describe("Array of preference objects to update"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/preferences/bulk`, + "PATCH", + { preferences: params.preferences } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 10. Get Subscriber Subscriptions ────────────────────────────────── + server.registerTool( + "novu_get_subscriber_subscriptions", + { + title: "Get Subscriber Subscriptions", + description: + "Retrieve topic subscriptions for a subscriber.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/subscriptions`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 11. Get Subscriber Notifications ────────────────────────────────── + server.registerTool( + "novu_get_subscriber_notifications", + { + title: "Get Subscriber Notifications", + description: + "Retrieve notifications for a subscriber with pagination.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + ...PaginationV1Schema, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/notifications`, + "GET", + undefined, + { limit: params.limit, offset: params.offset } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 12. Update Notification State ───────────────────────────────────── + server.registerTool( + "novu_update_notification_state", + { + title: "Update Notification State", + description: + "Update the state (read/seen/unseen/unread) of specific notifications for a subscriber.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + message_ids: z + .array(z.string()) + .describe("Array of notification message IDs to update"), + state: z + .enum(["read", "seen", "unseen", "unread"]) + .describe("Target notification state"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/notifications/state`, + "POST", + { messageIds: params.message_ids, state: params.state } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 13. Update All Notification States ──────────────────────────────── + server.registerTool( + "novu_update_all_notification_states", + { + title: "Update All Notification States", + description: + "Update the state of all notifications for a subscriber at once.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + state: z + .enum(["read", "seen", "unseen", "unread"]) + .describe("Target notification state to apply to all notifications"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/notifications/state/all`, + "POST", + { state: params.state } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 14. Update Subscriber Online Status ─────────────────────────────── + server.registerTool( + "novu_update_subscriber_online_status", + { + title: "Update Subscriber Online Status", + description: "Set a subscriber's online or offline status.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + is_online: z.boolean().describe("Whether the subscriber is online"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/online-status`, + "PATCH", + { isOnline: params.is_online } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 15. Update Provider Credentials ─────────────────────────────────── + server.registerTool( + "novu_update_provider_credentials", + { + title: "Update Provider Credentials", + description: + "Set (replace) integration provider credentials for a subscriber. Use PUT semantics — all credential fields must be provided.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + provider_id: z.string().describe("Integration provider identifier"), + credentials: z + .record(z.unknown()) + .describe("Provider credentials object (e.g., deviceTokens, webhookUrl)"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/credentials`, + "PUT", + { + providerId: params.provider_id, + credentials: params.credentials, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 16. Upsert Provider Credentials ─────────────────────────────────── + server.registerTool( + "novu_upsert_provider_credentials", + { + title: "Upsert Provider Credentials", + description: + "Create or partially update integration provider credentials for a subscriber. Uses PATCH semantics — only provided fields are changed.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + provider_id: z.string().describe("Integration provider identifier"), + credentials: z + .record(z.unknown()) + .describe("Provider credentials object to upsert"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/credentials`, + "PATCH", + { + providerId: params.provider_id, + credentials: params.credentials, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── 17. Delete Provider Credentials ─────────────────────────────────── + server.registerTool( + "novu_delete_provider_credentials", + { + title: "Delete Provider Credentials", + description: + "Remove integration provider credentials from a subscriber. This action cannot be undone.", + inputSchema: { + subscriber_id: z.string().describe("Unique subscriber identifier"), + provider_id: z.string().describe("Integration provider identifier to remove"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/subscribers/${encodeURIComponent(params.subscriber_id)}/credentials/${encodeURIComponent(params.provider_id)}`, + "DELETE" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/topics.ts b/src/tools/topics.ts new file mode 100644 index 0000000..0cf3482 --- /dev/null +++ b/src/tools/topics.ts @@ -0,0 +1,391 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; +import { PaginationV1Schema, PaginationV2Schema } from "../schemas/common.js"; +import type { Topic, PaginatedListV1, PaginatedListV2 } from "../types.js"; + +// --------------------------------------------------------------------------- +// Topics tools +// --------------------------------------------------------------------------- + +export function registerTopicsTools(server: McpServer): void { + // ---- Create Topic ------------------------------------------------------- + server.registerTool( + "novu_create_topic", + { + title: "Create Topic", + description: + "Create a new topic that can be used to group subscribers for bulk notifications. " + + "The topic key must be unique within the environment.", + inputSchema: { + key: z.string().describe("Unique key identifier for the topic"), + name: z + .string() + .optional() + .describe("Human-readable name for the topic"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }, + async (params) => { + try { + const result = await novuRequest("/v1/topics", "POST", { + key: params.key, + name: params.name, + }); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Topic ---------------------------------------------------------- + server.registerTool( + "novu_get_topic", + { + title: "Get Topic", + description: + "Retrieve a topic by its unique key, including metadata such as name and timestamps.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Update Topic ------------------------------------------------------- + server.registerTool( + "novu_update_topic", + { + title: "Update Topic", + description: "Rename an existing topic by its key.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic to update"), + name: z.string().describe("New name for the topic"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}`, + "PATCH", + { name: params.name } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Topic ------------------------------------------------------- + server.registerTool( + "novu_delete_topic", + { + title: "Delete Topic", + description: + "Permanently delete a topic by its key. This cannot be undone and will remove all subscriber associations.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}`, + "DELETE" + ); + return toolResult( + `Topic "${params.topic_key}" has been successfully deleted.` + ); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- List Topics (v2 cursor-based) -------------------------------------- + server.registerTool( + "novu_list_topics", + { + title: "List Topics", + description: + "List topics in the current environment with optional filtering by key or name. " + + "Uses cursor-based pagination; use the 'after' or 'before' cursors from a previous response to page through results.", + inputSchema: { + key: z + .string() + .optional() + .describe("Filter topics by key (exact or partial match)"), + name: z + .string() + .optional() + .describe("Filter topics by name (partial match)"), + ...PaginationV2Schema, + orderBy: z + .string() + .optional() + .describe("Field to order results by (e.g. 'createdAt')"), + orderDirection: z + .enum(["ASC", "DESC"]) + .optional() + .describe("Sort direction: ASC or DESC"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest>( + "/v2/topics", + "GET", + undefined, + { + key: params.key, + name: params.name, + limit: params.limit, + after: params.after, + before: params.before, + orderBy: params.orderBy, + orderDirection: params.orderDirection, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Create Topic Subscriptions ----------------------------------------- + server.registerTool( + "novu_create_topic_subscriptions", + { + title: "Add Subscribers to Topic", + description: + "Add one or more subscribers to a topic. Subscribers will then receive notifications sent to this topic.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic"), + subscriber_ids: z + .array(z.string()) + .describe("Array of subscriber IDs to add to the topic"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}/subscriptions`, + "POST", + { subscriberIds: params.subscriber_ids } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Topic Subscriptions ----------------------------------------- + server.registerTool( + "novu_delete_topic_subscriptions", + { + title: "Remove Subscribers from Topic", + description: + "Remove one or more subscribers from a topic. Those subscribers will no longer receive topic-targeted notifications.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic"), + subscriber_ids: z + .array(z.string()) + .describe("Array of subscriber IDs to remove from the topic"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}/subscriptions`, + "DELETE", + { subscriberIds: params.subscriber_ids } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Update Topic Subscription ------------------------------------------ + server.registerTool( + "novu_update_topic_subscription", + { + title: "Update Topic Subscription", + description: + "Update an existing subscription for a specific subscriber within a topic.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic"), + subscriber_id: z + .string() + .describe("The subscriber ID to update within the topic"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}/subscriptions/${encodeURIComponent(params.subscriber_id)}`, + "PATCH" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- List Topic Subscriptions ------------------------------------------- + server.registerTool( + "novu_list_topic_subscriptions", + { + title: "List Topic Subscriptions", + description: + "List all subscribers that belong to a given topic. " + + "Supports offset-based pagination with limit and offset parameters.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic"), + ...PaginationV1Schema, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest>( + `/v1/topics/${encodeURIComponent(params.topic_key)}/subscriptions`, + "GET", + undefined, + { + limit: params.limit, + offset: params.offset, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Check Topic Subscriber --------------------------------------------- + server.registerTool( + "novu_check_topic_subscriber", + { + title: "Check Topic Subscriber", + description: + "Check whether a specific subscriber belongs to a topic. " + + "Returns the subscription details if the subscriber is a member.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic"), + subscriber_id: z + .string() + .describe("The subscriber ID to check membership for"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}/subscriptions/${encodeURIComponent(params.subscriber_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Topic Subscription --------------------------------------------- + server.registerTool( + "novu_get_topic_subscription", + { + title: "Get Topic Subscription", + description: + "Retrieve the full subscription record for a specific subscriber within a topic, " + + "including subscription metadata and timestamps.", + inputSchema: { + topic_key: z.string().describe("The unique key of the topic"), + subscriber_id: z + .string() + .describe("The subscriber ID whose subscription to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/topics/${encodeURIComponent(params.topic_key)}/subscriptions/${encodeURIComponent(params.subscriber_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/translations.ts b/src/tools/translations.ts new file mode 100644 index 0000000..7666cbc --- /dev/null +++ b/src/tools/translations.ts @@ -0,0 +1,254 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; + +// --------------------------------------------------------------------------- +// Translations tools +// --------------------------------------------------------------------------- + +export function registerTranslationsTools(server: McpServer): void { + // ---- Create Translation ------------------------------------------------- + server.registerTool( + "novu_create_translation", + { + title: "Create Translation", + description: + "Create a new translation for a specific locale within a translation group. " + + "Provide key-value pairs that map translation keys to their localized strings.", + inputSchema: { + group_id: z.string().describe("The ID of the translation group"), + locale: z + .string() + .describe("Locale code for the translation (e.g. 'en', 'fr', 'de')"), + translations: z + .record(z.string()) + .describe( + "Key-value pairs mapping translation keys to localized strings" + ), + name: z + .string() + .optional() + .describe("Human-readable name for this translation"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }, + async (params) => { + try { + const result = await novuRequest( + "/v1/translations", + "POST", + { + groupId: params.group_id, + locale: params.locale, + translations: params.translations, + name: params.name, + } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Translation ---------------------------------------------------- + server.registerTool( + "novu_get_translation", + { + title: "Get Translation", + description: + "Retrieve a single translation by its ID, including locale, keys, and group information.", + inputSchema: { + translation_id: z + .string() + .describe("The unique ID of the translation to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/translations/${encodeURIComponent(params.translation_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Translation ------------------------------------------------- + server.registerTool( + "novu_delete_translation", + { + title: "Delete Translation", + description: + "Permanently delete a translation by its ID. This cannot be undone.", + inputSchema: { + translation_id: z + .string() + .describe("The unique ID of the translation to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + await novuRequest( + `/v1/translations/${encodeURIComponent(params.translation_id)}`, + "DELETE" + ); + return toolResult( + `Translation "${params.translation_id}" has been successfully deleted.` + ); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Translation Group ---------------------------------------------- + server.registerTool( + "novu_get_translation_group", + { + title: "Get Translation Group", + description: + "Retrieve a translation group by its ID, including its name, locales, and associated translations.", + inputSchema: { + group_id: z + .string() + .describe("The unique ID of the translation group to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/translations/groups/${encodeURIComponent(params.group_id)}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Delete Translation Group ------------------------------------------- + server.registerTool( + "novu_delete_translation_group", + { + title: "Delete Translation Group", + description: + "Permanently delete a translation group and all its associated translations. This cannot be undone.", + inputSchema: { + group_id: z + .string() + .describe("The unique ID of the translation group to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }, + async (params) => { + try { + await novuRequest( + `/v1/translations/groups/${encodeURIComponent(params.group_id)}`, + "DELETE" + ); + return toolResult( + `Translation group "${params.group_id}" has been successfully deleted.` + ); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Import Master Translations ----------------------------------------- + server.registerTool( + "novu_import_master_translations", + { + title: "Import Master Translations", + description: + "Import or update master translation key-value pairs for a translation group. " + + "Master translations serve as the source of truth that other locale translations are derived from.", + inputSchema: { + group_id: z + .string() + .describe("The unique ID of the translation group"), + translations: z + .record(z.unknown()) + .describe( + "JSON object of master translation key-value pairs to import" + ), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/translations/groups/${encodeURIComponent(params.group_id)}/master`, + "POST", + { translations: params.translations } + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ---- Get Master Translations -------------------------------------------- + server.registerTool( + "novu_get_master_translations", + { + title: "Get Master Translations", + description: + "Retrieve the master translation key-value pairs for a translation group.", + inputSchema: { + group_id: z + .string() + .describe("The unique ID of the translation group"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/translations/groups/${encodeURIComponent(params.group_id)}/master`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +} diff --git a/src/tools/workflows.ts b/src/tools/workflows.ts new file mode 100644 index 0000000..f3b35f2 --- /dev/null +++ b/src/tools/workflows.ts @@ -0,0 +1,314 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { novuRequest, toolResult, toolError } from "../services/api-client.js"; +import { PaginationV1Schema } from "../schemas/common.js"; + +export function registerWorkflowsTools(server: McpServer): void { + // ── List Workflows ────────────────────────────────────────────────── + server.registerTool( + "novu_list_workflows", + { + title: "List Novu Workflows", + description: + "List all notification workflows in the current Novu environment. " + + "Returns workflow names, IDs, statuses, and step summaries. " + + "Supports offset-based pagination.", + inputSchema: { + ...PaginationV1Schema, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest("/v1/workflows", "GET", undefined, { + limit: params.limit, + offset: params.offset, + }); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── Get Workflow ──────────────────────────────────────────────────── + server.registerTool( + "novu_get_workflow", + { + title: "Get Novu Workflow", + description: + "Retrieve a single workflow by its ID. " + + "Returns the full workflow definition including steps, triggers, tags, and status.", + inputSchema: { + workflow_id: z.string().describe("The unique ID of the workflow to retrieve"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/workflows/${params.workflow_id}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── Create Workflow ───────────────────────────────────────────────── + server.registerTool( + "novu_create_workflow", + { + title: "Create Novu Workflow", + description: + "Create a new notification workflow. " + + "At minimum, provide a name. Optionally configure description, tags, " + + "active status, steps, notification group, and critical flag. " + + "Steps and triggers are complex nested objects; pass them as arrays of objects.", + inputSchema: { + name: z.string().describe("Name of the workflow"), + description: z + .string() + .optional() + .describe("Description of the workflow"), + tags: z + .array(z.string()) + .optional() + .describe("Tags to categorize the workflow"), + active: z + .boolean() + .optional() + .describe("Whether the workflow is active (default: true)"), + steps: z + .array(z.record(z.unknown())) + .optional() + .describe( + "Array of step definition objects. Each step typically includes " + + "a template (with type, content, subject, etc.) and optional filters." + ), + notificationGroupId: z + .string() + .optional() + .describe("ID of the notification group this workflow belongs to"), + critical: z + .boolean() + .optional() + .describe( + "Whether this workflow is critical. Critical workflows are always sent, " + + "even if the subscriber has disabled notifications." + ), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (params) => { + try { + const body: Record = { name: params.name }; + if (params.description !== undefined) + body.description = params.description; + if (params.tags !== undefined) body.tags = params.tags; + if (params.active !== undefined) body.active = params.active; + if (params.steps !== undefined) body.steps = params.steps; + if (params.notificationGroupId !== undefined) + body.notificationGroupId = params.notificationGroupId; + if (params.critical !== undefined) body.critical = params.critical; + + const result = await novuRequest("/v1/workflows", "POST", body); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── Update Workflow ───────────────────────────────────────────────── + server.registerTool( + "novu_update_workflow", + { + title: "Update Novu Workflow", + description: + "Update an existing workflow by its ID. " + + "Provide only the fields you want to change. " + + "Steps and triggers are complex nested objects; pass them as arrays of objects.", + inputSchema: { + workflow_id: z.string().describe("The unique ID of the workflow to update"), + name: z.string().optional().describe("Updated name of the workflow"), + description: z + .string() + .optional() + .describe("Updated description of the workflow"), + tags: z + .array(z.string()) + .optional() + .describe("Updated tags to categorize the workflow"), + active: z + .boolean() + .optional() + .describe("Whether the workflow is active"), + steps: z + .array(z.record(z.unknown())) + .optional() + .describe( + "Updated array of step definition objects. Each step typically includes " + + "a template (with type, content, subject, etc.) and optional filters." + ), + notificationGroupId: z + .string() + .optional() + .describe("ID of the notification group this workflow belongs to"), + critical: z + .boolean() + .optional() + .describe( + "Whether this workflow is critical. Critical workflows are always sent, " + + "even if the subscriber has disabled notifications." + ), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const body: Record = {}; + if (params.name !== undefined) body.name = params.name; + if (params.description !== undefined) + body.description = params.description; + if (params.tags !== undefined) body.tags = params.tags; + if (params.active !== undefined) body.active = params.active; + if (params.steps !== undefined) body.steps = params.steps; + if (params.notificationGroupId !== undefined) + body.notificationGroupId = params.notificationGroupId; + if (params.critical !== undefined) body.critical = params.critical; + + const result = await novuRequest( + `/v1/workflows/${params.workflow_id}`, + "PUT", + body + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── Delete Workflow ───────────────────────────────────────────────── + server.registerTool( + "novu_delete_workflow", + { + title: "Delete Novu Workflow", + description: + "Permanently delete a workflow by its ID. " + + "This action cannot be undone. All associated triggers will stop working.", + inputSchema: { + workflow_id: z.string().describe("The unique ID of the workflow to delete"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/workflows/${params.workflow_id}`, + "DELETE" + ); + return toolResult( + JSON.stringify( + result ?? { success: true, message: "Workflow deleted successfully" }, + null, + 2 + ) + ); + } catch (error) { + return toolError(error); + } + } + ); + + // ── Sync Workflow ─────────────────────────────────────────────────── + server.registerTool( + "novu_sync_workflow", + { + title: "Sync Novu Workflow", + description: + "Sync a workflow to the target environment. " + + "Use this to promote workflow changes between environments (e.g., dev to production).", + inputSchema: { + workflow_id: z.string().describe("The unique ID of the workflow to sync"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/workflows/${params.workflow_id}/sync`, + "PUT" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); + + // ── Get Workflow Step ─────────────────────────────────────────────── + server.registerTool( + "novu_get_workflow_step", + { + title: "Get Novu Workflow Step", + description: + "Retrieve a specific step from a workflow. " + + "Returns the full step definition including its template, filters, and metadata.", + inputSchema: { + workflow_id: z.string().describe("The unique ID of the workflow"), + step_id: z.string().describe("The unique ID of the step within the workflow"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const result = await novuRequest( + `/v1/workflows/${params.workflow_id}/steps/${params.step_id}`, + "GET" + ); + return toolResult(JSON.stringify(result, null, 2)); + } catch (error) { + return toolError(error); + } + } + ); +}