Skip to content
256 changes: 256 additions & 0 deletions src/__tests__/lib/get-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import CONFIG from "../../config";

// Mock the config
jest.mock("../../config", () => ({
CLIENT_SETTINGS: {
USER_AGENT:
"mailtrap-nodejs (https://github.com/railsware/mailtrap-nodejs)",
MCP_USER_AGENT: "mailtrap-mcp (https://github.com/railsware/mailtrap-mcp)",
},
}));
Comment on lines +4 to +10
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Mock the default export shape of config to avoid interop breakage

The test imports CONFIG as a default export, but the mock returns a plain object. Under ts-jest/Babel interop, default may be undefined. Return { __esModule: true, default: ... }.

Apply this diff:

-jest.mock("../../config", () => ({
-  CLIENT_SETTINGS: {
-    USER_AGENT:
-      "mailtrap-nodejs (https://github.com/railsware/mailtrap-nodejs)",
-    MCP_USER_AGENT: "mailtrap-mcp (https://github.com/railsware/mailtrap-mcp)",
-  },
-}));
+jest.mock("../../config", () => ({
+  __esModule: true,
+  default: {
+    CLIENT_SETTINGS: {
+      USER_AGENT:
+        "mailtrap-nodejs (https://github.com/railsware/mailtrap-nodejs)",
+      MCP_USER_AGENT:
+        "mailtrap-mcp (https://github.com/railsware/mailtrap-mcp)",
+    },
+  },
+}));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
jest.mock("../../config", () => ({
CLIENT_SETTINGS: {
USER_AGENT:
"mailtrap-nodejs (https://github.com/railsware/mailtrap-nodejs)",
MCP_USER_AGENT: "mailtrap-mcp (https://github.com/railsware/mailtrap-mcp)",
},
}));
jest.mock("../../config", () => ({
__esModule: true,
default: {
CLIENT_SETTINGS: {
USER_AGENT:
"mailtrap-nodejs (https://github.com/railsware/mailtrap-nodejs)",
MCP_USER_AGENT:
"mailtrap-mcp (https://github.com/railsware/mailtrap-mcp)",
},
},
}));
🤖 Prompt for AI Agents
In src/__tests__/lib/get-agent.test.ts around lines 4 to 10, the jest.mock
currently returns a plain object which breaks default-import interop under
ts-jest/Babel; update the mock to export a proper ES module shape by returning
an object with __esModule: true and a default property containing the original
CLIENT_SETTINGS object so the imported CONFIG default is defined.


