From 0fa7a414570d6b6eef47e317c173161ea7990f33 Mon Sep 17 00:00:00 2001 From: "s.savinel" Date: Mon, 30 Mar 2026 13:53:02 +0200 Subject: [PATCH] feat: add shared API client with auth and error handling --- src/services/api-client.ts | 113 +++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/services/api-client.ts diff --git a/src/services/api-client.ts b/src/services/api-client.ts new file mode 100644 index 0000000..736fe56 --- /dev/null +++ b/src/services/api-client.ts @@ -0,0 +1,113 @@ +import axios, { type AxiosRequestConfig } from "axios"; +import { getApiBaseUrl, getApiKey, CHARACTER_LIMIT } from "../constants.js"; + +/** + * Make an authenticated request to the Novu API. + * + * @param path - API path including version prefix (e.g., "/v1/events/trigger" or "/v2/topics") + * @param method - HTTP method + * @param data - Request body for POST/PUT/PATCH + * @param params - Query parameters for GET requests + */ +export async function novuRequest( + path: string, + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" = "GET", + data?: unknown, + params?: Record +): Promise { + // Strip undefined values from params to avoid sending "?key=undefined" + const cleanParams = params + ? Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== undefined) + ) + : undefined; + + const config: AxiosRequestConfig = { + method, + url: `${getApiBaseUrl()}${path}`, + data, + params: cleanParams, + timeout: 30_000, + headers: { + Authorization: `ApiKey ${getApiKey()}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }; + + const response = await axios(config); + return response.data as T; +} + +/** + * Format an API error into an actionable string for the LLM. + */ +export function formatApiError(error: unknown): string { + if (axios.isAxiosError(error)) { + if (error.response) { + const status = error.response.status; + const body = error.response.data as + | { message?: string; error?: string } + | undefined; + const detail = + body?.message || body?.error || JSON.stringify(body); + + switch (status) { + case 400: + return `Error (400 Bad Request): ${detail}. Check that all required parameters are provided and valid.`; + case 401: + return "Error (401 Unauthorized): Invalid or missing API key. Set NOVU_SECRET_KEY environment variable with a valid key from https://dashboard.novu.co"; + case 403: + return `Error (403 Forbidden): ${detail}. Check your API key permissions.`; + case 404: + return `Error (404 Not Found): ${detail}. Verify the resource ID/key is correct.`; + case 409: + return `Error (409 Conflict): ${detail}. The resource may already exist.`; + case 413: + return `Error (413 Payload Too Large): ${detail}. Reduce the request body size.`; + case 422: + return `Error (422 Unprocessable Entity): ${detail}. Check request body schema.`; + case 429: + return "Error (429 Rate Limited): Too many requests. Wait before retrying. See https://docs.novu.co/api-reference/rate-limiting"; + default: + return `Error (${status}): ${detail}`; + } + } else if (error.code === "ECONNABORTED") { + return "Error: Request timed out after 30s. Try again or check Novu service status."; + } else if (error.code === "ENOTFOUND") { + return `Error: Cannot reach Novu API at ${getApiBaseUrl()}. Check NOVU_API_URL and network connectivity.`; + } + } + return `Error: ${error instanceof Error ? error.message : String(error)}`; +} + +/** + * Truncate a string if it exceeds CHARACTER_LIMIT, appending a truncation notice. + */ +export function truncateIfNeeded(text: string): string { + if (text.length <= CHARACTER_LIMIT) return text; + const truncated = text.slice(0, CHARACTER_LIMIT); + return ( + truncated + + "\n\n... [Response truncated. Use pagination or filters to see more results.]" + ); +} + +/** + * Create a standard MCP tool result (success). + */ +export function toolResult(text: string) { + return { + content: [{ type: "text" as const, text: truncateIfNeeded(text) }], + }; +} + +/** + * Create a standard MCP tool result (error). + */ +export function toolError(error: unknown) { + return { + content: [{ type: "text" as const, text: formatApiError(error) }], + isError: true, + }; +}