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
71 changes: 71 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,76 @@
import { getOrganizationClient } from "../client";

describe("MCP client info forwarding", () => {
const originalEnv = { ...process.env };

afterEach(() => {
process.env = { ...originalEnv };
jest.resetModules();
});

// The default client reads MAILTRAP_API_TOKEN at import time, so load a fresh
// module instance per test with the token in place.
function loadClientModule(): typeof import("../client") {
let mod: typeof import("../client") | undefined;
process.env.MAILTRAP_API_TOKEN = "test-token";
jest.isolateModules(() => {
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
mod = require("../client");
});
return mod as typeof import("../client");
}

function headerString(client: unknown): string {
return JSON.stringify(
(client as { axios: { defaults: { headers: unknown } } }).axios.defaults
.headers
);
}

it("forwards the MCP client identity in the default client's User-Agent", () => {
const client = loadClientModule();
client.setMcpClientInfoProvider(() => ({
name: "Claude Desktop",
version: "1.5.3",
}));

const mailtrap = client.requireClient("test", { requireAccountId: false });
expect(headerString(mailtrap)).toContain("(client: Claude Desktop/1.5.3)");
});

it("reflects an identity that becomes available after an earlier call", () => {
const client = loadClientModule();
let info: { name: string; version: string } | undefined;
client.setMcpClientInfoProvider(() => info);

// Before the handshake identity is known: base User-Agent.
const before = client.requireClient("test", { requireAccountId: false });
expect(headerString(before)).not.toContain("(client:");

// Once known, later clients forward it — nothing stale is cached.
info = { name: "Cursor", version: "2.0.0" };
const after = client.requireClient("test", { requireAccountId: false });
expect(headerString(after)).toContain("(client: Cursor/2.0.0)");
});

it("forwards the identity via the sandbox client too", () => {
const client = loadClientModule();
client.setMcpClientInfoProvider(() => ({ name: "Windsurf", version: "3" }));

expect(headerString(client.getSandboxClient(123))).toContain(
"(client: Windsurf/3)"
);
});

it("uses the base User-Agent when no MCP client identity is available", () => {
const client = loadClientModule();

expect(headerString(client.getSandboxClient(123))).not.toContain(
"(client:"
);
});
});