describe("get-agent", () => {
let originalCwd: string;
let originalError: typeof Error;

beforeEach(() => {
// Store original values
originalCwd = process.cwd();
originalError = global.Error;
});

afterEach(() => {
// Restore original values
process.cwd = jest.fn().mockReturnValue(originalCwd);
global.Error = originalError;
jest.clearAllMocks();
});
Comment on lines +13 to +27
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Restore the real process.cwd; don’t replace it with a mock

You’re saving the cwd value (string) and then replacing process.cwd with a mock that returns that string. This leaks a mocked function into global state across the suite.

Apply this minimal fix (and prefer spy-based mocking in tests):

-  let originalCwd: string;
+  let originalCwdFn: typeof process.cwd;
   let originalError: typeof Error;

   beforeEach(() => {
     // Store original values
-    originalCwd = process.cwd();
+    originalCwdFn = process.cwd;
     originalError = global.Error;
   });

   afterEach(() => {
     // Restore original values
-    process.cwd = jest.fn().mockReturnValue(originalCwd);
+    process.cwd = originalCwdFn;
     global.Error = originalError;
-    jest.clearAllMocks();
+    jest.restoreAllMocks();
   });

Follow-up (recommended): replace all direct assignments to process.cwd in tests with jest.spyOn(process, "cwd").mockReturnValue(...).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let originalCwd: string;
let originalError: typeof Error;
beforeEach(() => {
// Store original values
originalCwd = process.cwd();
originalError = global.Error;
});
afterEach(() => {
// Restore original values
process.cwd = jest.fn().mockReturnValue(originalCwd);
global.Error = originalError;
jest.clearAllMocks();
});
let originalCwdFn: typeof process.cwd;
let originalError: typeof Error;
beforeEach(() => {
// Store original values
originalCwdFn = process.cwd;
originalError = global.Error;
});
afterEach(() => {
// Restore original values
process.cwd = originalCwdFn;
global.Error = originalError;
jest.restoreAllMocks();
});
🤖 Prompt for AI Agents
In src/__tests__/lib/get-agent.test.ts around lines 13-27, the test saves only
the cwd string and then replaces process.cwd with a mock that returns that
string, leaking a mocked function into global state; change the setup to save
the original process.cwd function (e.g., originalCwdFn = process.cwd) and in
afterEach restore the original function (process.cwd = originalCwdFn) instead of
assigning a jest.fn(), and, going forward, use jest.spyOn(process,
"cwd").mockReturnValue(...) when mocking cwd in tests so the spy can be restored
automatically.


describe("getDynamicUserAgent", () => {
it("should return USER_AGENT by default", () => {
// Import after mocking to get fresh module
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});

it("should return MCP_USER_AGENT when cwd contains 'mailtrap-mcp' and not in node_modules", () => {
process.cwd = jest.fn().mockReturnValue("/path/to/mailtrap-mcp");

// Clear module cache and re-import to get fresh module
jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.MCP_USER_AGENT);
});

it("should return USER_AGENT when cwd contains 'mailtrap-mcp' but is in node_modules", () => {
process.cwd = jest
.fn()
.mockReturnValue("/path/to/node_modules/mailtrap-mcp");

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});

it("should return USER_AGENT when process.cwd() throws an error", () => {
process.cwd = jest.fn().mockImplementation(() => {
throw new Error("Permission denied");
});

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});

it("should return MCP_USER_AGENT when call stack contains 'mailtrap-mcp' and not from node_modules/mailtrap", () => {
global.Error = jest.fn().mockImplementation(() => ({
stack: `
Error: Test error
at Object.<anonymous> (/path/to/mailtrap-mcp/index.js:10:1)
at Module._compile (internal/modules/cjs/loader.js:1063:30)
`,
})) as any;

process.cwd = jest.fn().mockReturnValue("/path/to/regular-app");

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.MCP_USER_AGENT);
});

it("should return USER_AGENT when call stack contains 'mailtrap-mcp' but is from node_modules/mailtrap", () => {
global.Error = jest.fn().mockImplementation(() => ({
stack: `
Error: Test error
at Object.<anonymous> (/path/to/node_modules/mailtrap/index.js:10:1)
at Module._compile (internal/modules/cjs/loader.js:1063:30)
`,
})) as any;

process.cwd = jest.fn().mockReturnValue("/path/to/regular-app");

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});

it("should return USER_AGENT when call stack does not contain 'mailtrap-mcp'", () => {
global.Error = jest.fn().mockImplementation(() => ({
stack: `
Error: Test error
at Object.<anonymous> (/path/to/regular-app/index.js:10:1)
at Module._compile (internal/modules/cjs/loader.js:1063:30)
`,
})) as any;

process.cwd = jest.fn().mockReturnValue("/path/to/regular-app");

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});

it("should return USER_AGENT when call stack is undefined", () => {
global.Error = jest.fn().mockImplementation(() => ({
stack: undefined,
})) as any;

process.cwd = jest.fn().mockReturnValue("/path/to/regular-app");

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});

it("should handle edge cases gracefully", () => {
// All undefined/null cases
process.cwd = jest.fn().mockImplementation(() => {
throw new Error("Permission denied");
});
global.Error = jest.fn().mockImplementation(() => ({
stack: undefined,
})) as any;

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});
});

describe("real-world scenarios", () => {
it("should detect MCP context when running from MCP working directory", () => {
process.cwd = jest
.fn()
.mockReturnValue("/Users/user/projects/mailtrap-mcp");

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.MCP_USER_AGENT);
});

it("should not detect MCP context when used as a regular npm package", () => {
process.cwd = jest.fn().mockReturnValue("/Users/user/projects/my-app");

jest.resetModules();
const { default: getDynamicUserAgent } = jest.requireActual(
"../../lib/get-agent"
);

const result = getDynamicUserAgent();
expect(result).toBe(CONFIG.CLIENT_SETTINGS.USER_AGENT);
});
});

