diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 96888f2..0b663a8 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -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 }; diff --git a/src/client.ts b/src/client.ts index bebdae1..31fb5a2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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. @@ -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 && @@ -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)) @@ -81,7 +105,7 @@ function getOrganizationClient(): MailtrapClient { } return new MailtrapClient({ token, - userAgent: config.USER_AGENT, + userAgent: getUserAgent(), organizationId: parsedOrganizationId, }); } @@ -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"); } @@ -115,9 +140,8 @@ function requireClient( return client; } -// eslint-disable-next-line import/prefer-default-export export { - client, + setMcpClientInfoProvider, getSandboxClient, getBulkClient, getOrganizationClient, diff --git a/src/config/index.ts b/src/config/index.ts index 88f2321..1371e0d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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)`, }; diff --git a/src/server.ts b/src/server.ts index e35df74..9561b2a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 @@ -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 { diff --git a/src/utils/__tests__/userAgent.test.ts b/src/utils/__tests__/userAgent.test.ts new file mode 100644 index 0000000..1542200 --- /dev/null +++ b/src/utils/__tests__/userAgent.test.ts @@ -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)` + ); + }); +}); diff --git a/src/utils/userAgent.ts b/src/utils/userAgent.ts new file mode 100644 index 0000000..63092ce --- /dev/null +++ b/src/utils/userAgent.ts @@ -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; +}