Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ logs
*.swp
*.swo
.DS_Store
/ngrok.yml

.npmrc
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.0] - 2026-07-08

### Added

- **Module Architecture**:
- Created `src/index.js` as the primary entry point, exporting core MCP and OpenAPI schema logic.
- Added an `"exports"` block to `package.json` to support external `import`.
- Implemented security to prevent auto-execution when `mailjet-mcp.js` is imported as a library.
Comment thread
Copilot marked this conversation as resolved.
- Added tests for `makeMailjetRequest`, asserting that it correctly uses parameters and environment variable.
- **Tooling & Housekeeping**:
- Added a new `npm run start` script using Node's native `--watch` flag.
- Appended `/ngrok.yml` and `.npmrc` to `.gitignore` rules.

### Changed

- **Dynamic Context & Credentials**:
- Updated `generateToolsFromOpenApi`, `registerTool`, and `makeMailjetRequest` to accept dynamic `serverInstance` and `userContext` parameters.
- Fixed an unhandled exception in `makeMailjetRequest` by using `reject()` instead of throwing an error directly inside the Promise constructor.
- **Configuration & Metadata**:
- Extracted and exported `mcpVersion` and `mcpRequestHeaders`.
- Updated the standard `User-Agent` header from `STDIO` to `HTTP` when used through external project.
Comment thread
Copilot marked this conversation as resolved.

## [1.0.3] - 2026-06-25

### Added

- **Introduced request options helper function** — centralized HTTP configuration for the Mailjet API.
- Added the `getRequestOptionsMCPForAuth()` helper function to encapsulate the `hostname` and `headers` definitions (including `Authorization`, `Content-Type`, and `User-Agent`), promoting reuse across different request methods.

Expand Down Expand Up @@ -73,6 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `pnpm/action-setup` → pinned SHA `0e279bb...` (v6.0.8)
- `pnpm` version locked to `10.33.4`

