Skip to content

Commit 88a19a4

Browse files
authored
Merge pull request #94 from Recoupable-com/sweetmantech/myc-3868-chat-migrate-sendemailtool-to-the-mcp-server-in-the-api
feat: add send_email MCP tool
2 parents 058c714 + 77d163f commit 88a19a4

File tree

5 files changed

+184
-0
lines changed

5 files changed

+184
-0
lines changed

lib/const.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com";
1919
export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";
2020

2121
export const SUPABASE_STORAGE_BUCKET = "user-files";
22+
23+
/** Default from address for outbound emails sent by the agent */
24+
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;

lib/emails/sendEmailSchema.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { z } from "zod";
2+
3+
export const sendEmailSchema = z.object({
4+
to: z.array(z.string()).describe("Recipient email address or array of addresses"),
5+
cc: z
6+
.array(z.string())
7+
.describe(
8+
"Optional array of CC email addresses. active_account_email should always be included unless already in 'to'.",
9+
)
10+
.default([])
11+
.optional(),
12+
subject: z.string().describe("Email subject line"),
13+
text: z
14+
.string()
15+
.describe("Plain text body of the email. Use context to make this creative and engaging.")
16+
.optional(),
17+
html: z
18+
.string()
19+
.describe("HTML body of the email. Use context to make this creative and engaging.")
20+
.default("")
21+
.optional(),
22+
headers: z
23+
.record(z.string(), z.string())
24+
.describe("Optional custom headers for the email")
25+
.default({})
26+
.optional(),
27+
});
28+
29+
export type SendEmailInput = z.infer<typeof sendEmailSchema>;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import { registerSendEmailTool } from "../registerSendEmailTool";
4+
import { NextResponse } from "next/server";
5+
6+
const mockSendEmailWithResend = vi.fn();
7+
8+
vi.mock("@/lib/emails/sendEmail", () => ({
9+
sendEmailWithResend: (...args: unknown[]) => mockSendEmailWithResend(...args),
10+
}));
11+
12+
describe("registerSendEmailTool", () => {
13+
let mockServer: McpServer;
14+
let registeredHandler: (args: unknown) => Promise<unknown>;
15+
16+
beforeEach(() => {
17+
vi.clearAllMocks();
18+
19+
mockServer = {
20+
registerTool: vi.fn((name, config, handler) => {
21+
registeredHandler = handler;
22+
}),
23+
} as unknown as McpServer;
24+
25+
registerSendEmailTool(mockServer);
26+
});
27+
28+
it("registers the send_email tool", () => {
29+
expect(mockServer.registerTool).toHaveBeenCalledWith(
30+
"send_email",
31+
expect.objectContaining({
32+
description: expect.stringContaining("Send an email using the Resend API"),
33+
}),
34+
expect.any(Function),
35+
);
36+
});
37+
38+
it("returns success when email is sent successfully", async () => {
39+
mockSendEmailWithResend.mockResolvedValue({ id: "email-123" });
40+
41+
const result = await registeredHandler({
42+
to: ["test@example.com"],
43+
subject: "Test Subject",
44+
text: "Test body",
45+
});
46+
47+
expect(mockSendEmailWithResend).toHaveBeenCalledWith({
48+
from: "Agent by Recoup <agent@recoupable.com>",
49+
to: ["test@example.com"],
50+
cc: undefined,
51+
subject: "Test Subject",
52+
text: "Test body",
53+
html: undefined,
54+
headers: {},
55+
});
56+
57+
expect(result).toEqual({
58+
content: [
59+
{
60+
type: "text",
61+
text: expect.stringContaining("Email sent successfully"),
62+
},
63+
],
64+
});
65+
});
66+
67+
it("includes CC addresses when provided", async () => {
68+
mockSendEmailWithResend.mockResolvedValue({ id: "email-123" });
69+
70+
await registeredHandler({
71+
to: ["test@example.com"],
72+
cc: ["cc@example.com"],
73+
subject: "Test Subject",
74+
});
75+
76+
expect(mockSendEmailWithResend).toHaveBeenCalledWith(
77+
expect.objectContaining({
78+
cc: ["cc@example.com"],
79+
}),
80+
);
81+
});
82+
83+
it("returns error when sendEmailWithResend returns NextResponse", async () => {
84+
const errorResponse = NextResponse.json({ error: { message: "Rate limited" } }, { status: 429 });
85+
mockSendEmailWithResend.mockResolvedValue(errorResponse);
86+
87+
const result = await registeredHandler({
88+
to: ["test@example.com"],
89+
subject: "Test Subject",
90+
});
91+
92+
expect(result).toEqual({
93+
content: [
94+
{
95+
type: "text",
96+
text: expect.stringContaining("Rate limited"),
97+
},
98+
],
99+
});
100+
});
101+
});

lib/mcp/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { registerAllFileTools } from "./files";
1313
import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool";
1414
import { registerAllYouTubeTools } from "./youtube";
1515
import { registerTranscribeTools } from "./transcribe";
16+
import { registerSendEmailTool } from "./registerSendEmailTool";
1617

1718
/**
1819
* Registers all MCP tools on the server.
@@ -35,4 +36,5 @@ export const registerAllTools = (server: McpServer): void => {
3536
registerUpdateAccountInfoTool(server);
3637
registerCreateSegmentsTool(server);
3738
registerAllYouTubeTools(server);
39+
registerSendEmailTool(server);
3840
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { sendEmailSchema, type SendEmailInput } from "@/lib/emails/sendEmailSchema";
3+
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
4+
import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess";
5+
import { getToolResultError } from "@/lib/mcp/getToolResultError";
6+
import { RECOUP_FROM_EMAIL } from "@/lib/const";
7+
import { NextResponse } from "next/server";
8+
9+
/**
10+
* Registers the "send_email" tool on the MCP server.
11+
* Send an email using the Resend API.
12+
*
13+
* @param server - The MCP server instance to register the tool on.
14+
*/
15+
export function registerSendEmailTool(server: McpServer): void {
16+
server.registerTool(
17+
"send_email",
18+
{
19+
description: `Send an email using the Resend API. Requires 'to' and 'subject'. Optionally include 'text', 'html', and custom headers.\n\nNotes:\n- Emails are sent from ${RECOUP_FROM_EMAIL}.\n- Use context to make the email creative and engaging.\n- Use this tool to send transactional or notification emails to users or admins.`,
20+
inputSchema: sendEmailSchema,
21+
},
22+
async (args: SendEmailInput) => {
23+
const { to, cc = [], subject, text, html = "", headers = {} } = args;
24+
25+
const result = await sendEmailWithResend({
26+
from: RECOUP_FROM_EMAIL,
27+
to,
28+
cc: cc.length > 0 ? cc : undefined,
29+
subject,
30+
text,
31+
html: html || undefined,
32+
headers,
33+
});
34+
35+
if (result instanceof NextResponse) {
36+
const data = await result.json();
37+
return getToolResultError(
38+
data?.error?.message || `Failed to send email from ${RECOUP_FROM_EMAIL} to ${to}.`,
39+
);
40+
}
41+
42+
return getToolResultSuccess({
43+
success: true,
44+
message: `Email sent successfully from ${RECOUP_FROM_EMAIL} to ${to}. CC: ${cc.length > 0 ? JSON.stringify(cc) : "none"}.`,
45+
data: result,
46+
});
47+
},
48+
);
49+
}

0 commit comments

Comments
 (0)