Skip to content

Commit 79b4883

Browse files
authored
Merge pull request #95 from mailtrap/add-missing-contact-import-export-tools
Add missing contact import export tools
2 parents b89bac4 + ddd9424 commit 79b4883

18 files changed

Lines changed: 681 additions & 0 deletions

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,12 @@ Schema files define a JSON Schema–shaped object for MCP; optional Zod schemas
155155
- **update-contact-field**: Update name, merge_tag, or data_type of an existing contact field.
156156
- **delete-contact-field**: Permanently delete a contact field by ID.
157157

158+
159+
#### Contact Imports & Exports
160+
161+
- **create-contact-import**: Bulk import contacts (array of `{ email, fields?, list_ids_included?, list_ids_excluded? }`). Returns an import job.
162+
- **get-contact-import**: Get the status of a contact import job (`created`/`started`/`finished`/`failed`) and counts.
163+
- **create-contact-export**: Export contacts matching AND-combined filters (`name`/`operator`/`value`). Returns an export job; poll for download URL.
164+
- **get-contact-export**: Get the status of a contact export job. `url` is populated when `status: finished`.
165+
158166
Tools use input schemas (JSON Schema format) for MCP; handlers may validate input with Zod. Response format follows the MCP protocol.

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,45 @@ Permanently delete a contact field definition by ID.
788788

789789
- `field_id` (required): ID of the contact field to delete
790790

791+
### create-contact-import
792+
793+
Bulk import contacts. Returns an import job record; poll its status with `get-contact-import`.
794+
795+
**Parameters:**
796+
797+
- `contacts` (required): Array of contact entries. Each entry needs:
798+
- `email` (required): Contact email address
799+
- `fields` (optional): Custom field values keyed by merge tag (string or number values)
800+
- `list_ids_included` (optional): List IDs to add the contact to
801+
- `list_ids_excluded` (optional): List IDs to remove the contact from
802+
803+
### get-contact-import
804+
805+
Get the status of a contact import job (created/started/finished/failed) with created/updated/over-limit counts.
806+
807+
**Parameters:**
808+
809+
- `import_id` (required): ID of the contact import job
810+
811+
### create-contact-export
812+
813+
Export contacts matching a set of AND-combined filters. Returns an export job record; poll status with `get-contact-export` to retrieve the download URL once `status` is `finished`.
814+
815+
**Parameters:**
816+
817+
- `filters` (required): Array of filter objects. Each has:
818+
- `name` (required): Field to filter on (`list_id`, `subscription_status`, `email`, etc.)
819+
- `operator` (required): One of `equal`, `not_equal`, `contains`, `not_contains`, `is_empty`, `is_not_empty`
820+
- `value` (required): Comparison value (string, number, boolean, or array)
821+
822+
### get-contact-export
823+
824+
Get the status of a contact export job. Once `status` is `finished`, the `url` field holds the CSV download link.
825+
826+
**Parameters:**
827+
828+
- `export_id` (required): ID of the contact export job
829+
791830
## Development
792831

793832
1. Clone the repository:

src/server.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ import {
152152
deleteContactField,
153153
deleteContactFieldSchema,
154154
} from "./tools/contactFields";
155+
import {
156+
createContactImport,
157+
createContactImportSchema,
158+
getContactImport,
159+
getContactImportSchema,
160+
} from "./tools/contactImports";
161+
import {
162+
createContactExport,
163+
createContactExportSchema,
164+
getContactExport,
165+
getContactExportSchema,
166+
} from "./tools/contactExports";
155167

156168
// Define the tools registry
157169
const tools = [
@@ -789,6 +801,46 @@ const tools = [
789801
destructiveHint: true,
790802
},
791803
},
804+
{
805+
name: "create-contact-import",
806+
description:
807+
"Bulk import contacts. Returns an import job record; poll status via `get-contact-import`.",
808+
inputSchema: createContactImportSchema,
809+
handler: createContactImport,
810+
annotations: {
811+
destructiveHint: true,
812+
},
813+
},
814+
{
815+
name: "get-contact-import",
816+
description:
817+
"Get the status of a contact import job, including created/updated/over-limit counts.",
818+
inputSchema: getContactImportSchema,
819+
handler: getContactImport,
820+
annotations: {
821+
readOnlyHint: true,
822+
},
823+
},
824+
{
825+
name: "create-contact-export",
826+
description:
827+
"Export contacts matching a set of AND-combined filters. Returns an export job; poll status with `get-contact-export` to retrieve the download URL.",
828+
inputSchema: createContactExportSchema,
829+
handler: createContactExport,
830+
annotations: {
831+
destructiveHint: false,
832+
},
833+
},
834+
{
835+
name: "get-contact-export",
836+
description:
837+
"Get the status of a contact export job. Once `status` is `finished`, the `url` field holds the download link.",
838+
inputSchema: getContactExportSchema,
839+
handler: getContactExport,
840+
annotations: {
841+
readOnlyHint: true,
842+
},
843+
},
792844
];
793845

