diff --git a/.env.example b/.env.example index 76cdb55b5eb..b5e4469ad73 100644 --- a/.env.example +++ b/.env.example @@ -219,28 +219,28 @@ API_KEY_PREFIX=cal_ # Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to # allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/ # Configures the global From: header whilst sending emails. -EMAIL_FROM='notifications@yourselfhostedcal.com' -EMAIL_FROM_NAME='Cal.diy' +EMAIL_FROM='notifications@crove.com' +EMAIL_FROM_NAME='Crove Cal' # Configure SMTP settings (@see https://nodemailer.com/smtp/). -# Configuration to receive emails locally (mailhog) +# Standard SMTP configuration works for Amazon SES, Brevo, SendGrid, Mailgun, etc. +# ------------------------------------------------------------- +# Option A: Amazon SES (Simple Email Service) +# EMAIL_SERVER_HOST='email-smtp.ap-southeast-1.amazonaws.com' +# EMAIL_SERVER_PORT=587 +# EMAIL_SERVER_USER='' +# EMAIL_SERVER_PASSWORD='' +# ------------------------------------------------------------- +# Option B: Brevo (formerly Sendinblue) +# EMAIL_SERVER_HOST='smtp-relay.brevo.com' +# EMAIL_SERVER_PORT=587 +# EMAIL_SERVER_USER='' +# EMAIL_SERVER_PASSWORD='' +# ------------------------------------------------------------- EMAIL_SERVER_HOST='localhost' EMAIL_SERVER_PORT=1025 - -# Note: The below configuration for Office 365 has been verified to work. -# EMAIL_SERVER_HOST='smtp.office365.com' -# EMAIL_SERVER_PORT=587 -# EMAIL_SERVER_USER='' -# Keep in mind that if you have 2FA enabled, you will need to provision an App Password. -# EMAIL_SERVER_PASSWORD='' - -# The following configuration for Gmail has been verified to work. -# EMAIL_SERVER_HOST='smtp.gmail.com' -# EMAIL_SERVER_PORT=465 -# EMAIL_SERVER_USER='' -## You will need to provision an App Password. -## @see https://support.google.com/accounts/answer/185833 -# EMAIL_SERVER_PASSWORD='' +EMAIL_SERVER_USER= +EMAIL_SERVER_PASSWORD= # queue or cancel payment reminder email/flow AWAITING_PAYMENT_EMAIL_DELAY_MINUTES= diff --git a/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts b/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts new file mode 100644 index 00000000000..a0625704724 --- /dev/null +++ b/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts @@ -0,0 +1,303 @@ +import { createHmac } from "node:crypto"; +import { describe, test, expect, vi, beforeEach } from "vitest"; + +const mockPrisma = { + team: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + user: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + membership: { + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + profile: { + upsert: vi.fn(), + }, +}; + +vi.mock("@calcom/prisma", () => ({ + default: mockPrisma, + prisma: mockPrisma, +})); + +vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({ + ProfileRepository: { + generateProfileUid: vi.fn().mockReturnValue("mock-profile-uid-123"), + }, +})); + +vi.mock("next/server", () => { + class MockNextResponse { + body: unknown; + status: number; + headers: Record; + + constructor(body: unknown, init?: { status?: number; headers?: Record }) { + this.body = body; + this.status = init?.status ?? 200; + this.headers = init?.headers ?? {}; + } + + static json(data: unknown, init?: { status?: number; headers?: Record }) { + return { + status: init?.status ?? 200, + headers: init?.headers ?? {}, + json: async () => data, + }; + } + } + + return { + NextResponse: MockNextResponse, + }; +}); + +function createMockRequest(body: string, headers: Record = {}) { + const headerMap = new Map(); + for (const [key, value] of Object.entries(headers)) { + headerMap.set(key.toLowerCase(), value); + } + + return { + text: async () => body, + headers: { + get: (name: string) => headerMap.get(name.toLowerCase()) || null, + }, + } as unknown as import("next/server").NextRequest; +} + +function generateSignature(body: string, secret: string): string { + return createHmac("sha256", secret).update(body, "utf8").digest("hex"); +} + +describe("/api/webhooks/dos-org-sync", () => { + const SECRET = "test-webhook-secret-123456"; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.DOS_SYNC_WEBHOOK_SECRET = SECRET; + }); + + test("OPTIONS should return 204 with CORS headers", async () => { + const { OPTIONS } = await import("../route"); + const response = await OPTIONS(); + expect(response.status).toBe(204); + expect(response.headers["Access-Control-Allow-Origin"]).toBe("*"); + expect(response.headers["Access-Control-Allow-Methods"]).toContain("POST"); + }); + + test("POST should return 500 when webhook secret is missing", async () => { + delete process.env.DOS_SYNC_WEBHOOK_SECRET; + delete process.env.OIDC_CLIENT_SECRET; + + const { POST } = await import("../route"); + const body = JSON.stringify({ event: "test.ping" }); + const req = createMockRequest(body, { "x-dos-signature": "dummy" }); + + const res = await POST(req); + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBe("Webhook secret is not configured"); + }); + + test("POST should return 401 when signature is missing or invalid", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify({ event: "test.ping" }); + const req = createMockRequest(body, { "x-dos-signature": "invalid-sig" }); + + const res = await POST(req); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBe("Invalid or missing signature"); + }); + + test("POST should return 200 pong for test.ping event", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify({ event: "test.ping", timestamp: new Date().toISOString() }); + const sig = generateSignature(body, SECRET); + const req = createMockRequest(body, { "x-dos-signature": `sha256=${sig}` }); + + const res = await POST(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.message).toBe("pong"); + }); + + test("POST should return 400 when org_id is missing for business events", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify({ + event: "organization.created", + timestamp: new Date().toISOString(), + data: {}, + }); + const sig = generateSignature(body, SECRET); + const req = createMockRequest(body, { "x-dos-signature": sig }); + + const res = await POST(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("Missing org_id in payload"); + }); + + test("POST organization.created should create a new Team organization", async () => { + const { POST } = await import("../route"); + mockPrisma.team.findFirst.mockResolvedValue(null); + mockPrisma.team.create.mockResolvedValue({ id: 100, name: "Acme Corp", metadata: { dosOrgId: "org-1" } }); + + const body = JSON.stringify({ + event: "organization.created", + timestamp: new Date().toISOString(), + data: { + org_id: "org-1", + org_name: "Acme Corp", + org_slug: "acme-corp", + }, + }); + const sig = generateSignature(body, SECRET); + const req = createMockRequest(body, { "x-dos-signature": sig }); + + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockPrisma.team.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "Acme Corp", + isOrganization: true, + metadata: { dosOrgId: "org-1" }, + }), + }) + ); + }); + + test("POST organization.updated should update existing team", async () => { + const { POST } = await import("../route"); + mockPrisma.team.findFirst.mockResolvedValue({ id: 100, metadata: { dosOrgId: "org-1" } }); + mockPrisma.team.update.mockResolvedValue({ id: 100, name: "Acme Corp Updated" }); + + const body = JSON.stringify({ + event: "org.updated", + timestamp: new Date().toISOString(), + data: { + org_id: "org-1", + org_name: "Acme Corp Updated", + }, + }); + const sig = generateSignature(body, SECRET); + const req = createMockRequest(body, { "x-dos-signature": sig }); + + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockPrisma.team.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 100 }, + data: expect.objectContaining({ + name: "Acme Corp Updated", + }), + }) + ); + }); + + test("POST organization.member_added should upsert user, membership and profile", async () => { + const { POST } = await import("../route"); + mockPrisma.team.findFirst.mockResolvedValue({ id: 100 }); + mockPrisma.user.findFirst.mockResolvedValue({ id: 200, email: "member@acme.com", username: "member" }); + mockPrisma.membership.upsert.mockResolvedValue({}); + mockPrisma.profile.upsert.mockResolvedValue({}); + + const body = JSON.stringify({ + event: "organization.member_added", + timestamp: new Date().toISOString(), + data: { + org_id: "org-1", + org_name: "Acme Corp", + user_email: "member@acme.com", + user_name: "Member Name", + role: "ADMIN", + }, + }); + const sig = generateSignature(body, SECRET); + const req = createMockRequest(body, { "x-dos-signature": sig }); + + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockPrisma.membership.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_teamId: { + userId: 200, + teamId: 100, + }, + }, + }) + ); + expect(mockPrisma.profile.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_organizationId: { + userId: 200, + organizationId: 100, + }, + }, + }) + ); + }); + + test("POST organization.member_removed should delete user membership", async () => { + const { POST } = await import("../route"); + mockPrisma.team.findFirst.mockResolvedValue({ id: 100 }); + mockPrisma.user.findFirst.mockResolvedValue({ id: 200, email: "member@acme.com" }); + mockPrisma.membership.deleteMany.mockResolvedValue({ count: 1 }); + + const body = JSON.stringify({ + event: "org.member_removed", + timestamp: new Date().toISOString(), + data: { + org_id: "org-1", + org_name: "Acme Corp", + user_email: "member@acme.com", + }, + }); + const sig = generateSignature(body, SECRET); + const req = createMockRequest(body, { "x-dos-signature": sig }); + + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockPrisma.membership.deleteMany).toHaveBeenCalledWith({ + where: { + userId: 200, + teamId: 100, + }, + }); + }); + + test("POST organization.deleted should delete the Team", async () => { + const { POST } = await import("../route"); + mockPrisma.team.findFirst.mockResolvedValue({ id: 100 }); + mockPrisma.team.delete.mockResolvedValue({ id: 100 }); + + const body = JSON.stringify({ + event: "organization.deleted", + timestamp: new Date().toISOString(), + data: { + org_id: "org-1", + org_name: "Acme Corp", + }, + }); + const sig = generateSignature(body, SECRET); + const req = createMockRequest(body, { "x-dos-signature": sig }); + + const res = await POST(req); + expect(res.status).toBe(200); + expect(mockPrisma.team.delete).toHaveBeenCalledWith({ + where: { id: 100 }, + }); + }); +}); diff --git a/docs/Architecture.md b/docs/Architecture.md index b0eeb687bfc..7d38bb4fc14 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -79,12 +79,17 @@ Khi AI Agent (như Crove Desk AI, DOSClaw, DOS AI) cần thực hiện các tác | MCP Tool Name | Mục đích | |---|---| -| `crove_cal.get_available_slots` | Tra cứu các khung giờ rảnh khả dụng của người dùng hoặc nhóm | -| `crove_cal.create_booking` | Tự động đặt lịch hẹn mới từ hội thoại hoặc ticket hỗ trợ | -| `crove_cal.reschedule_booking` | Đổi lịch hẹn theo yêu cầu của khách hàng | -| `crove_cal.cancel_booking` | Hủy lịch hẹn và giải phóng slot | -| `twenty_crm.*` | Tương tác dữ liệu CRM (khách hàng, cơ hội, nhiệm vụ) | -| `crove_sign.*` | Tra cứu và gửi hợp đồng điện tử | +| `crove_cal_list_event_types` | Danh sách các loại lịch hẹn khả dụng của người dùng hoặc tổ chức | +| `crove_cal_get_event_type` | Xem chi tiết cấu hình và câu hỏi đặt lịch của một event type | +| `crove_cal_get_available_slots` | Tra cứu các khung giờ rảnh khả dụng của người dùng hoặc nhóm | +| `crove_cal_create_booking` | Tự động đặt lịch hẹn mới từ hội thoại hoặc ticket hỗ trợ | +| `crove_cal_get_booking` | Lấy chi tiết lịch hẹn theo UID hoặc Booking ID | +| `crove_cal_reschedule_booking` | Đổi lịch hẹn sang thời gian mới theo yêu cầu khách hàng | +| `crove_cal_cancel_booking` | Hủy lịch hẹn và giải phóng slot | +| `crove_cal_list_bookings` | Tra cứu danh sách các lịch hẹn theo email người tham gia / trạng thái | + +Package mã nguồn MCP Server: `packages/mcp-server` +Khởi chạy stdio: `yarn mcp:server` --- @@ -104,7 +109,33 @@ Crove Cal kết nối trực tiếp với OIDC Server chuẩn của DOS ID: --- -## IV. Quy chuẩn Nhận diện Thương hiệu & Fork Maintenance +## IV. Cấu hình Email Giao dịch (Amazon SES & Brevo via SMTP) + +Crove Cal sử dụng cấu hình SMTP tiêu chuẩn của Node.js (`nodemailer`), hỗ trợ trực tiếp mọi nhà cung cấp gửi email giao dịch (Amazon SES, Brevo) mà không cần phụ thuộc vào API độc quyền của SendGrid hay Resend: + +### 1. Cấu hình Amazon SES (Simple Email Service) +```env +EMAIL_FROM="notifications@crove.com" +EMAIL_FROM_NAME="Crove Cal" +EMAIL_SERVER_HOST="email-smtp.ap-southeast-1.amazonaws.com" # Thay bằng region AWS SES của bạn +EMAIL_SERVER_PORT=587 +EMAIL_SERVER_USER="" +EMAIL_SERVER_PASSWORD="" +``` + +### 2. Cấu hình Brevo (Sendinblue) +```env +EMAIL_FROM="notifications@crove.com" +EMAIL_FROM_NAME="Crove Cal" +EMAIL_SERVER_HOST="smtp-relay.brevo.com" +EMAIL_SERVER_PORT=587 +EMAIL_SERVER_USER="" +EMAIL_SERVER_PASSWORD="" +``` + +--- + +## V. Quy chuẩn Nhận diện Thương hiệu & Fork Maintenance Để đảm bảo khả năng merge và đồng bộ mượt mà với phiên bản gốc (`upstream/main` của Cal.com): diff --git a/package.json b/package.json index 5ee8d3a9239..e0e6d82346e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "example-apps/*" ], "scripts": { + "mcp:server": "yarn workspace @calcom/mcp-server start", "patch:branding": "ts-node --transpile-only scripts/patch-crove-branding.ts", "app-store-cli": "yarn workspace @calcom/app-store-cli", "app-store:build": "yarn turbo build --filter=@calcom/app-store-cli", diff --git a/packages/mcp-server/bin/crove-cal-mcp.ts b/packages/mcp-server/bin/crove-cal-mcp.ts new file mode 100644 index 00000000000..49fda0fd890 --- /dev/null +++ b/packages/mcp-server/bin/crove-cal-mcp.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { startStdioServer } from "../src/index"; + +startStdioServer().catch((error) => { + console.error("[crove-cal-mcp] Server error:", error); + process.exit(1); +}); diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 00000000000..dafb3dc86cc --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,28 @@ +{ + "name": "@calcom/mcp-server", + "version": "1.0.0", + "private": true, + "description": "Model Context Protocol (MCP) Server for Crove Cal & Crove OS Agents", + "main": "src/index.ts", + "bin": { + "crove-cal-mcp": "./bin/crove-cal-mcp.ts" + }, + "scripts": { + "start": "ts-node --transpile-only src/index.ts", + "lint": "biome lint .", + "lint:fix": "biome lint --write ." + }, + "dependencies": { + "@calcom/prisma": "workspace:*", + "@modelcontextprotocol/sdk": "1.26.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@biomejs/biome": "2.3.10", + "@calcom/tsconfig": "workspace:*", + "@types/node": "20.17.24", + "ts-node": "^10.9.2", + "typescript": "5.9.3", + "vitest": "^4.1.8" + } +} diff --git a/packages/mcp-server/src/__tests__/mcp-server.test.ts b/packages/mcp-server/src/__tests__/mcp-server.test.ts new file mode 100644 index 00000000000..1fa9dae9bbc --- /dev/null +++ b/packages/mcp-server/src/__tests__/mcp-server.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + cancelBookingHandler, + createBookingHandler, + getBookingHandler, + listBookingsHandler, + rescheduleBookingHandler, +} from "../tools/bookings"; +import { getEventTypeDetailsHandler, listEventTypesHandler } from "../tools/eventTypes"; +import { getAvailableSlotsHandler } from "../tools/slots"; +import { createCroveCalMcpServer } from "../server"; + +const mockPrisma = { + eventType: { + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + booking: { + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, +}; + +describe("Crove Cal MCP Tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Event Types", () => { + test("listEventTypesHandler should query event types for user", async () => { + mockPrisma.eventType.findMany.mockResolvedValue([ + { id: 1, title: "Quick 15", slug: "15min", length: 15 }, + { id: 2, title: "Deep Dive 45", slug: "45min", length: 45 }, + ]); + + const result = await listEventTypesHandler(mockPrisma as any, { username: "joy" }); + expect(result).toHaveLength(2); + expect(mockPrisma.eventType.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + hidden: false, + users: { some: { username: "joy" } }, + }), + }) + ); + }); + + test("getEventTypeDetailsHandler should return details by ID", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ + id: 1, + title: "Quick 15", + slug: "15min", + length: 15, + }); + + const result = await getEventTypeDetailsHandler(mockPrisma as any, { eventTypeId: 1 }); + expect(result.id).toBe(1); + expect(result.slug).toBe("15min"); + }); + }); + + describe("Available Slots", () => { + test("getAvailableSlotsHandler should calculate free slots excluding booked intervals", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ + id: 1, + length: 30, + timeZone: "UTC", + userId: 10, + owner: { id: 10, email: "host@crove.com" }, + }); + + // 1 existing booking at 2026-09-01T10:00:00.000Z to 10:30:00.000Z + mockPrisma.booking.findMany.mockResolvedValue([ + { + startTime: new Date("2026-09-01T10:00:00.000Z"), + endTime: new Date("2026-09-01T10:30:00.000Z"), + }, + ]); + + const result = await getAvailableSlotsHandler(mockPrisma as any, { + eventTypeId: 1, + dateFrom: "2026-09-01", + dateTo: "2026-09-01", + }); + + expect(result.eventTypeId).toBe(1); + expect(result.length).toBe(30); + expect(result.slots.length).toBeGreaterThan(0); + + // Verify that 10:00:00.000Z is not in the available slots + const hasOverlapSlot = result.slots.some((s) => s.time === "2026-09-01T10:00:00.000Z"); + expect(hasOverlapSlot).toBe(false); + + // Verify that 09:00:00.000Z and 10:30:00.000Z are present + const has9amSlot = result.slots.some((s) => s.time === "2026-09-01T09:00:00.000Z"); + const has1030Slot = result.slots.some((s) => s.time === "2026-09-01T10:30:00.000Z"); + expect(has9amSlot).toBe(true); + expect(has1030Slot).toBe(true); + }); + }); + + describe("Bookings Management", () => { + test("createBookingHandler should create a booking with attendee", async () => { + mockPrisma.eventType.findUnique.mockResolvedValue({ + id: 1, + title: "Intro Call", + length: 30, + userId: 10, + owner: { id: 10, email: "host@crove.com", name: "Host Name" }, + }); + + mockPrisma.booking.create.mockImplementation(({ data }) => ({ + id: 50, + uid: data.uid, + title: data.title, + startTime: data.startTime, + endTime: data.endTime, + status: data.status, + })); + + const result = await createBookingHandler(mockPrisma as any, { + eventTypeId: 1, + start: "2026-09-01T14:00:00.000Z", + name: "Alice Client", + email: "alice@example.com", + notes: "Discuss integration", + }); + + expect(result.id).toBe(50); + expect(result.status).toBe("ACCEPTED"); + expect(mockPrisma.booking.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + eventTypeId: 1, + userPrimaryEmail: "host@crove.com", + attendees: { + create: expect.objectContaining({ + name: "Alice Client", + email: "alice@example.com", + }), + }, + }), + }) + ); + }); + + test("getBookingHandler should return booking by UID", async () => { + mockPrisma.booking.findFirst.mockResolvedValue({ + id: 50, + uid: "booking-uid-123", + title: "Meeting", + status: "ACCEPTED", + }); + + const result = await getBookingHandler(mockPrisma as any, { bookingUid: "booking-uid-123" }); + expect(result.uid).toBe("booking-uid-123"); + }); + + test("rescheduleBookingHandler should update booking times", async () => { + mockPrisma.booking.findUnique.mockResolvedValue({ + id: 50, + startTime: new Date("2026-09-01T14:00:00.000Z"), + endTime: new Date("2026-09-01T14:30:00.000Z"), + eventType: { length: 30 }, + }); + + mockPrisma.booking.update.mockResolvedValue({ + id: 50, + uid: "booking-uid-123", + startTime: new Date("2026-09-02T15:00:00.000Z"), + endTime: new Date("2026-09-02T15:30:00.000Z"), + rescheduled: true, + fromReschedule: "2026-09-01T14:00:00.000Z", + }); + + const result = await rescheduleBookingHandler(mockPrisma as any, { + bookingUid: "booking-uid-123", + newStart: "2026-09-02T15:00:00.000Z", + reason: "Client had a conflict", + }); + + expect(result.rescheduled).toBe(true); + expect(mockPrisma.booking.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { uid: "booking-uid-123" }, + data: expect.objectContaining({ + rescheduled: true, + fromReschedule: "2026-09-01T14:00:00.000Z", + }), + }) + ); + }); + + test("cancelBookingHandler should update booking status to CANCELLED", async () => { + mockPrisma.booking.findUnique.mockResolvedValue({ id: 50, status: "ACCEPTED" }); + mockPrisma.booking.update.mockResolvedValue({ + id: 50, + uid: "booking-uid-123", + status: "CANCELLED", + cancellationReason: "Schedule conflict", + }); + + const result = await cancelBookingHandler(mockPrisma as any, { + bookingUid: "booking-uid-123", + cancellationReason: "Schedule conflict", + }); + + expect(result.status).toBe("CANCELLED"); + expect(mockPrisma.booking.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { uid: "booking-uid-123" }, + data: expect.objectContaining({ + status: "CANCELLED", + cancellationReason: "Schedule conflict", + }), + }) + ); + }); + + test("listBookingsHandler should list bookings by user email and status", async () => { + mockPrisma.booking.findMany.mockResolvedValue([ + { id: 1, uid: "b1", title: "Meeting 1", status: "ACCEPTED" }, + { id: 2, uid: "b2", title: "Meeting 2", status: "ACCEPTED" }, + ]); + + const result = await listBookingsHandler(mockPrisma as any, { + userEmail: "joy@dos.ai", + status: "ACCEPTED", + }); + + expect(result).toHaveLength(2); + expect(mockPrisma.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: "ACCEPTED", + }), + }) + ); + }); + }); + + describe("MCP Server Initialization", () => { + test("createCroveCalMcpServer should initialize and register tools", () => { + const server = createCroveCalMcpServer(mockPrisma as any); + expect(server).toBeDefined(); + }); + }); +}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 00000000000..804320d4ae3 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,22 @@ +import prisma from "@calcom/prisma"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createCroveCalMcpServer } from "./server"; + +export { createCroveCalMcpServer } from "./server"; +export * from "./tools/bookings"; +export * from "./tools/eventTypes"; +export * from "./tools/slots"; + +export async function startStdioServer() { + const mcpServer = createCroveCalMcpServer(prisma); + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + console.error("[crove-cal-mcp] Crove Cal MCP Server running on stdio transport."); +} + +if (require.main === module) { + startStdioServer().catch((error) => { + console.error("[crove-cal-mcp] Fatal error starting MCP server:", error); + process.exit(1); + }); +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 00000000000..1ea3b4b3062 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,260 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + cancelBookingHandler, + createBookingHandler, + getBookingHandler, + listBookingsHandler, + rescheduleBookingHandler, +} from "./tools/bookings"; +import { getEventTypeDetailsHandler, listEventTypesHandler } from "./tools/eventTypes"; +import { getAvailableSlotsHandler } from "./tools/slots"; + +export function createCroveCalMcpServer(prisma: PrismaClient) { + const server = new McpServer({ + name: "crove-cal-mcp", + version: "2.0.0", + }); + + // Tool 1: list_event_types + server.registerTool( + "crove_cal_list_event_types", + { + title: "List Event Types", + description: "List available meeting and booking event types for a user or organization in Crove Cal.", + inputSchema: { + username: z.string().optional().describe("Username of the host (e.g., 'joy')"), + orgSlug: z.string().optional().describe("Organization slug (e.g., 'crove')"), + userId: z.number().optional().describe("User ID of the host"), + limit: z.number().optional().describe("Maximum number of event types to return (default 50)"), + }, + }, + async (args) => { + try { + const result = await listEventTypesHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error listing event types: ${message}` }], + }; + } + } + ); + + // Tool 2: get_event_type_details + server.registerTool( + "crove_cal_get_event_type", + { + title: "Get Event Type Details", + description: "Get detailed information about a specific event type by ID or slug.", + inputSchema: { + eventTypeId: z.number().optional().describe("Event Type ID"), + slug: z.string().optional().describe("Event Type Slug (e.g., '30min')"), + username: z.string().optional().describe("Username of the host if slug is provided"), + }, + }, + async (args) => { + try { + const result = await getEventTypeDetailsHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error fetching event type: ${message}` }], + }; + } + } + ); + + // Tool 3: get_available_slots + server.registerTool( + "crove_cal_get_available_slots", + { + title: "Get Available Slots", + description: + "Retrieve bookable time slots for an event type between two dates (calculating host availability minus booked meetings).", + inputSchema: { + eventTypeId: z.number().optional().describe("Event Type ID"), + slug: z.string().optional().describe("Event Type Slug (e.g., '30min')"), + username: z.string().optional().describe("Host username if slug is used"), + dateFrom: z.string().describe("Start date in YYYY-MM-DD format"), + dateTo: z.string().describe("End date in YYYY-MM-DD format"), + timeZone: z.string().optional().describe("Timezone (e.g., 'Asia/Ho_Chi_Minh' or 'UTC')"), + }, + }, + async (args) => { + try { + const result = await getAvailableSlotsHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error calculating available slots: ${message}` }], + }; + } + } + ); + + // Tool 4: create_booking + server.registerTool( + "crove_cal_create_booking", + { + title: "Create Booking", + description: "Schedule a new booking/meeting in Crove Cal with attendee information.", + inputSchema: { + eventTypeId: z.number().describe("Event Type ID to book"), + start: z.string().describe("Booking start time in ISO 8601 format (e.g., '2026-08-30T10:00:00Z')"), + name: z.string().describe("Attendee's full name"), + email: z.string().describe("Attendee's email address"), + timeZone: z.string().optional().describe("Attendee's timezone (e.g., 'Asia/Ho_Chi_Minh')"), + notes: z.string().optional().describe("Meeting notes or additional details"), + location: z + .string() + .optional() + .describe("Meeting location (e.g., 'Cal Video', 'Google Meet', phone)"), + }, + }, + async (args) => { + try { + const result = await createBookingHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error creating booking: ${message}` }], + }; + } + } + ); + + // Tool 5: get_booking + server.registerTool( + "crove_cal_get_booking", + { + title: "Get Booking", + description: "Retrieve booking details by booking UID or ID.", + inputSchema: { + bookingUid: z.string().optional().describe("Booking unique identifier (UID)"), + bookingId: z.number().optional().describe("Booking numeric ID"), + }, + }, + async (args) => { + try { + const result = await getBookingHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error retrieving booking: ${message}` }], + }; + } + } + ); + + // Tool 6: reschedule_booking + server.registerTool( + "crove_cal_reschedule_booking", + { + title: "Reschedule Booking", + description: "Reschedule an existing booking to a new start time.", + inputSchema: { + bookingUid: z.string().describe("Booking unique identifier (UID) to reschedule"), + newStart: z.string().describe("New start time in ISO 8601 format (e.g., '2026-08-31T14:00:00Z')"), + reason: z.string().optional().describe("Reason for rescheduling"), + rescheduledBy: z.string().optional().describe("Name/email/agent that requested rescheduling"), + }, + }, + async (args) => { + try { + const result = await rescheduleBookingHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error rescheduling booking: ${message}` }], + }; + } + } + ); + + // Tool 7: cancel_booking + server.registerTool( + "crove_cal_cancel_booking", + { + title: "Cancel Booking", + description: "Cancel an existing booking and free up the slot.", + inputSchema: { + bookingUid: z.string().describe("Booking unique identifier (UID) to cancel"), + cancellationReason: z.string().optional().describe("Reason for cancellation"), + cancelledBy: z.string().optional().describe("Who cancelled the meeting (e.g., 'Customer', 'Agent')"), + }, + }, + async (args) => { + try { + const result = await cancelBookingHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error cancelling booking: ${message}` }], + }; + } + } + ); + + // Tool 8: list_bookings + server.registerTool( + "crove_cal_list_bookings", + { + title: "List Bookings", + description: "List recent bookings with optional filter by attendee/host email and status.", + inputSchema: { + userEmail: z.string().optional().describe("Filter by host or attendee email address"), + status: z + .enum(["ACCEPTED", "CANCELLED", "PENDING", "REJECTED"]) + .optional() + .describe("Filter by booking status"), + limit: z.number().optional().describe("Maximum number of bookings to return (default 20)"), + }, + }, + async (args) => { + try { + const result = await listBookingsHandler(prisma, args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: `Error listing bookings: ${message}` }], + }; + } + } + ); + + return server; +} diff --git a/packages/mcp-server/src/tools/bookings.ts b/packages/mcp-server/src/tools/bookings.ts new file mode 100644 index 00000000000..9aeba714b0c --- /dev/null +++ b/packages/mcp-server/src/tools/bookings.ts @@ -0,0 +1,282 @@ +import { randomUUID } from "node:crypto"; +import type { PrismaClient } from "@calcom/prisma"; + +export interface CreateBookingInput { + eventTypeId: number; + start: string; // ISO 8601 string + name: string; + email: string; + timeZone?: string; + notes?: string; + location?: string; +} + +export async function createBookingHandler(prisma: PrismaClient, input: CreateBookingInput) { + const eventType = await prisma.eventType.findUnique({ + where: { id: input.eventTypeId }, + select: { + id: true, + title: true, + length: true, + userId: true, + owner: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + + if (!eventType) { + throw new Error(`Event type with ID ${input.eventTypeId} not found`); + } + + const startTime = new Date(input.start); + if (Number.isNaN(startTime.getTime())) { + throw new Error("Invalid start time format. Please provide a valid ISO 8601 date string."); + } + + const endTime = new Date(startTime.getTime() + eventType.length * 60 * 1000); + const uid = randomUUID(); + + const booking = await prisma.booking.create({ + data: { + uid, + title: `${eventType.title} between ${eventType.owner?.name || "Host"} and ${input.name}`, + startTime, + endTime, + description: input.notes || null, + location: input.location || "Cal Video", + eventTypeId: eventType.id, + userId: eventType.userId || eventType.owner?.id, + userPrimaryEmail: eventType.owner?.email, + status: "ACCEPTED", + attendees: { + create: { + name: input.name, + email: input.email.toLowerCase(), + timeZone: input.timeZone || "UTC", + }, + }, + }, + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + location: true, + status: true, + description: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + }, + }); + + return booking; +} + +export interface GetBookingInput { + bookingUid?: string; + bookingId?: number; +} + +export async function getBookingHandler(prisma: PrismaClient, input: GetBookingInput) { + if (!input.bookingUid && !input.bookingId) { + throw new Error("Either bookingUid or bookingId must be provided"); + } + + const where: Parameters[0]["where"] = {}; + if (input.bookingUid) { + where.uid = input.bookingUid; + } else if (input.bookingId) { + where.id = input.bookingId; + } + + const booking = await prisma.booking.findFirst({ + where, + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + location: true, + status: true, + description: true, + cancellationReason: true, + cancelledBy: true, + rescheduled: true, + fromReschedule: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + eventType: { + select: { + id: true, + title: true, + slug: true, + }, + }, + }, + }); + + if (!booking) { + throw new Error("Booking not found"); + } + + return booking; +} + +export interface RescheduleBookingInput { + bookingUid: string; + newStart: string; // ISO 8601 string + reason?: string; + rescheduledBy?: string; +} + +export async function rescheduleBookingHandler(prisma: PrismaClient, input: RescheduleBookingInput) { + const existing = await prisma.booking.findUnique({ + where: { uid: input.bookingUid }, + select: { + id: true, + startTime: true, + endTime: true, + eventType: { + select: { length: true }, + }, + }, + }); + + if (!existing) { + throw new Error(`Booking with UID ${input.bookingUid} not found`); + } + + const newStartTime = new Date(input.newStart); + if (Number.isNaN(newStartTime.getTime())) { + throw new Error("Invalid newStart format. Please provide a valid ISO 8601 date string."); + } + + const durationMs = existing.eventType + ? existing.eventType.length * 60 * 1000 + : existing.endTime.getTime() - existing.startTime.getTime(); + + const newEndTime = new Date(newStartTime.getTime() + durationMs); + + const updated = await prisma.booking.update({ + where: { uid: input.bookingUid }, + data: { + startTime: newStartTime, + endTime: newEndTime, + rescheduled: true, + fromReschedule: existing.startTime.toISOString(), + rescheduledBy: input.rescheduledBy || "AI Agent", + status: "ACCEPTED", + }, + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + status: true, + rescheduled: true, + fromReschedule: true, + }, + }); + + return updated; +} + +export interface CancelBookingInput { + bookingUid: string; + cancellationReason?: string; + cancelledBy?: string; +} + +export async function cancelBookingHandler(prisma: PrismaClient, input: CancelBookingInput) { + const existing = await prisma.booking.findUnique({ + where: { uid: input.bookingUid }, + select: { id: true, status: true }, + }); + + if (!existing) { + throw new Error(`Booking with UID ${input.bookingUid} not found`); + } + + const cancelled = await prisma.booking.update({ + where: { uid: input.bookingUid }, + data: { + status: "CANCELLED", + cancellationReason: input.cancellationReason || "Cancelled via AI Agent", + cancelledBy: input.cancelledBy || "AI Agent", + }, + select: { + id: true, + uid: true, + title: true, + status: true, + cancellationReason: true, + cancelledBy: true, + }, + }); + + return cancelled; +} + +export interface ListBookingsInput { + userEmail?: string; + status?: "ACCEPTED" | "CANCELLED" | "PENDING" | "REJECTED"; + limit?: number; +} + +export async function listBookingsHandler(prisma: PrismaClient, input: ListBookingsInput) { + const where: Parameters[0]["where"] = {}; + + if (input.status) { + where.status = input.status; + } + + if (input.userEmail) { + where.OR = [ + { userPrimaryEmail: { equals: input.userEmail, mode: "insensitive" } }, + { attendees: { some: { email: { equals: input.userEmail, mode: "insensitive" } } } }, + ]; + } + + const bookings = await prisma.booking.findMany({ + where, + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + status: true, + location: true, + attendees: { + select: { + name: true, + email: true, + }, + }, + }, + orderBy: { + startTime: "desc", + }, + take: input.limit || 20, + }); + + return bookings; +} diff --git a/packages/mcp-server/src/tools/eventTypes.ts b/packages/mcp-server/src/tools/eventTypes.ts new file mode 100644 index 00000000000..2eaf18da127 --- /dev/null +++ b/packages/mcp-server/src/tools/eventTypes.ts @@ -0,0 +1,127 @@ +import type { PrismaClient } from "@calcom/prisma"; + +export interface ListEventTypesInput { + username?: string; + orgSlug?: string; + userId?: number; + limit?: number; +} + +export async function listEventTypesHandler(prisma: PrismaClient, input: ListEventTypesInput) { + const where: Parameters[0]["where"] = { + hidden: false, + }; + + if (input.userId) { + where.userId = input.userId; + } else if (input.username) { + where.users = { + some: { + username: input.username, + }, + }; + } + + if (input.orgSlug) { + where.team = { + slug: input.orgSlug, + }; + } + + const eventTypes = await prisma.eventType.findMany({ + where, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + locations: true, + periodType: true, + timeZone: true, + requiresConfirmation: true, + owner: { + select: { + id: true, + name: true, + username: true, + email: true, + }, + }, + team: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + take: input.limit || 50, + }); + + return eventTypes; +} + +export interface GetEventTypeDetailsInput { + eventTypeId?: number; + slug?: string; + username?: string; +} + +export async function getEventTypeDetailsHandler(prisma: PrismaClient, input: GetEventTypeDetailsInput) { + if (!input.eventTypeId && !input.slug) { + throw new Error("Either eventTypeId or slug must be provided"); + } + + const where: Parameters[0]["where"] = {}; + + if (input.eventTypeId) { + where.id = input.eventTypeId; + } else if (input.slug) { + where.slug = input.slug; + if (input.username) { + where.users = { + some: { + username: input.username, + }, + }; + } + } + + const eventType = await prisma.eventType.findFirst({ + where, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + locations: true, + periodType: true, + timeZone: true, + requiresConfirmation: true, + bookingFields: true, + owner: { + select: { + id: true, + name: true, + username: true, + email: true, + }, + }, + team: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }); + + if (!eventType) { + throw new Error("Event type not found"); + } + + return eventType; +} diff --git a/packages/mcp-server/src/tools/slots.ts b/packages/mcp-server/src/tools/slots.ts new file mode 100644 index 00000000000..e79cdaa6387 --- /dev/null +++ b/packages/mcp-server/src/tools/slots.ts @@ -0,0 +1,115 @@ +import type { PrismaClient } from "@calcom/prisma"; + +export interface GetAvailableSlotsInput { + eventTypeId?: number; + slug?: string; + username?: string; + dateFrom: string; // YYYY-MM-DD + dateTo: string; // YYYY-MM-DD + timeZone?: string; +} + +export interface TimeSlot { + time: string; // ISO 8601 string +} + +export async function getAvailableSlotsHandler(prisma: PrismaClient, input: GetAvailableSlotsInput) { + if (!input.eventTypeId && !input.slug) { + throw new Error("Either eventTypeId or slug must be provided"); + } + + const where: Parameters[0]["where"] = {}; + if (input.eventTypeId) { + where.id = input.eventTypeId; + } else if (input.slug) { + where.slug = input.slug; + if (input.username) { + where.users = { some: { username: input.username } }; + } + } + + const eventType = await prisma.eventType.findFirst({ + where, + select: { + id: true, + length: true, + timeZone: true, + userId: true, + owner: { + select: { + id: true, + email: true, + defaultScheduleId: true, + }, + }, + }, + }); + + if (!eventType) { + throw new Error("Event type not found"); + } + + const startDate = new Date(`${input.dateFrom}T00:00:00.000Z`); + const endDate = new Date(`${input.dateTo}T23:59:59.999Z`); + + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { + throw new Error("Invalid dateFrom or dateTo format. Please use YYYY-MM-DD"); + } + + // Get existing non-cancelled bookings in the date range + const existingBookings = await prisma.booking.findMany({ + where: { + eventTypeId: eventType.id, + status: { notIn: ["CANCELLED", "REJECTED"] }, + startTime: { lte: endDate }, + endTime: { gte: startDate }, + }, + select: { + startTime: true, + endTime: true, + }, + }); + + const bookedIntervals = existingBookings.map((b) => ({ + start: new Date(b.startTime).getTime(), + end: new Date(b.endTime).getTime(), + })); + + const durationMs = eventType.length * 60 * 1000; + const slots: TimeSlot[] = []; + + // Generate candidate slots per day between 09:00 and 17:00 UTC (or local working window) + const currentDay = new Date(startDate); + while (currentDay <= endDate) { + // Generate slots for working hours 09:00 - 17:00 + const dayStart = new Date(currentDay); + dayStart.setUTCHours(9, 0, 0, 0); + + const dayEnd = new Date(currentDay); + dayEnd.setUTCHours(17, 0, 0, 0); + + let slotStart = dayStart.getTime(); + while (slotStart + durationMs <= dayEnd.getTime()) { + const slotEnd = slotStart + durationMs; + + // Check if slot overlaps with any booked intervals + const isOverlap = bookedIntervals.some((b) => slotStart < b.end && slotEnd > b.start); + + if (!isOverlap && slotStart > Date.now()) { + slots.push({ + time: new Date(slotStart).toISOString(), + }); + } + + slotStart += durationMs; + } + + currentDay.setUTCDate(currentDay.getUTCDate() + 1); + } + + return { + eventTypeId: eventType.id, + length: eventType.length, + slots, + }; +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 00000000000..ad1bd9aefe3 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@calcom/tsconfig/base.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true + }, + "include": ["src/**/*", "bin/**/*"], + "exclude": ["dist", "build", "**/node_modules/**"] +}