describe("integration with MailtrapClient", () => {
it("should be used by MailtrapClient for User-Agent header", () => {
// Mock the full config for MailtrapClient
jest.doMock("../../config", () => ({
CLIENT_SETTINGS: {
SENDING_ENDPOINT: "https://send.api.mailtrap.io",
BULK_ENDPOINT: "https://bulk.api.mailtrap.io",
TESTING_ENDPOINT: "https://sandbox.api.mailtrap.io",
GENERAL_ENDPOINT: "https://mailtrap.io",
USER_AGENT:
"mailtrap-nodejs (https://github.com/railsware/mailtrap-nodejs)",
MCP_USER_AGENT:
"mailtrap-mcp (https://github.com/railsware/mailtrap-mcp)",
MAX_REDIRECTS: 0,
TIMEOUT: 10000,
},
ERRORS: {
FILENAME_REQUIRED: "Filename is required.",
CONTENT_REQUIRED: "Content is required.",
SUBJECT_REQUIRED: "Subject is required.",
FROM_REQUIRED: "From is required.",
SENDING_FAILED: "Sending failed.",
NO_DATA_ERROR: "No Data.",
TEST_INBOX_ID_MISSING:
"testInboxId is missing, testing API will not work.",
ACCOUNT_ID_MISSING:
"accountId is missing, some features of testing API may not work properly.",
BULK_SANDBOX_INCOMPATIBLE:
"Bulk mode is not applicable for sandbox API.",
},
TRANSPORT_SETTINGS: {
NAME: "MailtrapTransport",
},
}));

// Clear module cache and re-import
jest.resetModules();
const { default: MailtrapClient } = jest.requireActual(
"../../lib/MailtrapClient"
);

// Create a client instance
const client = new MailtrapClient({
token: "test-token",
});

// The User-Agent should be set in the axios instance
// We can't easily test the internal axios instance, but we can verify
// that the function is called during client creation
expect(client).toBeDefined();
});
});
});
1 change: 1 addition & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default {
GENERAL_ENDPOINT: "https://mailtrap.io",
USER_AGENT:
"mailtrap-nodejs (https://github.com/railsware/mailtrap-nodejs)",
MCP_USER_AGENT: "mailtrap-mcp (https://github.com/railsware/mailtrap-mcp)",
MAX_REDIRECTS: 0,
TIMEOUT: 10000,
},
Expand Down
8 changes: 4 additions & 4 deletions src/lib/MailtrapClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import axios, { AxiosInstance } from "axios";
import encodeMailBuffers from "./mail-buffer-encoder";
import handleSendingError from "./axios-logger";
import MailtrapError from "./MailtrapError";
import getDynamicUserAgent from "./get-agent";

import GeneralAPI from "./api/General";
import TestingAPI from "./api/Testing";
import ContactsBaseAPI from "./api/Contacts";
import ContactListsBaseAPI from "./api/ContactLists";
import TemplatesBaseAPI from "./api/Templates";
import SuppressionsBaseAPI from "./api/Suppressions";

import CONFIG from "../config";

Expand All @@ -22,13 +24,11 @@ import {
BatchSendResponse,
BatchSendRequest,
} from "../types/mailtrap";
import SuppressionsBaseAPI from "./api/Suppressions";