794846
export function createServer(): Server {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import createContactExport from "../createContactExport";
2+
import { requireClient } from "../../../client";
3+
4+
const mockClient = {
5+
contactExports: {
6+
create: jest.fn(),
7+
},
8+
};
9+
10+
jest.mock("../../../client", () => ({
11+
requireClient: jest.fn(() => mockClient),
12+
}));
13+
14+
describe("createContactExport", () => {
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
(requireClient as jest.Mock).mockReturnValue(mockClient);
18+
});
19+
20+
it("submits the export and returns the response as JSON", async () => {
21+
mockClient.contactExports.create.mockResolvedValue({
22+
id: 42,
23+
status: "started",
24+
created_at: "2026-05-20T10:00:00Z",
25+
updated_at: "2026-05-20T10:00:00Z",
26+
url: null,
27+
});
28+
29+
const result = await createContactExport({
30+
filters: [{ name: "list_id", operator: "equal", value: 10 }],
31+
});
32+
33+
expect(requireClient).toHaveBeenCalledWith("contact exports");
34+
expect(mockClient.contactExports.create).toHaveBeenCalledWith({
35+
filters: [{ name: "list_id", operator: "equal", value: 10 }],
36+
});
37+
expect(result.content[0].text).toContain('"id": 42');
38+
expect(result.content[0].text).toContain('"status": "started"');
39+
expect(result.isError).toBeUndefined();
40+
});
41+
42+
it("submits filters without a value for is_empty / is_not_empty operators", async () => {
43+
mockClient.contactExports.create.mockResolvedValue({
44+
id: 43,
45+
status: "started",
46+
});
47+
48+
await createContactExport({
49+
filters: [{ name: "email", operator: "is_empty" }],
50+
});
51+
52+
expect(mockClient.contactExports.create).toHaveBeenCalledWith({
53+
filters: [{ name: "email", operator: "is_empty" }],
54+
});
55+
});
56+
57+
it("surfaces API errors", async () => {
58+
mockClient.contactExports.create.mockRejectedValue(
59+
new Error("invalid filter")
60+
);
61+
62+
const result = await createContactExport({
63+
filters: [{ name: "bad", operator: "equal", value: "x" }],
64+
});
65+
66+
expect(result.isError).toBe(true);
67+
expect(result.content[0].text).toBe(
68+
"Failed to create contact export: invalid filter"
69+
);
70+
});
71+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import getContactExport from "../getContactExport";
2+
import { requireClient } from "../../../client";
3+
4+
const mockClient = {
5+
contactExports: {
6+
get: jest.fn(),
7+
},
8+
};
9+
10+
jest.mock("../../../client", () => ({
11+
requireClient: jest.fn(() => mockClient),
12+
}));
13+
14+
describe("getContactExport", () => {
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
(requireClient as jest.Mock).mockReturnValue(mockClient);
18+
});
19+
20+
it("returns the export job as JSON", async () => {
21+
mockClient.contactExports.get.mockResolvedValue({
22+
id: 42,
23+
status: "finished",
24+
created_at: "2026-05-20T10:00:00Z",
25+
updated_at: "2026-05-20T10:01:30Z",
26+
url: "https://example.com/exports/42.csv",
27+
});
28+
29+
const result = await getContactExport({ export_id: 42 });
30+
31+
expect(requireClient).toHaveBeenCalledWith("contact exports");
32+
expect(mockClient.contactExports.get).toHaveBeenCalledWith(42);
33+
expect(result.content[0].text).toContain('"id": 42');
34+
expect(result.content[0].text).toContain('"status": "finished"');
35+
expect(result.content[0].text).toContain(
36+
'"url": "https://example.com/exports/42.csv"'
37+
);
38+
expect(result.isError).toBeUndefined();
39+
});
40+
41+
it("handles a started export with null url", async () => {
42+
mockClient.contactExports.get.mockResolvedValue({
43+
id: 42,
44+
status: "started",
45+
created_at: "2026-05-20T10:00:00Z",
46+
updated_at: "2026-05-20T10:00:00Z",
47+
url: null,
48+
});
49+
50+
const result = await getContactExport({ export_id: 42 });
51+
52+
expect(result.content[0].text).toContain('"url": null');
53+
});
54+
55+
it("surfaces API errors", async () => {
56+
mockClient.contactExports.get.mockRejectedValue(new Error("not found"));
57+
58+
const result = await getContactExport({ export_id: 99 });
59+
60+
expect(result.isError).toBe(true);
61+
expect(result.content[0].text).toBe(
62+
"Failed to get contact export: not found"
63+
);
64+
});
65+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { requireClient } from "../../client";
2+
import {
3+
ContactExport,
4+
CreateContactExportRequest,
5+
} from "../../types/mailtrap";
6+
import {
7+
buildErrorResponse,
8+
buildSuccessResponse,
9+
ToolResponse,
10+
} from "../utils/responses";
11+
12+
async function createContactExport(
13+
params: CreateContactExportRequest
14+
): Promise<ToolResponse> {
15+
try {
16+
const mailtrap = requireClient("contact exports");
17+
18+
const response = (await mailtrap.contactExports.create(
19+
params as Parameters<typeof mailtrap.contactExports.create>[0]
20+
)) as ContactExport;
21+
22+
return buildSuccessResponse(JSON.stringify(response, null, 2));
23+
} catch (error) {
24+
return buildErrorResponse("create contact export", error);
25+
}
26+
}
27+
28+
export default createContactExport;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { requireClient } from "../../client";
2+
import { ContactExport, GetContactExportRequest } from "../../types/mailtrap";
3+
import {
4+
buildErrorResponse,
5+
buildSuccessResponse,
6+
ToolResponse,
7+
} from "../utils/responses";
8+
9+
async function getContactExport({
10+
export_id,
11+
}: GetContactExportRequest): Promise<ToolResponse> {
12+
try {
13+
const mailtrap = requireClient("contact exports");
14+
15+
const response = (await mailtrap.contactExports.get(
16+
export_id
17+
)) as ContactExport;
18+
19+
return buildSuccessResponse(JSON.stringify(response, null, 2));
20+
} catch (error) {
21+
return buildErrorResponse("get contact export", error);
22+
}
23+
}
24+
25+
export default getContactExport;

src/tools/contactExports/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import createContactExportSchema from "./schemas/createContactExport";
2+
import createContactExport from "./createContactExport";
3+
import getContactExportSchema from "./schemas/getContactExport";
4+
import getContactExport from "./getContactExport";
5+
6+
export {
7+
createContactExportSchema,
8+
createContactExport,
9+
getContactExportSchema,
10+
getContactExport,
11+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const createContactExportSchema = {
2+
type: "object",
3+
properties: {
4+
filters: {
5+
type: "array",
6+
description:
7+
"Filters that select which contacts to include in the export. AND-combined.",
8+
items: {
9+
type: "object",
10+
properties: {
11+
name: {
12+
type: "string",
13+
description:
14+
"Field to filter on (e.g. `list_id`, `subscription_status`, `email`).",
15+
},
16+
operator: {
17+
type: "string",
18+
enum: [
19+
"equal",
20+
"not_equal",
21+
"contains",
22+
"not_contains",
23+
"is_empty",
24+
"is_not_empty",
25+
],
26+
description: "Comparison operator.",
27+
},
28+
value: {
29+
description:
30+
"Comparison value. Type depends on the field — string, number, boolean, or array.",
31+
},
32+
},
33+
required: ["name", "operator"],
34+
allOf: [
35+
{
36+
if: {
37+
properties: {
38+
operator: { enum: ["is_empty", "is_not_empty"] },
39+
},
40+
required: ["operator"],
41+
},
42+
then: {
43+
not: { required: ["value"] },
44+
},
45+
else: {
46+
required: ["value"],
47+
},
48+
},
49+
],
50+
additionalProperties: false,
51+
},
52+
},
53+
},
54+
required: ["filters"],
55+
additionalProperties: false,
56+
};
57+
58+
export default createContactExportSchema;

0 commit comments

Comments
 (0)