Skip to content
Open
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
29 changes: 14 additions & 15 deletions apps/mcp-server/src/tools/availability.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import { calApi } from "../utils/api-client.js";
import { handleError, ok } from "../utils/tool-helpers.js";
import { buildParams } from "../utils/params-builder.js";

export const getAvailabilitySchema = {
start: z.string().describe("Range start in UTC, ISO 8601 (e.g. '2024-08-13' or '2024-08-13T09:00:00Z')"),
Expand Down Expand Up @@ -35,24 +36,22 @@ export async function getAvailability(params: {
bookingUidToReschedule?: string;
}) {
try {
const queryParams: Record<string, string | number | string[] | undefined> = {
const queryParams = buildParams({
start: params.start,
end: params.end,
};
if (params.timeZone) queryParams.timeZone = params.timeZone;
if (params.eventTypeId !== undefined) queryParams.eventTypeId = params.eventTypeId;
if (params.eventTypeSlug) queryParams.eventTypeSlug = params.eventTypeSlug;
if (params.username) queryParams.username = params.username;
if (params.teamSlug) queryParams.teamSlug = params.teamSlug;
if (params.organizationSlug) queryParams.organizationSlug = params.organizationSlug;
if (params.usernames !== undefined) {
queryParams.usernames = Array.isArray(params.usernames)
timeZone: params.timeZone,
eventTypeId: params.eventTypeId,
eventTypeSlug: params.eventTypeSlug,
username: params.username,
teamSlug: params.teamSlug,
organizationSlug: params.organizationSlug,
usernames: Array.isArray(params.usernames)
? params.usernames.join(",")
: params.usernames;
}
if (params.duration !== undefined) queryParams.duration = params.duration;
if (params.format) queryParams.format = params.format;
if (params.bookingUidToReschedule) queryParams.bookingUidToReschedule = params.bookingUidToReschedule;
: params.usernames,
duration: params.duration,
format: params.format,
bookingUidToReschedule: params.bookingUidToReschedule,
});
Comment thread
samadrehman marked this conversation as resolved.

const data = await calApi("slots", { params: queryParams });
return ok(data);
Expand Down
78 changes: 34 additions & 44 deletions apps/mcp-server/src/tools/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { calApi } from "../utils/api-client.js";
import { sanitizePathSegment } from "../utils/path-sanitizer.js";
import { handleError, ok } from "../utils/tool-helpers.js";
import { buildParams, buildBody } from "../utils/params-builder.js";

export const getBookingsSchema = {
status: z.string().optional().describe("Comma-separated statuses: upcoming, recurring, past, cancelled, unconfirmed"),
Expand Down Expand Up @@ -49,27 +50,7 @@ export async function getBookings(params: {
skip?: number;
}) {
try {
const qp: Record<string, string | number | undefined> = {};
if (params.status !== undefined) qp.status = params.status;
if (params.attendeeEmail !== undefined) qp.attendeeEmail = params.attendeeEmail;
if (params.attendeeName !== undefined) qp.attendeeName = params.attendeeName;
if (params.eventTypeId !== undefined) qp.eventTypeId = params.eventTypeId;
if (params.eventTypeIds !== undefined) qp.eventTypeIds = params.eventTypeIds;
if (params.teamId !== undefined) qp.teamId = params.teamId;
if (params.teamsIds !== undefined) qp.teamsIds = params.teamsIds;
if (params.afterStart !== undefined) qp.afterStart = params.afterStart;
if (params.beforeEnd !== undefined) qp.beforeEnd = params.beforeEnd;
if (params.afterCreatedAt !== undefined) qp.afterCreatedAt = params.afterCreatedAt;
if (params.beforeCreatedAt !== undefined) qp.beforeCreatedAt = params.beforeCreatedAt;
if (params.afterUpdatedAt !== undefined) qp.afterUpdatedAt = params.afterUpdatedAt;
if (params.beforeUpdatedAt !== undefined) qp.beforeUpdatedAt = params.beforeUpdatedAt;
if (params.bookingUid !== undefined) qp.bookingUid = params.bookingUid;
if (params.sortStart !== undefined) qp.sortStart = params.sortStart;
if (params.sortEnd !== undefined) qp.sortEnd = params.sortEnd;
if (params.sortCreated !== undefined) qp.sortCreated = params.sortCreated;
if (params.sortUpdatedAt !== undefined) qp.sortUpdatedAt = params.sortUpdatedAt;
if (params.take !== undefined) qp.take = params.take;
if (params.skip !== undefined) qp.skip = params.skip;
const qp = buildParams(params);
const data = await calApi("bookings", { params: qp });
return ok(data);
} catch (err) {
Expand Down Expand Up @@ -133,22 +114,26 @@ export async function createBooking(params: {
allowBookingOutOfBounds?: boolean;
}) {
try {
const body: Record<string, unknown> = {
start: params.start,
attendee: params.attendee,
};
if (params.eventTypeId !== undefined) body.eventTypeId = params.eventTypeId;
if (params.eventTypeSlug !== undefined) body.eventTypeSlug = params.eventTypeSlug;
if (params.username !== undefined) body.username = params.username;
if (params.teamSlug !== undefined) body.teamSlug = params.teamSlug;
if (params.organizationSlug !== undefined) body.organizationSlug = params.organizationSlug;
if (params.guests !== undefined) body.guests = params.guests;
if (params.lengthInMinutes !== undefined) body.lengthInMinutes = params.lengthInMinutes;
if (params.bookingFieldsResponses !== undefined) body.bookingFieldsResponses = params.bookingFieldsResponses;
if (params.metadata !== undefined) body.metadata = params.metadata;
if (params.location !== undefined) body.location = params.location;
if (params.allowConflicts !== undefined) body.allowConflicts = params.allowConflicts;
if (params.allowBookingOutOfBounds !== undefined) body.allowBookingOutOfBounds = params.allowBookingOutOfBounds;
const body = buildBody(
{
start: params.start,
attendee: params.attendee,
},
{
eventTypeId: params.eventTypeId,
eventTypeSlug: params.eventTypeSlug,
username: params.username,
teamSlug: params.teamSlug,
organizationSlug: params.organizationSlug,
guests: params.guests,
lengthInMinutes: params.lengthInMinutes,
bookingFieldsResponses: params.bookingFieldsResponses,
metadata: params.metadata,
location: params.location,
allowConflicts: params.allowConflicts,
allowBookingOutOfBounds: params.allowBookingOutOfBounds,
}
);
const data = await calApi("bookings", { method: "POST", body });
return ok(data);
} catch (err) {
Expand All @@ -170,9 +155,13 @@ export async function rescheduleBooking(params: {
rescheduledBy?: string;
}) {
try {
const body: Record<string, unknown> = { start: params.start };
if (params.reschedulingReason !== undefined) body.reschedulingReason = params.reschedulingReason;
if (params.rescheduledBy !== undefined) body.rescheduledBy = params.rescheduledBy;
const body = buildBody(
{ start: params.start },
{
reschedulingReason: params.reschedulingReason,
rescheduledBy: params.rescheduledBy,
}
);
const uid = sanitizePathSegment(params.bookingUid);
const data = await calApi(`bookings/${uid}/reschedule`, { method: "POST", body });
return ok(data);
Expand All @@ -190,10 +179,11 @@ export const cancelBookingSchema = {

export async function cancelBooking(params: { bookingUid: string; cancellationReason?: string; cancelSubsequentBookings?: boolean; seatUid?: string }) {
try {
const body: Record<string, unknown> = {};
if (params.cancellationReason !== undefined) body.cancellationReason = params.cancellationReason;
if (params.cancelSubsequentBookings !== undefined) body.cancelSubsequentBookings = params.cancelSubsequentBookings;
if (params.seatUid !== undefined) body.seatUid = params.seatUid;
const body = buildBody({}, {
cancellationReason: params.cancellationReason,
cancelSubsequentBookings: params.cancelSubsequentBookings,
seatUid: params.seatUid,
});
const uid = sanitizePathSegment(params.bookingUid);
const data = await calApi(`bookings/${uid}/cancel`, { method: "POST", body });
return ok(data);
Expand Down
32 changes: 19 additions & 13 deletions apps/mcp-server/src/tools/schedules.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import { calApi } from "../utils/api-client.js";
import { handleError, ok } from "../utils/tool-helpers.js";
import { buildBody } from "../utils/params-builder.js";

const availabilitySlotSchema = z.object({
days: z
Expand Down Expand Up @@ -62,13 +63,17 @@ export async function createSchedule(params: {
overrides?: { date: string; startTime: string; endTime: string }[];
}) {
try {
const body: Record<string, unknown> = {
name: params.name,
timeZone: params.timeZone,
isDefault: params.isDefault,
};
if (params.availability !== undefined) body.availability = params.availability;
if (params.overrides !== undefined) body.overrides = params.overrides;
const body = buildBody(
{
name: params.name,
timeZone: params.timeZone,
isDefault: params.isDefault,
},
{
availability: params.availability,
overrides: params.overrides,
}
);
const data = await calApi("schedules", {
method: "POST",
body,
Expand Down Expand Up @@ -100,12 +105,13 @@ export async function updateSchedule(params: {
overrides?: { date: string; startTime: string; endTime: string }[];
}) {
try {
const body: Record<string, unknown> = {};
if (params.name !== undefined) body.name = params.name;
if (params.timeZone !== undefined) body.timeZone = params.timeZone;
if (params.isDefault !== undefined) body.isDefault = params.isDefault;
if (params.availability !== undefined) body.availability = params.availability;
if (params.overrides !== undefined) body.overrides = params.overrides;
const body = buildBody({}, {
name: params.name,
timeZone: params.timeZone,
isDefault: params.isDefault,
availability: params.availability,
overrides: params.overrides,
});
const data = await calApi(`schedules/${params.scheduleId}`, { method: "PATCH", body });
return ok(data);
} catch (err) {
Expand Down
17 changes: 14 additions & 3 deletions apps/mcp-server/src/utils/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Add jitter to exponential backoff to prevent thundering herd.
* Formula: baseDelay * 2^attempt * (1 + random(0-1))
*/
function calculateBackoffWithJitter(baseDelayMs: number, attempt: number): number {
const exponentialDelay = baseDelayMs * (2 ** (attempt - 1));
const jitter = Math.random(); // 0 to 1
return exponentialDelay * (1 + jitter);
}
Comment thread
samadrehman marked this conversation as resolved.

export async function calApi<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, params, apiVersionOverride } = options;
const url = buildUrl(path, params);
Expand All @@ -130,9 +140,10 @@ export async function calApi<T = unknown>(path: string, options: RequestOptions
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
try {
if (attempt > 0) {
const delay = baseDelayMs * 2 ** (attempt - 1);
logger.warn("Retrying Cal.com API request", { path: normalizedPath, attempt, delay });
await sleep(delay);
const delay = calculateBackoffWithJitter(baseDelayMs, attempt);
const roundedDelay = Math.round(delay);
logger.warn("Retrying Cal.com API request", { path: normalizedPath, attempt, delay: roundedDelay });
await sleep(roundedDelay);
}

const fetchOptions: RequestInit = {
Expand Down
45 changes: 45 additions & 0 deletions apps/mcp-server/src/utils/params-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Build query parameters efficiently by filtering undefined values.
* Replaces verbose if-checks throughout the codebase.
*
* @param params Object with optional parameters
* @returns Object with only defined parameters
* @example
* buildParams({ status: "upcoming", skip: undefined }) // { status: "upcoming" }
*/
export function buildParams(
params: Record<string, unknown>
): Record<string, string | number | boolean | string[] | undefined> {
const result: Record<string, string | number | boolean | string[] | undefined> = {};

for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
result[key] = value as string | number | boolean | string[];
}
}
Comment thread
samadrehman marked this conversation as resolved.

return result;
}

/**
* Build request body by filtering undefined values.
* More performant than multiple if-checks.
*
* @param required Required fields to always include
* @param optional Optional fields to conditionally include
* @returns Combined body object
*/
Comment thread
samadrehman marked this conversation as resolved.
export function buildBody(
required: Record<string, unknown>,
optional: Record<string, unknown>
): Record<string, unknown> {
const body: Record<string, unknown> = { ...required };

for (const [key, value] of Object.entries(optional)) {
if (value !== undefined) {
body[key] = value;
}
}

return body;
}
Loading
Loading