Skip to content
Draft
4 changes: 2 additions & 2 deletions apps/mcp-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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, ORG_ROUTING_FORM_READ, ORG_ATTRIBUTES_READ, and ORG_ATTRIBUTES_WRITE.
# 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 ORG_ATTRIBUTES_READ ORG_ATTRIBUTES_WRITE"
# Defaults include core tool scopes plus ORG_BOOKING_READ, TEAM_BOOKING_READ, TEAM_EVENT_TYPE_READ, ORG_MEMBERSHIP_READ, ORG_MEMBERSHIP_WRITE, ORG_ROUTING_FORM_READ, ORG_ATTRIBUTES_READ, and ORG_ATTRIBUTES_WRITE.
# 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 ORG_ATTRIBUTES_READ ORG_ATTRIBUTES_WRITE"

# Public URL of this MCP server (used for OAuth redirect URIs)
# MCP_SERVER_URL=https://your-host.com
Expand Down
5 changes: 3 additions & 2 deletions apps/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ORG_ATTRIBUTES_READ ORG_ATTRIBUTES_WRITE` | 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 ORG_ATTRIBUTES_READ ORG_ATTRIBUTES_WRITE` | 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 |
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp-server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ORG_ATTRIBUTES_READ ORG_ATTRIBUTES_WRITE"
"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 ORG_ATTRIBUTES_READ ORG_ATTRIBUTES_WRITE"
),
rateLimitWindowMs: z.coerce.number().int().positive().default(60_000),
rateLimitMax: z.coerce.number().int().positive().default(30),
Expand Down
15 changes: 14 additions & 1 deletion apps/mcp-server/src/register-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import {
getEventTypeHistory,
getEventTypeHistorySchema,
getEventTypeSchema,
getEventTypeSettings,
getEventTypeSettingsSchema,
getEventTypes,
getEventTypesSchema,
getSchedulingConfig,
Expand Down Expand Up @@ -215,7 +217,7 @@ export function registerTools(server: McpServer): void {
updateMe
);

// ── Event Types (7) ──
// ── Event Types (8) ──
server.registerTool(
"get_event_types",
{
Expand Down Expand Up @@ -293,6 +295,17 @@ export function registerTools(server: McpServer): void {
},
getEventTypeHistory
);
server.registerTool(
"get_event_type_settings",
{
title: "Get Event Type Settings",
description:
"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,
},
getEventTypeSettings
);

// ── Bookings (10) ──
server.registerTool(
Expand Down
1 change: 1 addition & 0 deletions apps/mcp-server/src/server-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions apps/mcp-server/src/tools/event-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
getEventTypeHistory,
getEventTypeHistorySchema,
getEventTypeSchema,
getEventTypeSettings,
getEventTypeSettingsSchema,
getEventTypes,
getEventTypesSchema,
getSchedulingConfig,
Expand Down Expand Up @@ -69,6 +71,12 @@ describe("event-types schemas", () => {
it("exports deleteEventTypeSchema", () => {
expect(deleteEventTypeSchema.eventTypeId).toBeDefined();
});

it("exports getEventTypeSettingsSchema with required and optional fields", () => {
expect(getEventTypeSettingsSchema.eventTypeId).toBeDefined();
expect(getEventTypeSettingsSchema.orgId).toBeDefined();
expect(getEventTypeSettingsSchema.teamId).toBeDefined();
});
});

describe("getEventTypes", () => {
Expand Down Expand Up @@ -179,6 +187,86 @@ describe("deleteEventType", () => {
});
});

describe("getEventTypeSettings", () => {
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, scheduleId: 456 },
{ userId: 2, name: "Bob", isFixed: true, priority: 1, weight: 50, scheduleId: null },
],
};
mockCalApi.mockResolvedValueOnce(apiResponse);

const result = await getEventTypeSettings({ eventTypeId: 10 });

expect(mockCalApi).toHaveBeenCalledWith("event-types/10");
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toEqual(apiResponse);
});

it("uses org-scoped path when orgId and teamId are provided", async () => {
const apiResponse = {
id: 5,
schedulingType: "roundRobin",
assignAllTeamMembers: true,
hosts: [],
};
mockCalApi.mockResolvedValueOnce(apiResponse);

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);
expect(parsed).toEqual(apiResponse);
});

it("returns whatever the API exposes without transformation", async () => {
const apiResponse = {
id: 7,
schedulingType: "collective",
hosts: [{ userId: 3, name: "Carol", isFixed: true }],
bookingLimitsCount: { day: 2 },
locations: [{ type: "inPerson", address: "123 Main St" }],
};
mockCalApi.mockResolvedValueOnce(apiResponse);

const result = await getEventTypeSettings({ eventTypeId: 7 });

const parsed = JSON.parse(result.content[0].text);
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", {}));

const result = await getEventTypeSettings({ eventTypeId: 999 });

expect(result).toHaveProperty("isError", true);
});
});

describe("getEventTypeHistory schema", () => {
it("exports getEventTypeHistorySchema with eventTypeId, limit and cursor", () => {
expect(getEventTypeHistorySchema.eventTypeId).toBeDefined();
Expand Down
48 changes: 48 additions & 0 deletions apps/mcp-server/src/tools/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,54 @@ 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."),
};

export async function getEventTypeSettings(params: {
eventTypeId: number;
orgId?: number;
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 =
hasOrgId && hasTeamId
? `organizations/${params.orgId}/teams/${params.teamId}/event-types/${params.eventTypeId}`
: `event-types/${params.eventTypeId}`;
Comment thread
joeauyeung marked this conversation as resolved.

const data = await calApi(path);
return ok(data);
} catch (err) {
return handleError("get_event_type_settings", err);
}
}
Comment on lines +485 to +515

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New tool is functionally identical to get_event_type for non-org calls

When called without orgId/teamId, getEventTypeSettings at apps/mcp-server/src/tools/event-types.ts:484 calls calApi("event-types/${params.eventTypeId}") — which is exactly what getEventType at apps/mcp-server/src/tools/event-types.ts:56 does. The test at apps/mcp-server/src/tools/event-types.test.ts:208 confirms this overlap. The only differentiation is the org-scoped path when both orgId and teamId are provided. It may be worth clarifying in the tool description that this tool's primary value-add is the org-scoped lookup, or consider whether the org-scoping should be added to the existing get_event_type tool instead of creating a new one.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


export const deleteEventTypeSchema = {
eventTypeId: z
.number()
Expand Down
Loading