@mailjet/mailjet-mcp-server 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/run-tests.yml +36 -0
- package/CHANGELOG.md +40 -0
- package/CODEOWNERS +1 -0
- package/LICENSE +191 -0
- package/README.md +93 -0
- package/package.json +39 -0
- package/src/mailjet-mcp.js +656 -0
- package/src/mailjet-openapi-schema.js +63 -0
- package/src/openapi-mailjet.yaml +15364 -0
- package/tests/mailjet-mcp.test.js +249 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { MailjetApiSchema } from "./mailjet-openapi-schema.js";
|
|
12
|
+
import packageInfo from "../package.json" with { type: "json" };
|
|
13
|
+
|
|
14
|
+
const __dirname = import.meta.dirname;
|
|
15
|
+
|
|
16
|
+
export const server = new McpServer({
|
|
17
|
+
name: "mailjet",
|
|
18
|
+
version: packageInfo.version,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Mailjet API authentication credentials in the documented BASIC Auth form "api_key:secret_key"
|
|
22
|
+
const API_KEY = process.env.MAILJET_API_KEY;
|
|
23
|
+
// Alternate non-US region of the API server
|
|
24
|
+
const API_REGION = process.env.MAILJET_API_REGION?.toLowerCase();
|
|
25
|
+
// API server hostname based on region
|
|
26
|
+
const API_HOSTNAME = `api.${API_REGION ? `${API_REGION}.` : ""}mailjet.com`;
|
|
27
|
+
// Path to openapi spec file
|
|
28
|
+
const OPENAPI_SPEC = resolve(__dirname, "openapi-mailjet.yaml");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extracts all endpoints from the OpenAPI specification and organizes them by HTTP method.
|
|
32
|
+
*
|
|
33
|
+
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Parsed OpenAPI specification
|
|
34
|
+
*/
|
|
35
|
+
function extractEndpoints(openApiSpec) {
|
|
36
|
+
try {
|
|
37
|
+
// Initialize the endpoints dictionary
|
|
38
|
+
const endpoints = {
|
|
39
|
+
/** @type {string[]} DELETE - List of DELETE endpoints supported by the API */
|
|
40
|
+
DELETE: [],
|
|
41
|
+
/** @type {string[]} GET - List of GET endpoints supported by the API */
|
|
42
|
+
GET: [],
|
|
43
|
+
/** @type {string[]} PUT - List of PUT endpoints supported by the API */
|
|
44
|
+
PUT: [],
|
|
45
|
+
/** @type {string[]} POST - List of POST endpoints supported by the API */
|
|
46
|
+
POST: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const paths = openApiSpec.paths;
|
|
50
|
+
|
|
51
|
+
Object.keys(paths).forEach((path) => {
|
|
52
|
+
const pathItem = paths[path];
|
|
53
|
+
|
|
54
|
+
// Check for each HTTP method
|
|
55
|
+
if (pathItem.get) {
|
|
56
|
+
endpoints.GET.push(path);
|
|
57
|
+
}
|
|
58
|
+
if (pathItem.post) {
|
|
59
|
+
endpoints.POST.push(path);
|
|
60
|
+
}
|
|
61
|
+
if (pathItem.put) {
|
|
62
|
+
endpoints.PUT.push(path);
|
|
63
|
+
}
|
|
64
|
+
if (pathItem.delete) {
|
|
65
|
+
endpoints.DELETE.push(path);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return endpoints;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error("Error extracting endpoints:", error);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Loads and parses the OpenAPI specification from a YAML file
|
|
78
|
+
* @param {string} filePath - Path to the OpenAPI YAML file
|
|
79
|
+
* @returns {Promise<unknown>} - Parsed OpenAPI specification
|
|
80
|
+
*/
|
|
81
|
+
export async function loadOpenApiSpec(filePath) {
|
|
82
|
+
try {
|
|
83
|
+
const streamedFile = createReadStream(filePath, { encoding: "utf-8" });
|
|
84
|
+
|
|
85
|
+
/** @type {string} file contents read into a string */
|
|
86
|
+
const contents = await new Promise((resolve, reject) => {
|
|
87
|
+
let data = "";
|
|
88
|
+
streamedFile.on("data", (chunk) => {
|
|
89
|
+
data += chunk;
|
|
90
|
+
});
|
|
91
|
+
streamedFile.on("end", () => {
|
|
92
|
+
resolve(data);
|
|
93
|
+
});
|
|
94
|
+
streamedFile.on("error", (err) => {
|
|
95
|
+
reject(err);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return yaml.load(contents);
|
|
100
|
+
} catch (/** @type {any} */ error) {
|
|
101
|
+
console.error(`Error loading OpenAPI spec: ${error.message}`);
|
|
102
|
+
// Don't exit in test mode
|
|
103
|
+
if (process.env.NODE_ENV !== "test") {
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
throw error; // Throw so tests can catch it
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Retrieves operation details from the OpenAPI spec for a given method and path
|
|
112
|
+
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Parsed OpenAPI specification
|
|
113
|
+
* @param {keyof ReturnType<typeof extractEndpoints>} method - HTTP method (GET, POST, etc.)
|
|
114
|
+
* @param { ReturnType<typeof extractEndpoints>[keyof ReturnType<typeof extractEndpoints>][number] } path - API endpoint path
|
|
115
|
+
* @returns Operation details or null if not found
|
|
116
|
+
*/
|
|
117
|
+
export function getOperationDetails(openApiSpec, method, path) {
|
|
118
|
+
const lowerMethod = method.toLowerCase();
|
|
119
|
+
|
|
120
|
+
// @ts-ignore lowercased string loses type info
|
|
121
|
+
if (!openApiSpec.paths?.[path]?.[lowerMethod]) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
/** @type {NonNullable<z.infer<typeof MailjetApiSchema>["paths"][string]["delete" | "get" | "post" | "put"]>} */
|
|
127
|
+
// @ts-ignore We know this exists because of the if condition above
|
|
128
|
+
operation: openApiSpec.paths[path][lowerMethod],
|
|
129
|
+
operationId:
|
|
130
|
+
openApiSpec.paths[path]["get"]?.operationId ??
|
|
131
|
+
`${method}-${sanitizeToolId(path).replace(/-+/g, "-")}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Converts OpenAPI schema definitions to Zod validation schemas
|
|
137
|
+
* @param {any} schema - OpenAPI schema object
|
|
138
|
+
* @param {z.infer<typeof MailjetApiSchema>} fullSpec - Complete OpenAPI specification
|
|
139
|
+
* @returns {z.ZodType} - Corresponding Zod schema
|
|
140
|
+
*/
|
|
141
|
+
export function openapiToZod(schema, fullSpec) {
|
|
142
|
+
if (!schema) {
|
|
143
|
+
return z.any();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Handle schema references (e.g. #/components/schemas/...)
|
|
147
|
+
if (schema.$ref) {
|
|
148
|
+
// For #/components/schemas/ type references
|
|
149
|
+
if (schema.$ref.startsWith("#/")) {
|
|
150
|
+
const refPath = schema.$ref.substring(2).split("/");
|
|
151
|
+
/** @type any */
|
|
152
|
+
let referenced = fullSpec;
|
|
153
|
+
for (const segment of refPath) {
|
|
154
|
+
if (!referenced || !referenced[segment]) {
|
|
155
|
+
console.error(`Failed to resolve reference: ${schema.$ref}, segment: ${segment}`);
|
|
156
|
+
return z.any().describe(`Failed reference: ${schema.$ref}`);
|
|
157
|
+
}
|
|
158
|
+
referenced = referenced[segment];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return openapiToZod(referenced, fullSpec);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Handle other reference formats if needed
|
|
165
|
+
console.error(`Unsupported reference format: ${schema.$ref}`);
|
|
166
|
+
return z.any().describe(`Unsupported reference: ${schema.$ref}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Convert different schema types to Zod equivalents
|
|
170
|
+
switch (schema.type) {
|
|
171
|
+
case "string":
|
|
172
|
+
let zodString = z.string();
|
|
173
|
+
if (schema.enum) {
|
|
174
|
+
return z.enum(schema.enum);
|
|
175
|
+
}
|
|
176
|
+
if (schema.format === "email") {
|
|
177
|
+
zodString = zodString.email();
|
|
178
|
+
}
|
|
179
|
+
if (schema.format === "uri") {
|
|
180
|
+
zodString = zodString.describe(`URI: ${schema.description || ""}`);
|
|
181
|
+
}
|
|
182
|
+
return zodString.describe(schema.description || "");
|
|
183
|
+
|
|
184
|
+
case "number":
|
|
185
|
+
case "integer":
|
|
186
|
+
let zodNumber = z.number();
|
|
187
|
+
if (schema.minimum !== undefined) {
|
|
188
|
+
zodNumber = zodNumber.min(schema.minimum);
|
|
189
|
+
}
|
|
190
|
+
if (schema.maximum !== undefined) {
|
|
191
|
+
zodNumber = zodNumber.max(schema.maximum);
|
|
192
|
+
}
|
|
193
|
+
return zodNumber.describe(schema.description || "");
|
|
194
|
+
|
|
195
|
+
case "boolean":
|
|
196
|
+
return z.boolean().describe(schema.description || "");
|
|
197
|
+
|
|
198
|
+
case "array":
|
|
199
|
+
return z.array(openapiToZod(schema.items, fullSpec)).describe(schema.description || "");
|
|
200
|
+
|
|
201
|
+
case "object":
|
|
202
|
+
if (!schema.properties) {
|
|
203
|
+
return z.record(z.string(), z.any());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @type Record<string, z.ZodType> */
|
|
207
|
+
const shape = {};
|
|
208
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
209
|
+
shape[key] = schema.required?.includes(key)
|
|
210
|
+
? openapiToZod(prop, fullSpec)
|
|
211
|
+
: openapiToZod(prop, fullSpec).optional();
|
|
212
|
+
}
|
|
213
|
+
return z.object(shape).describe(schema.description || "");
|
|
214
|
+
|
|
215
|
+
default:
|
|
216
|
+
// For schemas without a type but with properties
|
|
217
|
+
if (schema.properties) {
|
|
218
|
+
/** @type Record<string, z.ZodType> */
|
|
219
|
+
const shape = {};
|
|
220
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
221
|
+
shape[key] = schema.required?.includes(key)
|
|
222
|
+
? openapiToZod(prop, fullSpec)
|
|
223
|
+
: openapiToZod(prop, fullSpec).optional();
|
|
224
|
+
}
|
|
225
|
+
return z.object(shape).describe(schema.description || "");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// For YAML that defines "oneOf", "anyOf", etc.
|
|
229
|
+
if (schema.oneOf) {
|
|
230
|
+
const unionTypes = schema.oneOf.map((/** @type unknown */ s) => openapiToZod(s, fullSpec));
|
|
231
|
+
if (unionTypes.length === 1) {
|
|
232
|
+
return unionTypes[0].describe(schema.description || "");
|
|
233
|
+
}
|
|
234
|
+
return z.union(unionTypes).describe(schema.description || "");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (schema.anyOf) {
|
|
238
|
+
const unionTypes = schema.anyOf.map((/** @type unknown */ s) => openapiToZod(s, fullSpec));
|
|
239
|
+
if (unionTypes.length === 1) {
|
|
240
|
+
return unionTypes[0].describe(schema.description || "");
|
|
241
|
+
}
|
|
242
|
+
return z.union(unionTypes).describe(schema.description || "");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return z.any().describe(schema.description || "");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Processes OpenAPI parameters into Zod schemas
|
|
251
|
+
* @param {NonNullable<z.infer<typeof MailjetApiSchema>["paths"][string]["parameters"]>} parameters - OpenAPI parameter objects
|
|
252
|
+
* @param {Record<string, z.ZodType>} paramsSchema - Target schema object to populate
|
|
253
|
+
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Complete OpenAPI specification
|
|
254
|
+
*/
|
|
255
|
+
export function processParameters(parameters, paramsSchema, openApiSpec) {
|
|
256
|
+
for (const param of parameters) {
|
|
257
|
+
const zodParam = openapiToZod(param.schema, openApiSpec);
|
|
258
|
+
paramsSchema[param.name] = param.required ? zodParam : zodParam.optional();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Resolves a schema reference within an OpenAPI spec
|
|
264
|
+
* @param {string} ref - Reference string (e.g. #/components/schemas/ModelName)
|
|
265
|
+
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Complete OpenAPI specification
|
|
266
|
+
* @returns Resolved schema
|
|
267
|
+
*/
|
|
268
|
+
export function resolveReference(ref, openApiSpec) {
|
|
269
|
+
const refPath = ref.replace("#/", "").split("/");
|
|
270
|
+
// top-level reference key is missing in mailjet schema
|
|
271
|
+
return refPath.reduce((obj, path) => obj[path], openApiSpec);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Processes request body schema into Zod schemas
|
|
276
|
+
* @param {NonNullable<z.infer<typeof MailjetApiSchema>["paths"][string]["delete" | "get" | "post" | "put"]>['requestBody']} requestBody - OpenAPI request body object
|
|
277
|
+
* @param {Record<string, z.ZodType>} paramsSchema - Target schema object to populate
|
|
278
|
+
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Complete OpenAPI specification
|
|
279
|
+
*/
|
|
280
|
+
export function processRequestBody(requestBody, paramsSchema, openApiSpec) {
|
|
281
|
+
if (!requestBody?.content) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// All requests are currently JSON
|
|
286
|
+
const contentType = "application/json";
|
|
287
|
+
|
|
288
|
+
let bodySchema = requestBody.content[contentType].schema;
|
|
289
|
+
|
|
290
|
+
// Handle schema references.
|
|
291
|
+
if (bodySchema?.$ref) {
|
|
292
|
+
bodySchema = resolveReference(bodySchema.$ref, openApiSpec);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Process schema properties
|
|
296
|
+
if (bodySchema?.properties) {
|
|
297
|
+
for (const [prop, schema] of Object.entries(bodySchema.properties)) {
|
|
298
|
+
let propSchema = schema;
|
|
299
|
+
|
|
300
|
+
// Handle nested references
|
|
301
|
+
if (propSchema.$ref) {
|
|
302
|
+
propSchema = resolveReference(propSchema.$ref, openApiSpec);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const zodProp = openapiToZod(propSchema, openApiSpec);
|
|
306
|
+
paramsSchema[prop] = bodySchema?.required?.includes(prop) ? zodProp : zodProp.optional();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Builds a Zod parameter schema from an OpenAPI operation
|
|
313
|
+
* @param {NonNullable<ReturnType<typeof getOperationDetails>>['operation']} operation - OpenAPI operation object
|
|
314
|
+
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Complete OpenAPI specification
|
|
315
|
+
* @returns Zod parameter schema
|
|
316
|
+
*/
|
|
317
|
+
export function buildParamsSchema(operation, openApiSpec) {
|
|
318
|
+
/** @type {Record<string, z.ZodType>} */
|
|
319
|
+
const paramsSchema = {};
|
|
320
|
+
|
|
321
|
+
// Process path parameters
|
|
322
|
+
const pathParams = operation?.parameters?.filter((p) => p.in === "path") || [];
|
|
323
|
+
processParameters(pathParams, paramsSchema, openApiSpec);
|
|
324
|
+
|
|
325
|
+
// Process query parameters
|
|
326
|
+
const queryParams = operation?.parameters?.filter((p) => p.in === "query") || [];
|
|
327
|
+
processParameters(queryParams, paramsSchema, openApiSpec);
|
|
328
|
+
|
|
329
|
+
// Process request body if it exists
|
|
330
|
+
if (operation?.requestBody) {
|
|
331
|
+
processRequestBody(operation.requestBody, paramsSchema, openApiSpec);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return paramsSchema;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Sanitizes an operation ID to be used as a tool ID
|
|
339
|
+
* @param {string} operationId - The operation ID to sanitize
|
|
340
|
+
* @returns Sanitized tool ID
|
|
341
|
+
*/
|
|
342
|
+
export function sanitizeToolId(operationId) {
|
|
343
|
+
return operationId.replace(/[^\w-]/g, "-").toLowerCase();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Processes path parameters from the request parameters
|
|
348
|
+
* @param {string} path - API endpoint path with placeholders
|
|
349
|
+
* @param {NonNullable<ReturnType<typeof getOperationDetails>>['operation']} operation - OpenAPI operation object
|
|
350
|
+
* @param {Record<string, string | number>} params - Request parameters
|
|
351
|
+
* @returns Processed path and remaining parameters
|
|
352
|
+
*/
|
|
353
|
+
export function processPathParameters(path, operation, params) {
|
|
354
|
+
let actualPath = path;
|
|
355
|
+
const pathParams = operation.parameters?.filter((p) => p.in === "path") || [];
|
|
356
|
+
const remainingParams = { ...params };
|
|
357
|
+
|
|
358
|
+
for (const param of pathParams) {
|
|
359
|
+
if (params[param.name]) {
|
|
360
|
+
actualPath = actualPath.replace(`{${param.name}}`, encodeURIComponent(params[param.name]));
|
|
361
|
+
delete remainingParams[param.name];
|
|
362
|
+
} else {
|
|
363
|
+
throw new Error(`Required path parameter '${param.name}' is missing`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { actualPath, remainingParams };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Separates parameters into query parameters and body parameters
|
|
372
|
+
* @param {Record<string, string | number>} params - Request parameters
|
|
373
|
+
* @param {NonNullable<ReturnType<typeof getOperationDetails>>['operation']} operation - OpenAPI operation object
|
|
374
|
+
* @param {keyof ReturnType<typeof extractEndpoints>} method - HTTP method (GET, POST, etc.)
|
|
375
|
+
* @returns Separated query and body parameters
|
|
376
|
+
*/
|
|
377
|
+
export function separateParameters(params, operation, method) {
|
|
378
|
+
/** @type Record<string, string | number> */
|
|
379
|
+
const queryParams = {};
|
|
380
|
+
/** @type Record<string, string | number> */
|
|
381
|
+
const bodyParams = {};
|
|
382
|
+
|
|
383
|
+
// Get query parameters from operation definition
|
|
384
|
+
const definedQueryParams =
|
|
385
|
+
operation.parameters?.filter((p) => p.in === "query").map((p) => p.name) || [];
|
|
386
|
+
|
|
387
|
+
// Sort parameters into body or query
|
|
388
|
+
for (const [key, value] of Object.entries(params)) {
|
|
389
|
+
if (definedQueryParams.includes(key)) {
|
|
390
|
+
queryParams[key] = value;
|
|
391
|
+
} else {
|
|
392
|
+
bodyParams[key] = value;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// For GET requests, move all params to query
|
|
397
|
+
if (method.toUpperCase() === "GET") {
|
|
398
|
+
Object.assign(queryParams, bodyParams);
|
|
399
|
+
Object.keys(bodyParams).forEach((key) => delete bodyParams[key]);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { queryParams, bodyParams };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Appends query string parameters to a path
|
|
407
|
+
* @param {string} path - API endpoint path
|
|
408
|
+
* @param {Record<string, string | number>} queryParams - Query parameters
|
|
409
|
+
* @returns Path with query string
|
|
410
|
+
*/
|
|
411
|
+
export function appendQueryString(path, queryParams) {
|
|
412
|
+
if (Object.keys(queryParams).length === 0) {
|
|
413
|
+
return path;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const queryString = new URLSearchParams();
|
|
417
|
+
|
|
418
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
419
|
+
if (value !== undefined && value !== null) {
|
|
420
|
+
queryString.append(key, value.toString());
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return `${path}?${queryString.toString()}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Makes an authenticated request to the Mailjet API
|
|
429
|
+
* @param {keyof ReturnType<typeof extractEndpoints>} method - HTTP method (GET, POST, etc.)
|
|
430
|
+
* @param {string} path - API endpoint path
|
|
431
|
+
* @param {Record<string, string | number> | null} data - Request payload data (for POST/PUT requests)
|
|
432
|
+
* @returns {Promise<JSON>} - Response data as JSON
|
|
433
|
+
*/
|
|
434
|
+
export async function makeMailjetRequest(method, path, data = null) {
|
|
435
|
+
return new Promise((resolve, reject) => {
|
|
436
|
+
// Normalize path format (handle paths with or without leading slash)
|
|
437
|
+
const cleanPath = path.startsWith("/") ? path.substring(1) : path;
|
|
438
|
+
|
|
439
|
+
if (!API_KEY) {
|
|
440
|
+
throw new Error(`Required MAILJET_API_KEY environment variable is missing`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Create basic auth credentials from API key
|
|
444
|
+
const auth = Buffer.from(`${API_KEY}`).toString("base64");
|
|
445
|
+
const options = {
|
|
446
|
+
hostname: API_HOSTNAME,
|
|
447
|
+
path: `/${cleanPath}`,
|
|
448
|
+
method: method,
|
|
449
|
+
headers: {
|
|
450
|
+
Authorization: `Basic ${auth}`,
|
|
451
|
+
"Content-Type": "application/json",
|
|
452
|
+
"User-Agent": `Mailjet/MCP-SERVER-STDIO/${packageInfo.version}`,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Create and send the HTTP request
|
|
457
|
+
const req = https.request(options, (res) => {
|
|
458
|
+
let responseData = "";
|
|
459
|
+
|
|
460
|
+
res.on("data", (chunk) => {
|
|
461
|
+
responseData += chunk;
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
res.on("end", () => {
|
|
465
|
+
try {
|
|
466
|
+
const parsedData = JSON.parse(responseData);
|
|
467
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
468
|
+
resolve(parsedData);
|
|
469
|
+
} else {
|
|
470
|
+
reject(new Error(`Mailjet API error: ${parsedData.message || responseData}`));
|
|
471
|
+
}
|
|
472
|
+
} catch (/** @type any */ e) {
|
|
473
|
+
reject(new Error(`Failed to parse response: ${e.message}`));
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
req.on("error", (error) => {
|
|
479
|
+
reject(error);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// For non-GET requests, serialize and send the form data
|
|
483
|
+
if (data && method !== "GET") {
|
|
484
|
+
// Convert object to URL encoded form data
|
|
485
|
+
const formData = new URLSearchParams();
|
|
486
|
+
for (const [key, value] of Object.entries(data)) {
|
|
487
|
+
if (Array.isArray(value)) {
|
|
488
|
+
for (const item of value) {
|
|
489
|
+
formData.append(key, item);
|
|
490
|
+
}
|
|
491
|
+
} else if (value !== undefined && value !== null) {
|
|
492
|
+
formData.append(key, value.toString());
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
req.write(formData.toString());
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
req.end();
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Validates Mailjet API credentials by making a read-only test request.
|
|
505
|
+
*
|
|
506
|
+
* @param {string} credentials - The Mailjet API key in "PUBLIC_KEY:SECRET_KEY" format.
|
|
507
|
+
* @returns {Promise<boolean>} - Returns true if the keys are valid, false otherwise.
|
|
508
|
+
*/
|
|
509
|
+
export async function validateMailjetKeys(credentials) {
|
|
510
|
+
// Ensure the credentials string exists and is properly formatted
|
|
511
|
+
if (!credentials || typeof credentials !== 'string' || !credentials.includes(':')) {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Mailjet API requires Basic Authentication (Base64 encoded string of "public:private")
|
|
516
|
+
const encodedAuth = Buffer.from(credentials).toString('base64');
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
const response = await fetch('https://api.mailjet.com/v3/REST/user', {
|
|
520
|
+
method: 'GET',
|
|
521
|
+
headers: {
|
|
522
|
+
'Authorization': `Basic ${encodedAuth}`,
|
|
523
|
+
'Content-Type': 'application/json'
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// If Mailjet returns a 200 OK, the keys are perfectly valid
|
|
528
|
+
if (response.ok) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// A 401 Unauthorized or any other non-2xx status means invalid keys/account
|
|
533
|
+
return false;
|
|
534
|
+
|
|
535
|
+
} catch (error) {
|
|
536
|
+
// Catch network errors (e.g., DNS failure, no internet connection)
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Registers a tool with the MCP server
|
|
543
|
+
* @param {string} toolId - Unique tool identifier
|
|
544
|
+
* @param {string} toolDescription - Human-readable description
|
|
545
|
+
* @param {Record<string, z.ZodType>} paramsSchema - Zod schema for parameters
|
|
546
|
+
* @param {keyof ReturnType<typeof extractEndpoints>} method - Supported methods (GET, POST, etc.)
|
|
547
|
+
* @param {string} path - API endpoint path
|
|
548
|
+
* @param {NonNullable<ReturnType<typeof getOperationDetails>>['operation']} operation - OpenAPI operation object
|
|
549
|
+
*/
|
|
550
|
+
export function registerTool(toolId, toolDescription, paramsSchema, method, path, operation) {
|
|
551
|
+
server.tool(toolId, toolDescription, paramsSchema, async (params) => {
|
|
552
|
+
try {
|
|
553
|
+
const { actualPath, remainingParams } = processPathParameters(path, operation, params);
|
|
554
|
+
const { queryParams, bodyParams } = separateParameters(remainingParams, operation, method);
|
|
555
|
+
const finalPath = appendQueryString(actualPath, queryParams);
|
|
556
|
+
|
|
557
|
+
// Make the API request
|
|
558
|
+
const result = await makeMailjetRequest(
|
|
559
|
+
method,
|
|
560
|
+
finalPath,
|
|
561
|
+
method === "GET" ? null : bodyParams,
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
content: [
|
|
566
|
+
{
|
|
567
|
+
type: "text",
|
|
568
|
+
text: `✅ ${method} ${finalPath} completed successfully:\n${JSON.stringify(result, null, 2)}`,
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
};
|
|
572
|
+
} catch (/** @type any */ error) {
|
|
573
|
+
return {
|
|
574
|
+
content: [
|
|
575
|
+
{
|
|
576
|
+
type: "text",
|
|
577
|
+
text: `Error: ${error.message || String(error)}`,
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Generates MCP tools from the OpenAPI specification
|
|
587
|
+
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Parsed OpenAPI specification
|
|
588
|
+
*/
|
|
589
|
+
export function generateToolsFromOpenApi(openApiSpec) {
|
|
590
|
+
const endpoints = extractEndpoints(openApiSpec);
|
|
591
|
+
|
|
592
|
+
for (const path of endpoints.GET) {
|
|
593
|
+
const method = "GET";
|
|
594
|
+
try {
|
|
595
|
+
const operationDetails = getOperationDetails(openApiSpec, method, path);
|
|
596
|
+
|
|
597
|
+
if (!operationDetails) {
|
|
598
|
+
console.warn(`Could not match endpoint: ${method} ${path} in OpenAPI spec`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const { operation, operationId } = operationDetails;
|
|
603
|
+
const paramsSchema = buildParamsSchema(operation, openApiSpec);
|
|
604
|
+
const toolId = sanitizeToolId(operationId);
|
|
605
|
+
const toolDescription = operation?.summary || `${method.toUpperCase()} ${path}`;
|
|
606
|
+
|
|
607
|
+
registerTool(toolId, toolDescription, paramsSchema, method, path, operation);
|
|
608
|
+
} catch (/** @type {any} */ error) {
|
|
609
|
+
console.error(`Failed to process endpoint ${method} ${path}: ${error.message}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Main function to initialize and start the MCP server
|
|
618
|
+
*/
|
|
619
|
+
export async function main() {
|
|
620
|
+
try {
|
|
621
|
+
const isValidApiKey = await validateMailjetKeys(API_KEY)
|
|
622
|
+
|
|
623
|
+
if(!API_KEY || !isValidApiKey) {
|
|
624
|
+
throw new Error(`⚠️ Please provide a valid MAILJET_API_KEY env var before running the mcp server.`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Load and parse OpenAPI spec
|
|
628
|
+
const openApiSpec = await loadOpenApiSpec(OPENAPI_SPEC);
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
const parsedOpenApiSpec = MailjetApiSchema.parse(openApiSpec);
|
|
632
|
+
|
|
633
|
+
// Generate tools from the spec
|
|
634
|
+
generateToolsFromOpenApi(parsedOpenApiSpec);
|
|
635
|
+
} catch (/** @type { any } */ error) {
|
|
636
|
+
throw Error(error);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Connect to the transport
|
|
640
|
+
const transport = new StdioServerTransport();
|
|
641
|
+
await server.connect(transport);
|
|
642
|
+
// This is an STDIO server and log msgs are sent to stdio by default
|
|
643
|
+
// So send to console.error to avoid errors on server startup
|
|
644
|
+
console.error(`Mailjet MCP Server ${packageInfo.version} running on stdio`);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
console.error("Fatal error in main():", error);
|
|
647
|
+
if (process.env.NODE_ENV !== "test") {
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Only auto-execute when not in test environment
|
|
654
|
+
if (process.env.NODE_ENV !== "test") {
|
|
655
|
+
main();
|
|
656
|
+
}
|