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
3 changes: 3 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com";
export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";

export const SUPABASE_STORAGE_BUCKET = "user-files";

/** Default from address for outbound emails sent by the agent */
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;
29 changes: 29 additions & 0 deletions lib/emails/sendEmailSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { z } from "zod";

export const sendEmailSchema = z.object({
to: z.array(z.string()).describe("Recipient email address or array of addresses"),
cc: z
.array(z.string())
.describe(
"Optional array of CC email addresses. active_account_email should always be included unless already in 'to'.",
)
.default([])
.optional(),
subject: z.string().describe("Email subject line"),
text: z
.string()
.describe("Plain text body of the email. Use context to make this creative and engaging.")
.optional(),
html: z
.string()
.describe("HTML body of the email. Use context to make this creative and engaging.")
.default("")
.optional(),
headers: z
.record(z.string(), z.string())
.describe("Optional custom headers for the email")
.default({})
.optional(),
});

export type SendEmailInput = z.infer<typeof sendEmailSchema>;
101 changes: 101 additions & 0 deletions lib/mcp/tools/__tests__/registerSendEmailTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerSendEmailTool } from "../registerSendEmailTool";
import { NextResponse } from "next/server";

const mockSendEmailWithResend = vi.fn();

vi.mock("@/lib/emails/sendEmail", () => ({
sendEmailWithResend: (...args: unknown[]) => mockSendEmailWithResend(...args),
}));

describe("registerSendEmailTool", () => {
let mockServer: McpServer;
let registeredHandler: (args: unknown) => Promise<unknown>;

beforeEach(() => {
vi.clearAllMocks();

mockServer = {
registerTool: vi.fn((name, config, handler) => {
registeredHandler = handler;
}),
} as unknown as McpServer;

registerSendEmailTool(mockServer);
});

it("registers the send_email tool", () => {
expect(mockServer.registerTool).toHaveBeenCalledWith(
"send_email",
expect.objectContaining({
description: expect.stringContaining("Send an email using the Resend API"),
}),
expect.any(Function),
);
});

it("returns success when email is sent successfully", async () => {
mockSendEmailWithResend.mockResolvedValue({ id: "email-123" });

const result = await registeredHandler({
to: ["[email protected]"],
subject: "Test Subject",
text: "Test body",
});

expect(mockSendEmailWithResend).toHaveBeenCalledWith({
from: "Agent by Recoup <[email protected]>",
to: ["[email protected]"],
cc: undefined,
subject: "Test Subject",
text: "Test body",
html: undefined,
headers: {},
});

expect(result).toEqual({
content: [
{
type: "text",
text: expect.stringContaining("Email sent successfully"),
},
],
});
});

it("includes CC addresses when provided", async () => {
mockSendEmailWithResend.mockResolvedValue({ id: "email-123" });

await registeredHandler({
to: ["[email protected]"],
cc: ["[email protected]"],
subject: "Test Subject",
});

expect(mockSendEmailWithResend).toHaveBeenCalledWith(
expect.objectContaining({
cc: ["[email protected]"],
}),
);
});

it("returns error when sendEmailWithResend returns NextResponse", async () => {
const errorResponse = NextResponse.json({ error: { message: "Rate limited" } }, { status: 429 });
mockSendEmailWithResend.mockResolvedValue(errorResponse);

const result = await registeredHandler({
to: ["[email protected]"],
subject: "Test Subject",
});

expect(result).toEqual({
content: [
{
type: "text",
text: expect.stringContaining("Rate limited"),
},
],
});
});
});
2 changes: 2 additions & 0 deletions lib/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { registerAllFileTools } from "./files";
import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool";
import { registerAllYouTubeTools } from "./youtube";
import { registerTranscribeTools } from "./transcribe";
import { registerSendEmailTool } from "./registerSendEmailTool";

/**
* Registers all MCP tools on the server.
Expand All @@ -35,4 +36,5 @@ export const registerAllTools = (server: McpServer): void => {
registerUpdateAccountInfoTool(server);
registerCreateSegmentsTool(server);
registerAllYouTubeTools(server);
registerSendEmailTool(server);
};
49 changes: 49 additions & 0 deletions lib/mcp/tools/registerSendEmailTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { sendEmailSchema, type SendEmailInput } from "@/lib/emails/sendEmailSchema";
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess";
import { getToolResultError } from "@/lib/mcp/getToolResultError";
import { RECOUP_FROM_EMAIL } from "@/lib/const";
import { NextResponse } from "next/server";

/**
* Registers the "send_email" tool on the MCP server.
* Send an email using the Resend API.
*
* @param server - The MCP server instance to register the tool on.
*/
export function registerSendEmailTool(server: McpServer): void {
server.registerTool(
"send_email",
{
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.`,
inputSchema: sendEmailSchema,
},
async (args: SendEmailInput) => {
const { to, cc = [], subject, text, html = "", headers = {} } = args;

const result = await sendEmailWithResend({
from: RECOUP_FROM_EMAIL,
to,
cc: cc.length > 0 ? cc : undefined,
subject,
text,
html: html || undefined,
headers,
});

if (result instanceof NextResponse) {
const data = await result.json();
return getToolResultError(
data?.error?.message || `Failed to send email from ${RECOUP_FROM_EMAIL} to ${to}.`,
);
}

return getToolResultSuccess({
success: true,
message: `Email sent successfully from ${RECOUP_FROM_EMAIL} to ${to}. CC: ${cc.length > 0 ? JSON.stringify(cc) : "none"}.`,
data: result,
});
},
);
}