From e2bb63d4ceb63602af2bbdcc83a1f492ae86694d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:13:08 +0000 Subject: [PATCH 1/4] feat: add get_round_robin_config MCP tool for team event types Co-Authored-By: joe@cal.com --- apps/mcp-server/src/register-tools.ts | 15 ++- apps/mcp-server/src/tools/event-types.test.ts | 104 ++++++++++++++++++ apps/mcp-server/src/tools/event-types.ts | 61 ++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/apps/mcp-server/src/register-tools.ts b/apps/mcp-server/src/register-tools.ts index 75314201..9a3b9849 100644 --- a/apps/mcp-server/src/register-tools.ts +++ b/apps/mcp-server/src/register-tools.ts @@ -14,6 +14,8 @@ import { createEventType, updateEventTypeSchema, updateEventType, + getRoundRobinConfigSchema, + getRoundRobinConfig, deleteEventTypeSchema, deleteEventType, } from "./tools/event-types.js"; @@ -162,7 +164,7 @@ export function registerTools(server: McpServer): void { updateMe, ); - // ── Event Types (5) ── + // ── Event Types (6) ── server.registerTool( "get_event_types", { @@ -218,6 +220,17 @@ export function registerTools(server: McpServer): void { }, deleteEventType, ); + server.registerTool( + "get_round_robin_config", + { + title: "Get Round-Robin Config", + description: + "Get the round-robin / team scheduling configuration for an event type. Shows who is in the host rotation pool and each host's priority, weight, and isFixed status. Also returns schedulingType and whether assignAllTeamMembers is enabled. For org-scoped team event types, pass orgId + teamId.", + inputSchema: getRoundRobinConfigSchema, + annotations: READ_ONLY, + }, + getRoundRobinConfig, + ); // ── Bookings (10) ── server.registerTool( diff --git a/apps/mcp-server/src/tools/event-types.test.ts b/apps/mcp-server/src/tools/event-types.test.ts index 909d3434..54d61445 100644 --- a/apps/mcp-server/src/tools/event-types.test.ts +++ b/apps/mcp-server/src/tools/event-types.test.ts @@ -12,11 +12,13 @@ import { createEventType, updateEventType, deleteEventType, + getRoundRobinConfig, getEventTypesSchema, getEventTypeSchema, createEventTypeSchema, updateEventTypeSchema, deleteEventTypeSchema, + getRoundRobinConfigSchema, } from "./event-types.js"; const mockCalApi = vi.mocked(calApi); @@ -65,6 +67,12 @@ describe("event-types schemas", () => { it("exports deleteEventTypeSchema", () => { expect(deleteEventTypeSchema.eventTypeId).toBeDefined(); }); + + it("exports getRoundRobinConfigSchema with required and optional fields", () => { + expect(getRoundRobinConfigSchema.eventTypeId).toBeDefined(); + expect(getRoundRobinConfigSchema.orgId).toBeDefined(); + expect(getRoundRobinConfigSchema.teamId).toBeDefined(); + }); }); describe("getEventTypes", () => { @@ -174,3 +182,99 @@ describe("deleteEventType", () => { expect(mockCalApi).toHaveBeenCalledWith("event-types/42", { method: "DELETE" }); }); }); + +describe("getRoundRobinConfig", () => { + it("fetches event type and extracts round-robin fields", async () => { + mockCalApi.mockResolvedValueOnce({ + id: 10, + schedulingType: "roundRobin", + assignAllTeamMembers: false, + hosts: [ + { userId: 1, name: "Alice", isFixed: false, priority: 2, weight: 100 }, + { userId: 2, name: "Bob", isFixed: true, priority: 1, weight: 50 }, + ], + }); + + const result = await getRoundRobinConfig({ eventTypeId: 10 }); + + expect(mockCalApi).toHaveBeenCalledWith("event-types/10"); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.eventTypeId).toBe(10); + expect(parsed.schedulingType).toBe("roundRobin"); + expect(parsed.isRoundRobin).toBe(true); + expect(parsed.assignAllTeamMembers).toBe(false); + expect(parsed.hosts).toHaveLength(2); + expect(parsed.hosts[0]).toEqual({ + userId: 1, + name: "Alice", + isFixed: false, + priority: 2, + weight: 100, + weightAdjustment: null, + scheduleId: null, + createdAt: null, + }); + }); + + it("uses org-scoped path when orgId and teamId are provided", async () => { + mockCalApi.mockResolvedValueOnce({ + schedulingType: "roundRobin", + assignAllTeamMembers: true, + hosts: [], + }); + + const result = await getRoundRobinConfig({ eventTypeId: 5, orgId: 100, teamId: 200 }); + + expect(mockCalApi).toHaveBeenCalledWith("organizations/100/teams/200/event-types/5"); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.assignAllTeamMembers).toBe(true); + expect(parsed.isRoundRobin).toBe(true); + }); + + it("returns isRoundRobin false for non-RR scheduling types", async () => { + mockCalApi.mockResolvedValueOnce({ + schedulingType: "collective", + hosts: [{ userId: 3, name: "Carol" }], + }); + + const result = await getRoundRobinConfig({ eventTypeId: 7 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.schedulingType).toBe("collective"); + expect(parsed.isRoundRobin).toBe(false); + expect(parsed.hosts).toHaveLength(1); + }); + + it("handles null schedulingType gracefully", async () => { + mockCalApi.mockResolvedValueOnce({ + hosts: [], + }); + + const result = await getRoundRobinConfig({ eventTypeId: 1 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.schedulingType).toBeNull(); + expect(parsed.isRoundRobin).toBe(false); + expect(parsed.assignAllTeamMembers).toBe(false); + }); + + it("handles ROUND_ROBIN with underscore/mixed case", async () => { + mockCalApi.mockResolvedValueOnce({ + schedulingType: "ROUND_ROBIN", + hosts: [], + }); + + const result = await getRoundRobinConfig({ eventTypeId: 1 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.isRoundRobin).toBe(true); + }); + + it("handles errors", async () => { + mockCalApi.mockRejectedValueOnce(new CalApiError(404, "Not found", {})); + + const result = await getRoundRobinConfig({ eventTypeId: 999 }); + + expect(result).toHaveProperty("isError", true); + }); +}); diff --git a/apps/mcp-server/src/tools/event-types.ts b/apps/mcp-server/src/tools/event-types.ts index ae3f5d06..5193fd99 100644 --- a/apps/mcp-server/src/tools/event-types.ts +++ b/apps/mcp-server/src/tools/event-types.ts @@ -294,6 +294,67 @@ export async function updateEventType(params: { } } +export const getRoundRobinConfigSchema = { + eventTypeId: z.number().int().describe("Event type ID. Use get_event_types to find this."), + orgId: z.number().int().optional().describe("Organization ID for org-scoped team event types. Use get_me to obtain your organizationId."), + teamId: z.number().int().optional().describe("Team ID for org-scoped team event types. Required together with orgId."), +}; + +interface RoundRobinHost { + userId: number; + name?: string; + isFixed?: boolean; + priority?: number; + weight?: number; + weightAdjustment?: number; + scheduleId?: number | null; + createdAt?: string; +} + +export async function getRoundRobinConfig(params: { + eventTypeId: number; + orgId?: number; + teamId?: number; +}) { + try { + const path = + params.orgId !== undefined && params.teamId !== undefined + ? `organizations/${params.orgId}/teams/${params.teamId}/event-types/${params.eventTypeId}` + : `event-types/${params.eventTypeId}`; + + const data = (await calApi(path)) as Record; + + const schedulingType = (data.schedulingType as string) ?? null; + const assignAllTeamMembers = (data.assignAllTeamMembers as boolean) ?? false; + + const rawHosts = (data.hosts as RoundRobinHost[] | undefined) ?? []; + const hosts = rawHosts.map((h) => ({ + userId: h.userId, + name: h.name ?? null, + isFixed: h.isFixed ?? false, + priority: h.priority ?? null, + weight: h.weight ?? null, + weightAdjustment: h.weightAdjustment ?? null, + scheduleId: h.scheduleId ?? null, + createdAt: h.createdAt ?? null, + })); + + const isRoundRobin = + typeof schedulingType === "string" && + schedulingType.toLowerCase().replace(/_/g, "") === "roundrobin"; + + return ok({ + eventTypeId: params.eventTypeId, + schedulingType, + isRoundRobin, + assignAllTeamMembers, + hosts, + }); + } catch (err) { + return handleError("get_round_robin_config", err); + } +} + export const deleteEventTypeSchema = { eventTypeId: z.number().int().describe("Event type ID to delete. Use get_event_types to find this."), }; From 2d76f84f2e44fbb59d931694745a01d661c69d8e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:47:47 +0000 Subject: [PATCH 2/4] refactor: return full event type settings from get_round_robin_config Instead of cherry-picking specific RR fields, pass through the raw API response so the tool surfaces whatever event type settings the Cal.com API exposes. Co-Authored-By: joe@cal.com --- apps/mcp-server/src/register-tools.ts | 2 +- apps/mcp-server/src/tools/event-types.test.ts | 80 ++++++------------- apps/mcp-server/src/tools/event-types.ts | 41 +--------- 3 files changed, 27 insertions(+), 96 deletions(-) diff --git a/apps/mcp-server/src/register-tools.ts b/apps/mcp-server/src/register-tools.ts index 9a3b9849..182db583 100644 --- a/apps/mcp-server/src/register-tools.ts +++ b/apps/mcp-server/src/register-tools.ts @@ -225,7 +225,7 @@ export function registerTools(server: McpServer): void { { title: "Get Round-Robin Config", description: - "Get the round-robin / team scheduling configuration for an event type. Shows who is in the host rotation pool and each host's priority, weight, and isFixed status. Also returns schedulingType and whether assignAllTeamMembers is enabled. For org-scoped team event types, pass orgId + teamId.", + "Get the full event type settings including round-robin / team scheduling configuration. Returns all fields exposed by the Cal.com API: schedulingType, hosts (with priority, weight, isFixed), assignAllTeamMembers, and all other event type settings. For org-scoped team event types, pass orgId + teamId.", inputSchema: getRoundRobinConfigSchema, annotations: READ_ONLY, }, diff --git a/apps/mcp-server/src/tools/event-types.test.ts b/apps/mcp-server/src/tools/event-types.test.ts index 54d61445..a27bfe06 100644 --- a/apps/mcp-server/src/tools/event-types.test.ts +++ b/apps/mcp-server/src/tools/event-types.test.ts @@ -184,90 +184,58 @@ describe("deleteEventType", () => { }); describe("getRoundRobinConfig", () => { - it("fetches event type and extracts round-robin fields", async () => { - mockCalApi.mockResolvedValueOnce({ + it("returns full event type settings from the API", async () => { + const apiResponse = { id: 10, + title: "Team Meeting", + slug: "team-meeting", schedulingType: "roundRobin", assignAllTeamMembers: false, + lengthInMinutes: 30, hosts: [ - { userId: 1, name: "Alice", isFixed: false, priority: 2, weight: 100 }, - { userId: 2, name: "Bob", isFixed: true, priority: 1, weight: 50 }, + { userId: 1, name: "Alice", isFixed: false, priority: 2, weight: 100, scheduleId: 456 }, + { userId: 2, name: "Bob", isFixed: true, priority: 1, weight: 50, scheduleId: null }, ], - }); + }; + mockCalApi.mockResolvedValueOnce(apiResponse); const result = await getRoundRobinConfig({ eventTypeId: 10 }); expect(mockCalApi).toHaveBeenCalledWith("event-types/10"); const parsed = JSON.parse(result.content[0].text); - expect(parsed.eventTypeId).toBe(10); - expect(parsed.schedulingType).toBe("roundRobin"); - expect(parsed.isRoundRobin).toBe(true); - expect(parsed.assignAllTeamMembers).toBe(false); - expect(parsed.hosts).toHaveLength(2); - expect(parsed.hosts[0]).toEqual({ - userId: 1, - name: "Alice", - isFixed: false, - priority: 2, - weight: 100, - weightAdjustment: null, - scheduleId: null, - createdAt: null, - }); + expect(parsed).toEqual(apiResponse); }); it("uses org-scoped path when orgId and teamId are provided", async () => { - mockCalApi.mockResolvedValueOnce({ + const apiResponse = { + id: 5, schedulingType: "roundRobin", assignAllTeamMembers: true, hosts: [], - }); + }; + mockCalApi.mockResolvedValueOnce(apiResponse); const result = await getRoundRobinConfig({ eventTypeId: 5, orgId: 100, teamId: 200 }); expect(mockCalApi).toHaveBeenCalledWith("organizations/100/teams/200/event-types/5"); const parsed = JSON.parse(result.content[0].text); - expect(parsed.assignAllTeamMembers).toBe(true); - expect(parsed.isRoundRobin).toBe(true); + expect(parsed).toEqual(apiResponse); }); - it("returns isRoundRobin false for non-RR scheduling types", async () => { - mockCalApi.mockResolvedValueOnce({ + it("returns whatever the API exposes without transformation", async () => { + const apiResponse = { + id: 7, schedulingType: "collective", - hosts: [{ userId: 3, name: "Carol" }], - }); + hosts: [{ userId: 3, name: "Carol", isFixed: true }], + bookingLimitsCount: { day: 2 }, + locations: [{ type: "inPerson", address: "123 Main St" }], + }; + mockCalApi.mockResolvedValueOnce(apiResponse); const result = await getRoundRobinConfig({ eventTypeId: 7 }); const parsed = JSON.parse(result.content[0].text); - expect(parsed.schedulingType).toBe("collective"); - expect(parsed.isRoundRobin).toBe(false); - expect(parsed.hosts).toHaveLength(1); - }); - - it("handles null schedulingType gracefully", async () => { - mockCalApi.mockResolvedValueOnce({ - hosts: [], - }); - - const result = await getRoundRobinConfig({ eventTypeId: 1 }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.schedulingType).toBeNull(); - expect(parsed.isRoundRobin).toBe(false); - expect(parsed.assignAllTeamMembers).toBe(false); - }); - - it("handles ROUND_ROBIN with underscore/mixed case", async () => { - mockCalApi.mockResolvedValueOnce({ - schedulingType: "ROUND_ROBIN", - hosts: [], - }); - - const result = await getRoundRobinConfig({ eventTypeId: 1 }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.isRoundRobin).toBe(true); + expect(parsed).toEqual(apiResponse); }); it("handles errors", async () => { diff --git a/apps/mcp-server/src/tools/event-types.ts b/apps/mcp-server/src/tools/event-types.ts index 5193fd99..f7609c74 100644 --- a/apps/mcp-server/src/tools/event-types.ts +++ b/apps/mcp-server/src/tools/event-types.ts @@ -300,17 +300,6 @@ export const getRoundRobinConfigSchema = { teamId: z.number().int().optional().describe("Team ID for org-scoped team event types. Required together with orgId."), }; -interface RoundRobinHost { - userId: number; - name?: string; - isFixed?: boolean; - priority?: number; - weight?: number; - weightAdjustment?: number; - scheduleId?: number | null; - createdAt?: string; -} - export async function getRoundRobinConfig(params: { eventTypeId: number; orgId?: number; @@ -322,34 +311,8 @@ export async function getRoundRobinConfig(params: { ? `organizations/${params.orgId}/teams/${params.teamId}/event-types/${params.eventTypeId}` : `event-types/${params.eventTypeId}`; - const data = (await calApi(path)) as Record; - - const schedulingType = (data.schedulingType as string) ?? null; - const assignAllTeamMembers = (data.assignAllTeamMembers as boolean) ?? false; - - const rawHosts = (data.hosts as RoundRobinHost[] | undefined) ?? []; - const hosts = rawHosts.map((h) => ({ - userId: h.userId, - name: h.name ?? null, - isFixed: h.isFixed ?? false, - priority: h.priority ?? null, - weight: h.weight ?? null, - weightAdjustment: h.weightAdjustment ?? null, - scheduleId: h.scheduleId ?? null, - createdAt: h.createdAt ?? null, - })); - - const isRoundRobin = - typeof schedulingType === "string" && - schedulingType.toLowerCase().replace(/_/g, "") === "roundrobin"; - - return ok({ - eventTypeId: params.eventTypeId, - schedulingType, - isRoundRobin, - assignAllTeamMembers, - hosts, - }); + const data = await calApi(path); + return ok(data); } catch (err) { return handleError("get_round_robin_config", err); } From 287a28f0bc43ec621002e30ed8b29021442bf31a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:53:23 +0000 Subject: [PATCH 3/4] refactor: rename get_round_robin_config to get_event_type_settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the MCP tool to better reflect its purpose — it returns full event type settings, not just round-robin configuration. Co-Authored-By: joe@cal.com --- apps/mcp-server/src/register-tools.ts | 14 ++++++------ apps/mcp-server/src/tools/event-types.test.ts | 22 +++++++++---------- apps/mcp-server/src/tools/event-types.ts | 6 ++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/mcp-server/src/register-tools.ts b/apps/mcp-server/src/register-tools.ts index 182db583..352f9d0d 100644 --- a/apps/mcp-server/src/register-tools.ts +++ b/apps/mcp-server/src/register-tools.ts @@ -14,8 +14,8 @@ import { createEventType, updateEventTypeSchema, updateEventType, - getRoundRobinConfigSchema, - getRoundRobinConfig, + getEventTypeSettingsSchema, + getEventTypeSettings, deleteEventTypeSchema, deleteEventType, } from "./tools/event-types.js"; @@ -221,15 +221,15 @@ export function registerTools(server: McpServer): void { deleteEventType, ); server.registerTool( - "get_round_robin_config", + "get_event_type_settings", { - title: "Get Round-Robin Config", + title: "Get Event Type Settings", description: - "Get the full event type settings including round-robin / team scheduling configuration. Returns all fields exposed by the Cal.com API: schedulingType, hosts (with priority, weight, isFixed), assignAllTeamMembers, and all other event type settings. For org-scoped team event types, pass orgId + teamId.", - inputSchema: getRoundRobinConfigSchema, + "Get the full event type settings as exposed by the Cal.com API — including scheduling type, hosts, locations, booking fields, limits, and all other configuration. For org-scoped team event types, pass orgId + teamId.", + inputSchema: getEventTypeSettingsSchema, annotations: READ_ONLY, }, - getRoundRobinConfig, + getEventTypeSettings, ); // ── Bookings (10) ── diff --git a/apps/mcp-server/src/tools/event-types.test.ts b/apps/mcp-server/src/tools/event-types.test.ts index a27bfe06..f7b6f3ee 100644 --- a/apps/mcp-server/src/tools/event-types.test.ts +++ b/apps/mcp-server/src/tools/event-types.test.ts @@ -12,13 +12,13 @@ import { createEventType, updateEventType, deleteEventType, - getRoundRobinConfig, + getEventTypeSettings, getEventTypesSchema, getEventTypeSchema, createEventTypeSchema, updateEventTypeSchema, deleteEventTypeSchema, - getRoundRobinConfigSchema, + getEventTypeSettingsSchema, } from "./event-types.js"; const mockCalApi = vi.mocked(calApi); @@ -68,10 +68,10 @@ describe("event-types schemas", () => { expect(deleteEventTypeSchema.eventTypeId).toBeDefined(); }); - it("exports getRoundRobinConfigSchema with required and optional fields", () => { - expect(getRoundRobinConfigSchema.eventTypeId).toBeDefined(); - expect(getRoundRobinConfigSchema.orgId).toBeDefined(); - expect(getRoundRobinConfigSchema.teamId).toBeDefined(); + it("exports getEventTypeSettingsSchema with required and optional fields", () => { + expect(getEventTypeSettingsSchema.eventTypeId).toBeDefined(); + expect(getEventTypeSettingsSchema.orgId).toBeDefined(); + expect(getEventTypeSettingsSchema.teamId).toBeDefined(); }); }); @@ -183,7 +183,7 @@ describe("deleteEventType", () => { }); }); -describe("getRoundRobinConfig", () => { +describe("getEventTypeSettings", () => { it("returns full event type settings from the API", async () => { const apiResponse = { id: 10, @@ -199,7 +199,7 @@ describe("getRoundRobinConfig", () => { }; mockCalApi.mockResolvedValueOnce(apiResponse); - const result = await getRoundRobinConfig({ eventTypeId: 10 }); + const result = await getEventTypeSettings({ eventTypeId: 10 }); expect(mockCalApi).toHaveBeenCalledWith("event-types/10"); const parsed = JSON.parse(result.content[0].text); @@ -215,7 +215,7 @@ describe("getRoundRobinConfig", () => { }; mockCalApi.mockResolvedValueOnce(apiResponse); - const result = await getRoundRobinConfig({ eventTypeId: 5, orgId: 100, teamId: 200 }); + const result = await getEventTypeSettings({ eventTypeId: 5, orgId: 100, teamId: 200 }); expect(mockCalApi).toHaveBeenCalledWith("organizations/100/teams/200/event-types/5"); const parsed = JSON.parse(result.content[0].text); @@ -232,7 +232,7 @@ describe("getRoundRobinConfig", () => { }; mockCalApi.mockResolvedValueOnce(apiResponse); - const result = await getRoundRobinConfig({ eventTypeId: 7 }); + const result = await getEventTypeSettings({ eventTypeId: 7 }); const parsed = JSON.parse(result.content[0].text); expect(parsed).toEqual(apiResponse); @@ -241,7 +241,7 @@ describe("getRoundRobinConfig", () => { it("handles errors", async () => { mockCalApi.mockRejectedValueOnce(new CalApiError(404, "Not found", {})); - const result = await getRoundRobinConfig({ eventTypeId: 999 }); + const result = await getEventTypeSettings({ eventTypeId: 999 }); expect(result).toHaveProperty("isError", true); }); diff --git a/apps/mcp-server/src/tools/event-types.ts b/apps/mcp-server/src/tools/event-types.ts index f7609c74..e6a10351 100644 --- a/apps/mcp-server/src/tools/event-types.ts +++ b/apps/mcp-server/src/tools/event-types.ts @@ -294,13 +294,13 @@ export async function updateEventType(params: { } } -export const getRoundRobinConfigSchema = { +export const getEventTypeSettingsSchema = { eventTypeId: z.number().int().describe("Event type ID. Use get_event_types to find this."), orgId: z.number().int().optional().describe("Organization ID for org-scoped team event types. Use get_me to obtain your organizationId."), teamId: z.number().int().optional().describe("Team ID for org-scoped team event types. Required together with orgId."), }; -export async function getRoundRobinConfig(params: { +export async function getEventTypeSettings(params: { eventTypeId: number; orgId?: number; teamId?: number; @@ -314,7 +314,7 @@ export async function getRoundRobinConfig(params: { const data = await calApi(path); return ok(data); } catch (err) { - return handleError("get_round_robin_config", err); + return handleError("get_event_type_settings", err); } } From 9ddc641201a1199e1b60b487578be97efeca6d71 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:22:01 +0000 Subject: [PATCH 4/4] fix: enforce orgId/teamId together and add TEAM_EVENT_TYPE_READ scope for get_event_type_settings Co-Authored-By: joe@cal.com --- apps/mcp-server/.env.example | 4 +-- apps/mcp-server/README.md | 5 ++-- apps/mcp-server/src/config.ts | 2 +- apps/mcp-server/src/server-instructions.ts | 1 + apps/mcp-server/src/tools/event-types.test.ts | 16 ++++++++++ apps/mcp-server/src/tools/event-types.ts | 30 +++++++++++++++++-- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/apps/mcp-server/.env.example b/apps/mcp-server/.env.example index 99995e62..aa9e03e0 100644 --- a/apps/mcp-server/.env.example +++ b/apps/mcp-server/.env.example @@ -19,8 +19,8 @@ CAL_API_KEY=cal_live_xxxx # CAL_OAUTH_CLIENT_SECRET=your_client_secret # Optional: override requested Cal.com OAuth scopes. -# Defaults include core tool scopes plus ORG_BOOKING_READ, TEAM_BOOKING_READ, ORG_MEMBERSHIP_READ, ORG_MEMBERSHIP_WRITE, and ORG_ROUTING_FORM_READ. -# CAL_OAUTH_SCOPES="EVENT_TYPE_READ EVENT_TYPE_WRITE BOOKING_READ BOOKING_WRITE SCHEDULE_READ SCHEDULE_WRITE APPS_READ APPS_WRITE PROFILE_READ PROFILE_WRITE ORG_BOOKING_READ TEAM_BOOKING_READ ORG_MEMBERSHIP_READ ORG_MEMBERSHIP_WRITE ORG_ROUTING_FORM_READ" +# Defaults include core tool scopes plus ORG_BOOKING_READ, TEAM_BOOKING_READ, TEAM_EVENT_TYPE_READ, ORG_MEMBERSHIP_READ, ORG_MEMBERSHIP_WRITE, and ORG_ROUTING_FORM_READ. +# CAL_OAUTH_SCOPES="EVENT_TYPE_READ EVENT_TYPE_WRITE BOOKING_READ BOOKING_WRITE SCHEDULE_READ SCHEDULE_WRITE APPS_READ APPS_WRITE PROFILE_READ PROFILE_WRITE ORG_BOOKING_READ TEAM_BOOKING_READ TEAM_EVENT_TYPE_READ ORG_MEMBERSHIP_READ ORG_MEMBERSHIP_WRITE ORG_ROUTING_FORM_READ" # Public URL of this MCP server (used for OAuth redirect URIs) # MCP_SERVER_URL=https://your-host.com diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index e0a5e5c8..2a496ac1 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -57,7 +57,7 @@ cp apps/mcp-server/.env.example apps/mcp-server/.env | `CAL_OAUTH_CLIENT_SECRET` | Yes | — | Cal.com OAuth client secret | | `TOKEN_ENCRYPTION_KEY` | Yes | — | 64-char hex string (32 bytes) for AES-256-GCM token encryption | | `MCP_SERVER_URL` | Yes | — | Public URL of this server (e.g. `https://mcp.example.com`) | -| `CAL_OAUTH_SCOPES` | No | Core scopes plus `ORG_BOOKING_READ TEAM_BOOKING_READ ORG_MEMBERSHIP_READ ORG_MEMBERSHIP_WRITE ORG_ROUTING_FORM_READ` | Space-separated Cal.com OAuth scopes requested during authorization | +| `CAL_OAUTH_SCOPES` | No | Core scopes plus `ORG_BOOKING_READ TEAM_BOOKING_READ TEAM_EVENT_TYPE_READ ORG_MEMBERSHIP_READ ORG_MEMBERSHIP_WRITE ORG_ROUTING_FORM_READ` | Space-separated Cal.com OAuth scopes requested during authorization | | `DATABASE_PATH` | No | `mcp-server.db` | SQLite database file path | | `RATE_LIMIT_WINDOW_MS` | No | `60000` | Rate limit window in ms (per IP) | | `RATE_LIMIT_MAX` | No | `30` | Max OAuth requests per window per IP | @@ -200,11 +200,12 @@ Each tool exposes MCP [tool annotations](https://modelcontextprotocol.io/specifi | `get_me` | Get My Profile | Read | Get authenticated user profile | | `update_me` | Update My Profile | Update | Update user profile | -### Event Types (7) +### Event Types (8) | Tool | Title | Hint | Description | |---|---|---|---| | `get_event_types` | List Event Types | Read | List all event types | | `get_event_type` | Get Event Type | Read | Get a specific event type by ID | +| `get_event_type_settings` | Get Event Type Settings | Read | Get the full event type settings; supports org-scoped team event types via orgId + teamId | | `get_event_type_history` | Get Event Type History | Read | Get the audit history (change log) for an event type | | `get_scheduling_config` | Get Scheduling Config | Read | Get scheduling configuration for a team event type | | `create_event_type` | Create Event Type | Create | Create a new event type | diff --git a/apps/mcp-server/src/config.ts b/apps/mcp-server/src/config.ts index 70702c4b..27e99a33 100644 --- a/apps/mcp-server/src/config.ts +++ b/apps/mcp-server/src/config.ts @@ -53,7 +53,7 @@ const httpSchema = baseSchema.extend({ calOAuthScopes: z .string() .default( - "EVENT_TYPE_READ EVENT_TYPE_WRITE BOOKING_READ BOOKING_WRITE SCHEDULE_READ SCHEDULE_WRITE APPS_READ APPS_WRITE PROFILE_READ PROFILE_WRITE ORG_BOOKING_READ TEAM_BOOKING_READ ORG_MEMBERSHIP_READ ORG_MEMBERSHIP_WRITE ORG_ROUTING_FORM_READ" + "EVENT_TYPE_READ EVENT_TYPE_WRITE BOOKING_READ BOOKING_WRITE SCHEDULE_READ SCHEDULE_WRITE APPS_READ APPS_WRITE PROFILE_READ PROFILE_WRITE ORG_BOOKING_READ TEAM_BOOKING_READ TEAM_EVENT_TYPE_READ ORG_MEMBERSHIP_READ ORG_MEMBERSHIP_WRITE ORG_ROUTING_FORM_READ" ), rateLimitWindowMs: z.coerce.number().int().positive().default(60_000), rateLimitMax: z.coerce.number().int().positive().default(30), diff --git a/apps/mcp-server/src/server-instructions.ts b/apps/mcp-server/src/server-instructions.ts index 578bb8c5..c77f5a9e 100644 --- a/apps/mcp-server/src/server-instructions.ts +++ b/apps/mcp-server/src/server-instructions.ts @@ -7,6 +7,7 @@ export const SERVER_INSTRUCTIONS = `You are connected to the Cal.com MCP server. CAPABILITIES — what you CAN do with the available tools: - Manage the user's profile (get_me, update_me) - List, create, update, and delete event types +- Get the full event type settings, including org-scoped team event types via orgId + teamId (get_event_type_settings) - Get the audit history (change log) for an event type (get_event_type_history) - Get scheduling configuration for team event types (hosts, priorities, weights, groups, distribution settings for roundRobin; basic host info for collective/managed) - List, get, create, reschedule, cancel, and confirm bookings diff --git a/apps/mcp-server/src/tools/event-types.test.ts b/apps/mcp-server/src/tools/event-types.test.ts index 25793297..f2246306 100644 --- a/apps/mcp-server/src/tools/event-types.test.ts +++ b/apps/mcp-server/src/tools/event-types.test.ts @@ -242,6 +242,22 @@ describe("getEventTypeSettings", () => { expect(parsed).toEqual(apiResponse); }); + it("returns an error when only orgId is provided without teamId", async () => { + const result = await getEventTypeSettings({ eventTypeId: 5, orgId: 100 }); + + expect(mockCalApi).not.toHaveBeenCalled(); + expect(result).toHaveProperty("isError", true); + expect(result.content[0].text).toContain("orgId and teamId must be provided together"); + }); + + it("returns an error when only teamId is provided without orgId", async () => { + const result = await getEventTypeSettings({ eventTypeId: 5, teamId: 200 }); + + expect(mockCalApi).not.toHaveBeenCalled(); + expect(result).toHaveProperty("isError", true); + expect(result.content[0].text).toContain("orgId and teamId must be provided together"); + }); + it("handles errors", async () => { mockCalApi.mockRejectedValueOnce(new CalApiError(404, "Not found", {})); diff --git a/apps/mcp-server/src/tools/event-types.ts b/apps/mcp-server/src/tools/event-types.ts index 84c60b5e..ebc77263 100644 --- a/apps/mcp-server/src/tools/event-types.ts +++ b/apps/mcp-server/src/tools/event-types.ts @@ -468,8 +468,18 @@ export async function updateEventType(params: { export const getEventTypeSettingsSchema = { eventTypeId: z.number().int().describe("Event type ID. Use get_event_types to find this."), - orgId: z.number().int().optional().describe("Organization ID for org-scoped team event types. Use get_me to obtain your organizationId."), - teamId: z.number().int().optional().describe("Team ID for org-scoped team event types. Required together with orgId."), + orgId: z + .number() + .int() + .optional() + .describe( + "Organization ID for org-scoped team event types. Use get_me to obtain your organizationId." + ), + teamId: z + .number() + .int() + .optional() + .describe("Team ID for org-scoped team event types. Required together with orgId."), }; export async function getEventTypeSettings(params: { @@ -478,8 +488,22 @@ export async function getEventTypeSettings(params: { teamId?: number; }) { try { + const hasOrgId = params.orgId !== undefined; + const hasTeamId = params.teamId !== undefined; + if (hasOrgId !== hasTeamId) { + return { + content: [ + { + type: "text" as const, + text: "orgId and teamId must be provided together to look up an org-scoped team event type.", + }, + ], + isError: true as const, + }; + } + const path = - params.orgId !== undefined && params.teamId !== undefined + hasOrgId && hasTeamId ? `organizations/${params.orgId}/teams/${params.teamId}/event-types/${params.eventTypeId}` : `event-types/${params.eventTypeId}`;