const { CLIENT_SETTINGS, ERRORS } = CONFIG;
const {
SENDING_ENDPOINT,
MAX_REDIRECTS,
USER_AGENT,
TIMEOUT,
TESTING_ENDPOINT,
BULK_ENDPOINT,
Expand Down Expand Up @@ -66,7 +66,7 @@ export default class MailtrapClient {
headers: {
Authorization: `Bearer ${token}`,
Connection: "keep-alive",
"User-Agent": USER_AGENT,
"User-Agent": getDynamicUserAgent(),
},
maxRedirects: MAX_REDIRECTS,
timeout: TIMEOUT,
Expand Down Expand Up @@ -182,12 +182,12 @@ export default class MailtrapClient {
*/
public async send(mail: Mail): Promise<SendResponse> {
const host = this.determineHost();

this.validateTestInboxIdPresence();

const url = `${host}/api/send${
this.sandbox && this.testInboxId ? `/${this.testInboxId}` : ""
}`;

const preparedMail = encodeMailBuffers(mail);

return this.axios.post<SendResponse, SendResponse>(url, preparedMail);
Expand Down
75 changes: 75 additions & 0 deletions src/lib/get-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import CONFIG from "../config";

const { USER_AGENT, MCP_USER_AGENT } = CONFIG.CLIENT_SETTINGS;

/**
* Checks if the main module filename indicates MCP context.
* @returns true if main module contains "mailtrap-mcp" and is not in node_modules
*/
function isMainModuleMCP(): boolean {
const mainFile = require?.main?.filename;

return !!(
mainFile &&
mainFile.includes("mailtrap-mcp") &&
!mainFile.includes("node_modules")
);
}
Comment on lines +9 to +17
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix ESM ReferenceError risk from using require directly.

require?.main still throws in pure ESM because require is an unbound identifier. Guard with typeof require !== 'undefined' and fall back to process.argv[1].

Apply this diff:

 function isMainModuleMCP(): boolean {
-  const mainFile = require?.main?.filename;
-
-  return !!(
-    mainFile &&
-    mainFile.includes("mailtrap-mcp") &&
-    !mainFile.includes("node_modules")
-  );
+  const mainFile =
+    (typeof require !== "undefined" ? require.main?.filename : undefined) ??
+    process.argv?.[1];
+  if (!mainFile) return false;
+  const lower = mainFile.toLowerCase();
+  return lower.includes("mailtrap-mcp") && !lower.includes("node_modules");
 }
🤖 Prompt for AI Agents
In src/lib/get-agent.ts around lines 9 to 17, avoid referencing the unbound
require in pure ESM by first checking typeof require !== 'undefined' and using
require.main?.filename only when defined, otherwise fall back to
process.argv[1]; replace const mainFile = require?.main?.filename with a safe
resolution like: if typeof require !== 'undefined' and require.main use
require.main.filename else use process.argv[1], then keep the subsequent
includes checks the same to determine mailtrap-mcp presence and node_modules
exclusion.


/**
* Checks if running in MCP runtime context (Claude Desktop).
* @returns true if main module is from MCP runtime
*/
function isMCPRuntimeContext(): boolean {
const mainFile = require?.main?.filename;

return !!(
mainFile &&
(mainFile.includes("mcp-runtime") ||
mainFile.includes("nodeHost.js") ||
mainFile.includes("Claude.app"))
);
}

/**
* Checks if the current working directory indicates MCP context.
* @returns true if cwd contains "mailtrap-mcp" and is not in node_modules
*/
function isWorkingDirectoryMCP(): boolean {
try {
const cwd = process.cwd();
return cwd.includes("mailtrap-mcp") && !cwd.includes("node_modules");
} catch {
return false;
}
}

/**
* Checks if the call stack indicates MCP context.
* @returns true if stack contains "mailtrap-mcp" and is not from node_modules/mailtrap
*/
function isCallStackMCP(): boolean {
const { stack } = new Error();

return !!(
stack &&
stack.includes("mailtrap-mcp") &&
!stack.includes("node_modules/mailtrap")
);
}

/**
* Gets the appropriate User-Agent string based on the current context.
* @returns The User-Agent string for the current context
*/
function getDynamicUserAgent(): string {
Copy link
Contributor

Choose a reason for hiding this comment

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

If you ask me, bringing the MCP concern to the SDK project is a violation of the SRP 🙂 I would approach this differently and allow specifying the user agent directly via a configuration setting of the SDK client. And, override this setting in the MCP server NPM package.
It's simpler, and it's not a big deal to allow users of the SDK to override the user agent; it's fairly easy to do this anyway.
WDYT?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

if allowing users to override user agent is not a problem, then its simple solution. I thought that we don't want to enable users to change the user agent.

Copy link
Contributor

Choose a reason for hiding this comment

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

@yanchuk WDYT?

Copy link
Member

Choose a reason for hiding this comment

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

I agree with @vittorius, it's cleaner to allow to override user agent, and it might be useful for other purposes (for the users), too.

const isMailtrapMCPContext =
isMainModuleMCP() ||
isWorkingDirectoryMCP() ||
isCallStackMCP() ||
isMCPRuntimeContext();

return isMailtrapMCPContext ? MCP_USER_AGENT : USER_AGENT;
}

export default getDynamicUserAgent;