describe("getOrganizationClient", () => {
const originalEnv = { ...process.env };

Expand Down
64 changes: 44 additions & 20 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
import { MailtrapClient } from "mailtrap";
import config from "./config";
import { buildUserAgent, McpClientInfo } from "./utils/userAgent";

const { MAILTRAP_API_TOKEN } = process.env;

// Create client only if API token is available
const client = (
MAILTRAP_API_TOKEN
? new MailtrapClient({
token: MAILTRAP_API_TOKEN,
userAgent: config.USER_AGENT,
// conditionally set accountId if it's a valid number
...(process.env.MAILTRAP_ACCOUNT_ID &&
!Number.isNaN(Number(process.env.MAILTRAP_ACCOUNT_ID))
? { accountId: Number(process.env.MAILTRAP_ACCOUNT_ID) }
: {}),
})
: null
) as MailtrapClient;
let mcpClientInfoProvider: (() => McpClientInfo | undefined) | undefined;

/**
* Registers a provider for the MCP client identity (name + version) reported
* during the `initialize` handshake, so it can be forwarded in the API
* User-Agent. The provider is read lazily at client-construction time — which
* only happens while handling a tool call, i.e. strictly after `initialize` —
* so the identity is always available by then and no stale value is cached.
*/
function setMcpClientInfoProvider(
provider: () => McpClientInfo | undefined
): void {
mcpClientInfoProvider = provider;
}

function getUserAgent(): string {
return buildUserAgent(mcpClientInfoProvider?.());
}

/**
* Default (transactional) MailtrapClient. Constructed on demand — like the
* other getters below — so its User-Agent always reflects the current MCP
* client identity. Null when no API token is configured.
*/
function getDefaultClient(): MailtrapClient | null {
if (!MAILTRAP_API_TOKEN) {
return null;
}
return new MailtrapClient({
token: MAILTRAP_API_TOKEN,
userAgent: getUserAgent(),
// conditionally set accountId if it's a valid number
...(process.env.MAILTRAP_ACCOUNT_ID &&
!Number.isNaN(Number(process.env.MAILTRAP_ACCOUNT_ID))
? { accountId: Number(process.env.MAILTRAP_ACCOUNT_ID) }
: {}),
});
}

/**
* Returns a sandbox MailtrapClient for the given test inbox ID.
Expand All @@ -28,7 +52,7 @@ function getSandboxClient(inboxId: number): MailtrapClient {
}
return new MailtrapClient({
token: MAILTRAP_API_TOKEN,
userAgent: config.USER_AGENT,
userAgent: getUserAgent(),
testInboxId: inboxId,
sandbox: true,
...(process.env.MAILTRAP_ACCOUNT_ID &&
Expand All @@ -47,7 +71,7 @@ function getBulkClient(): MailtrapClient {
}
return new MailtrapClient({
token: MAILTRAP_API_TOKEN,
userAgent: config.USER_AGENT,
userAgent: getUserAgent(),
bulk: true,
...(process.env.MAILTRAP_ACCOUNT_ID &&
!Number.isNaN(Number(process.env.MAILTRAP_ACCOUNT_ID))
Expand Down Expand Up @@ -81,7 +105,7 @@ function getOrganizationClient(): MailtrapClient {
}
return new MailtrapClient({
token,
userAgent: config.USER_AGENT,
userAgent: getUserAgent(),
organizationId: parsedOrganizationId,
});
}
Expand All @@ -101,6 +125,7 @@ function requireClient(
feature: string,
{ requireAccountId = true }: { requireAccountId?: boolean } = {}
): MailtrapClient {
const client = getDefaultClient();
if (!client) {
throw new Error("MAILTRAP_API_TOKEN environment variable is required");
}
Expand All @@ -115,9 +140,8 @@ function requireClient(
return client;
}

// eslint-disable-next-line import/prefer-default-export
export {
client,
setMcpClientInfoProvider,
getSandboxClient,
getBulkClient,
getOrganizationClient,
Expand Down
2 changes: 1 addition & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import MCP_SERVER_VERSION from "./version";
export default {
MCP_SERVER_NAME: "mailtrap-mcp-server",
MCP_SERVER_VERSION,
USER_AGENT: "mailtrap-mcp (https://github.com/mailtrap/mailtrap-mcp)",
USER_AGENT: `mailtrap-mcp/${MCP_SERVER_VERSION} (+https://github.com/mailtrap/mailtrap-mcp)`,
};
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ListResourcesRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import CONFIG from "./config";
import { setMcpClientInfoProvider } from "./client";

// Environment variables are now set directly by MCPB from user_config
// No need to process them here
Expand Down Expand Up @@ -1227,6 +1228,11 @@ export function createServer(): Server {
}
);

// Forward the MCP client identity (name + version) in outgoing API calls.
// Read lazily: `getClientVersion()` is populated during the `initialize`
// request, which always precedes any tool call that builds an API client.
setMcpClientInfoProvider(() => server.getClientVersion());

// Set up request handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
Expand Down
49 changes: 49 additions & 0 deletions src/utils/__tests__/userAgent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import config from "../../config";
import { buildUserAgent, formatClientInfo } from "../userAgent";

describe("formatClientInfo", () => {
it("returns null when no info is provided", () => {
expect(formatClientInfo(undefined)).toBeNull();
});

it("returns null when the name is empty or whitespace", () => {
expect(formatClientInfo({ name: "", version: "1.0.0" })).toBeNull();
expect(formatClientInfo({ name: " ", version: "1.0.0" })).toBeNull();
});

it("renders name/version", () => {
expect(formatClientInfo({ name: "Claude Desktop", version: "1.5.3" })).toBe(
"Claude Desktop/1.5.3"
);
});

it("renders just the name when no version is reported", () => {
expect(formatClientInfo({ name: "Cursor" })).toBe("Cursor");
expect(formatClientInfo({ name: "Cursor", version: "" })).toBe("Cursor");
});

it("strips parentheses and control characters that could break the header", () => {
expect(
formatClientInfo({ name: "Evil\n(client)", version: "1.0\r\n" })
).toBe("Evil client/1.0");
});

it("caps overly long tokens", () => {
const longName = "a".repeat(200);
const formatted = formatClientInfo({ name: longName, version: "1.0.0" });
expect(formatted).toBe(`${"a".repeat(64)}/1.0.0`);
});
});

describe("buildUserAgent", () => {
it("returns the base user-agent when no client info is present", () => {
expect(buildUserAgent(undefined)).toBe(config.USER_AGENT);
expect(buildUserAgent({ name: "" })).toBe(config.USER_AGENT);
});

it("appends the client identity when present", () => {
expect(buildUserAgent({ name: "Claude Desktop", version: "1.5.3" })).toBe(
`${config.USER_AGENT} (client: Claude Desktop/1.5.3)`
);
});
});
54 changes: 54 additions & 0 deletions src/utils/userAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import config from "../config";

/**
* MCP client identity reported during the `initialize` handshake. Structurally
* a subset of the MCP SDK's `Implementation` (name + version).
*/
export interface McpClientInfo {
name?: string;
version?: string;
}

/**
* The client name/version come from an external MCP client, so strip anything
* that could break the outgoing HTTP header or the User-Agent comment grammar:
* non-printable ASCII, parentheses (comment delimiters), and runaway length.
*/
function sanitizeToken(value: string, maxLength = 64): string {
return value
.replace(/[^\x20-\x7e]/g, " ")
.replace(/[()]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, maxLength);
}

/**
* Render an MCP client identity as `name/version` (or just `name` when no
* version is reported). Returns null when there is no usable name.
*/
export function formatClientInfo(
info: McpClientInfo | undefined
): string | null {
if (!info) {
return null;
}
const name = sanitizeToken(info.name ?? "");
if (!name) {
return null;
}
const version = sanitizeToken(info.version ?? "");
return version ? `${name}/${version}` : name;
}

/**
* Build the User-Agent for outgoing Mailtrap API calls, appending the MCP
* client identity when one was captured during the handshake, e.g.
* `mailtrap-mcp/0.6.0 (+https://github.com/mailtrap/mailtrap-mcp) (client: Claude Desktop/1.5.3)`.
*/
export function buildUserAgent(info: McpClientInfo | undefined): string {
const client = formatClientInfo(info);
return client
? `${config.USER_AGENT} (client: ${client})`
: config.USER_AGENT;
}
Loading