[1.1.0]: https://github.com/mailgun/mailjet-mcp-server/compare/v1.0.3...v1.1.0
[1.0.3]: https://github.com/mailgun/mailjet-mcp-server/compare/v1.0.2...v1.0.3
[1.0.2]: https://github.com/mailgun/mailjet-mcp-server/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/mailgun/mailjet-mcp-server/compare/v1.0.0...v1.0.1
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mailjet/mailjet-mcp-server",
"version": "1.0.3",
"version": "1.1.0-beta.0",
"type": "module",
"bin": {
"mailjet-mcp-server": "src/mailjet-mcp.js"
Expand All @@ -18,7 +18,13 @@
"LICENSE",
"README.md"
],
"exports": {
".": {
"import": "./src/index.js"
}
},
"scripts": {
"start": "node --watch src/mailjet-mcp.js",
Comment thread
obennett-m marked this conversation as resolved.
"test": "NODE_ENV=test node --test tests/mailjet-mcp.test.js"
},
"keywords": [
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './mailjet-mcp.js'
export * from './mailjet-openapi-schema.js'
99 changes: 65 additions & 34 deletions src/mailjet-mcp.js
Comment thread
obennett-m marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/env node

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import yaml from "js-yaml";
Expand All @@ -13,9 +12,17 @@ import packageInfo from "../package.json" with { type: "json" };

const __dirname = import.meta.dirname;

const isLocalUse = !!process.argv[1] && resolve(process.argv[1]) === import.meta.filename;
// Identify if the file is currently imported by another project, which should prevent from firing main
const isImported = !isLocalUse;
export const mcpVersion = packageInfo.version
// Headers that will be used to identify MCP as a source for Mailjet request generated, considering we will serve imported version through http
export const mcpRequestHeaders = {
"User-Agent": `Mailjet/MCP-SERVER-${isImported ? 'HTTP' : 'STDIO'}/${mcpVersion}`
}
export const server = new McpServer({
name: "mailjet",
version: packageInfo.version,
version: mcpVersion,
});

// Mailjet API authentication credentials in the documented BASIC Auth form "api_key:secret_key"
Expand All @@ -26,6 +33,8 @@ const API_REGION = process.env.MAILJET_API_REGION?.toLowerCase();
const API_HOSTNAME = `api.${API_REGION ? `${API_REGION}.` : ""}mailjet.com`;
// Path to openapi spec file
const OPENAPI_SPEC = resolve(__dirname, "openapi-mailjet.yaml");
// Load and parse OpenAPI spec
const openApiSpec = await loadOpenApiSpec(OPENAPI_SPEC);

/**
* Extracts all endpoints from the OpenAPI specification and organizes them by HTTP method.
Expand Down Expand Up @@ -423,36 +432,55 @@ export function appendQueryString(path, queryParams) {

return `${path}?${queryString.toString()}`;
}
const getRequestOptionsMCPForAuth = () => {
const auth = Buffer.from(`${API_KEY}`).toString("base64")

/**
* Returns request options for authenticated requests to Mailjet API
* @param {string=} sessionAuth - Session authentication token
* @returns Request options object
*/
const getRequestOptionsMCPForAuth = (sessionAuth) => {
// Defaulting to environment variable for API_KEY if not provided as parameter
const auth = Buffer.from(`${sessionAuth || API_KEY}`).toString("base64")
return {
hostname: API_HOSTNAME,
headers: {
...mcpRequestHeaders,
Authorization: `Basic ${auth}`,
"Content-Type": "application/json",
"User-Agent": `Mailjet/MCP-SERVER-STDIO/${packageInfo.version}`,
},
}
}

/**
* @typedef {Object} UserContext
* @property {string=} apiKey - The public API key associated with the user's session.
* @property {string=} secretKey - The private secret key associated with the user's session.
*/

/**
* Makes an authenticated request to the Mailjet API
* @param {keyof ReturnType<typeof extractEndpoints>} method - HTTP method (GET, POST, etc.)
* @param {string} path - API endpoint path
* @param {Record<string, string | number> | null} data - Request payload data (for POST/PUT requests)
* @param {UserContext} [userContext={}] - Optional User context containing credentials
* @returns {Promise<JSON>} - Response data as JSON
*/
export async function makeMailjetRequest(method, path, data = null) {
export async function makeMailjetRequest(method, path, data = null, userContext = {}) {
return new Promise((resolve, reject) => {
// Normalize path format (handle paths with or without leading slash)
const cleanPath = path.startsWith("/") ? path.substring(1) : path;

const { apiKey: sessionApiKey, secretKey: sessionSecretKey } = userContext;
// Enabling to both use the mcp server running this file as it is or importing the logic to external project
const API_KEY = sessionApiKey && sessionSecretKey ? `${sessionApiKey}:${sessionSecretKey}` : process.env.MAILJET_API_KEY;

if (!API_KEY) {
throw new Error(`Required MAILJET_API_KEY environment variable is missing`);
reject(`API keys are missing from user context and environment.`);
return;
}
Comment thread
KingPepito marked this conversation as resolved.

const options = {
...getRequestOptionsMCPForAuth(),
...getRequestOptionsMCPForAuth(API_KEY),
path: `/${cleanPath}`,
method,
};
Expand All @@ -479,8 +507,8 @@ export async function makeMailjetRequest(method, path, data = null) {
});
});

req.on("error", (error) => {
reject(error);
req.on("error", (err) => {
reject(`${err}\nNetwork unavailable. Credentials can not be validated.`);
});

// For non-GET requests, serialize and send the form data
Expand Down Expand Up @@ -515,6 +543,7 @@ export async function validateMailjetKeys(credentials) {
// Ensure the credentials string exists and is properly formatted
if (!credentials || typeof credentials !== 'string' || !credentials.includes(':')) {
resolve(false);
return
}

try {
Expand All @@ -538,11 +567,19 @@ export async function validateMailjetKeys(credentials) {
resolve(false);
}
});

req.on("error", (err) => {
reject(`${err}\nNetwork unavailable. Credentials can not be validated.`);
});
});

req.on("error", (err) => {
// Catch network errors (e.g., DNS failure, no internet connection)
reject(`${err}\nNetwork unavailable. Credentials can not be validated.`);
});
Comment on lines 569 to +579

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


req.end();
} catch (error) {
// Catch network errors (e.g., DNS failure, no internet connection)
reject(error)
}
})
Expand All @@ -556,9 +593,11 @@ export async function validateMailjetKeys(credentials) {
* @param {keyof ReturnType<typeof extractEndpoints>} method - Supported methods (GET, POST, etc.)
* @param {string} path - API endpoint path
* @param {NonNullable<ReturnType<typeof getOperationDetails>>['operation']} operation - OpenAPI operation object
* @param serverInstance - MCP server instance to register the tool on (defaults to main server)
* @param {UserContext} [userContext={}] - Optional User context containing credentials
*/
export function registerTool(toolId, toolDescription, paramsSchema, method, path, operation) {
server.tool(toolId, toolDescription, paramsSchema, async (params) => {
export function registerTool(toolId, toolDescription, paramsSchema, method, path, operation, serverInstance = server, userContext = {}) {
serverInstance.tool(toolId, toolDescription, paramsSchema, async (params) => {
try {
const { actualPath, remainingParams } = processPathParameters(path, operation, params);
const { queryParams, bodyParams } = separateParameters(remainingParams, operation, method);
Expand All @@ -569,6 +608,7 @@ export function registerTool(toolId, toolDescription, paramsSchema, method, path
method,
finalPath,
method === "GET" ? null : bodyParams,
userContext
);

return {
Expand All @@ -595,15 +635,15 @@ export function registerTool(toolId, toolDescription, paramsSchema, method, path
/**
* Generates MCP tools from the OpenAPI specification
* @param {z.infer<typeof MailjetApiSchema>} openApiSpec - Parsed OpenAPI specification
* @param serverInstance - MCP server instance to register tools on (defaults to main server)
* @param {UserContext} [userContext={}] - Optional User context containing credentials
*/
export function generateToolsFromOpenApi(openApiSpec) {
export function generateToolsFromOpenApi(openApiSpec, serverInstance = server, userContext = {}) {
const endpoints = extractEndpoints(openApiSpec);

for (const path of endpoints.GET) {
const method = "GET";
try {
const operationDetails = getOperationDetails(openApiSpec, method, path);

if (!operationDetails) {
console.warn(`Could not match endpoint: ${method} ${path} in OpenAPI spec`);
Comment thread
obennett-m marked this conversation as resolved.
continue;
Expand All @@ -614,44 +654,35 @@ export function generateToolsFromOpenApi(openApiSpec) {
const toolId = sanitizeToolId(operationId);
const toolDescription = operation?.summary || `${method.toUpperCase()} ${path}`;

registerTool(toolId, toolDescription, paramsSchema, method, path, operation);
registerTool(toolId, toolDescription, paramsSchema, method, path, operation, serverInstance, userContext);
} catch (/** @type {any} */ error) {
console.error(`Failed to process endpoint ${method} ${path}: ${error.message}`);
}
}

return;
}

/**
* Main function to initialize and start the MCP server
*/
export async function main() {
try {
const parsedOpenApiSpec = MailjetApiSchema.parse(openApiSpec);

const isValidApiKey = await validateMailjetKeys(API_KEY)

if(!API_KEY || !isValidApiKey) {
if (!API_KEY || !isValidApiKey) {
throw new Error(`⚠️ Please provide a valid MAILJET_API_KEY environment variable before running the mcp server.`);
}

// Load and parse OpenAPI spec
const openApiSpec = await loadOpenApiSpec(OPENAPI_SPEC);

try {
const parsedOpenApiSpec = MailjetApiSchema.parse(openApiSpec);

// Generate tools from the spec
generateToolsFromOpenApi(parsedOpenApiSpec);
} catch (/** @type { any } */ error) {
throw Error(error);
}
// Generate tools from the spec
generateToolsFromOpenApi(parsedOpenApiSpec);

// Connect to the transport
const transport = new StdioServerTransport();
await server.connect(transport);
// This is an STDIO server and log msgs are sent to stdio by default
// So send to console.error to avoid errors on server startup
console.error(`Mailjet MCP Server ${packageInfo.version} running on stdio`);
console.error(`Mailjet MCP Server ${mcpVersion} running on stdio`);
} catch (error) {
console.error("Fatal error in main():", error);
if (process.env.NODE_ENV !== "test") {
Expand All @@ -661,6 +692,6 @@ export async function main() {
}

// Only auto-execute when not in test environment
if (process.env.NODE_ENV !== "test") {
if (process.env.NODE_ENV !== "test" && !isImported) {
main();
}
}
69 changes: 68 additions & 1 deletion tests/mailjet-mcp.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { describe, it, mock } from "node:test";

import * as serverModule from "../src/mailjet-mcp.js";
import https from "node:https";
import EventEmitter from "node:events";

// Mock OpenAPI spec for testing
const mockOpenApiSpec = {
Expand Down Expand Up @@ -247,3 +249,68 @@ describe("openapiToZod", () => {
assert.strictEqual(zod._def.type, "object");
});
});

describe("makeMailjetRequest", () => {
const testApiKey = "env_api_key";
const testSecretKey = "env_secret_key";
const testApiKeys = `${testApiKey}:${testSecretKey}`;
function createMockResponse(statusCode, dataString) {
const res = new EventEmitter();
res.statusCode = statusCode;

// Simulate the chunks of data coming in
process.nextTick(() => {
res.emit("data", Buffer.from(dataString));
res.emit("end");
});

return res;
}

function createMockRequest(statusCode, payload) {
// Prevent previous tests from creating board effects
mock.restoreAll();
mock.method(https, "request", (_options, callback) => {
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
const mockRes = createMockResponse(statusCode, body);
callback(mockRes);

// Return a mock request object
return {
on: () => {},
write: () => {},
end: () => {},
};
});
}
const mockResponseData = JSON.stringify({ message: "success" });

it("should reject the request if the api keys are missing from user context and environment", async () => {
await assert.rejects(
serverModule.makeMailjetRequest("GET", "/v3/REST/message", {}, {}),
/API keys(.+)missing/
);
});
it("should reject with an error message on a failed API response", async () => {
const mockErrorPayload = JSON.stringify({ message: "Invalid API key" });
createMockRequest(401, mockErrorPayload);
await assert.rejects(
serverModule.makeMailjetRequest("GET", "/v3/REST/message", {}, {apiKey: testApiKey, secretKey: testSecretKey}),
/Mailjet API error:/
);
});
it("should use API keys from user context when provided", async () => {
createMockRequest(200, mockResponseData);
// Mock the underlying https.request to prevent a real network call
const userContext = { apiKey: "test_api_key", secretKey: "test_secret_key" };
await assert.doesNotReject(serverModule.makeMailjetRequest("GET", "/v3/REST/message", {}, userContext))
});
it("should use API key from environment variables if user context is empty", async () => {
process.env.MAILJET_API_KEY = testApiKeys;
createMockRequest(200, mockResponseData);
// Assuming a mock setup for https.request
await serverModule.makeMailjetRequest("GET", "/v3/REST/message", {}, {});
await assert.doesNotReject(serverModule.makeMailjetRequest("GET", "/v3/REST/message", {}));
delete process.env.MAILJET_API_KEY; // Clean up environment variable
});
});