Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/mcp-server/src/register-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export function registerTools(server: McpServer): void {
{
title: "List Bookings",
description:
"List bookings with pagination (default 100, max 250 per page — use take/skip for more). Supports filtering by status (upcoming, recurring, past, cancelled, unconfirmed), attendee email/name, event type, team, date ranges (afterStart, beforeEnd), and sorting (sortStart, sortEnd, sortCreated).",
"List bookings with pagination (default 100, max 250 per page — use take/skip for more). By default returns only bookings the authenticated user participates in (organizer, host, or attendee); pass scope='all' to include every booking the API key/token can see (useful for admins viewing org-wide bookings). Supports filtering by status (upcoming, recurring, past, cancelled, unconfirmed), attendee email/name, event type, team, date ranges (afterStart, beforeEnd), and sorting (sortStart, sortEnd, sortCreated).",
inputSchema: getBookingsSchema,
annotations: READ_ONLY,
},
Expand Down
77 changes: 72 additions & 5 deletions apps/mcp-server/src/tools/bookings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe("bookings schemas", () => {
expect(getBookingsSchema.sortStart).toBeDefined();
expect(getBookingsSchema.sortCreated).toBeDefined();
expect(getBookingsSchema.bookingUid).toBeDefined();
expect(getBookingsSchema.scope).toBeDefined();
});

it("exports getBookingSchema with bookingUid", () => {
Expand Down Expand Up @@ -108,21 +109,26 @@ describe("bookings schemas", () => {

describe("getBookings", () => {
it("returns formatted data on success", async () => {
mockCalApi.mockResolvedValueOnce({ bookings: [{ uid: "abc" }] });
mockCalApi
.mockResolvedValueOnce({ id: 1, email: "test@example.com" })
.mockResolvedValueOnce({
bookings: [{ uid: "abc", hosts: [{ id: 1, email: "test@example.com" }] }],
});

const result = await getBookings({ status: "upcoming" });

expect(mockCalApi).toHaveBeenCalledWith("bookings", {
expect(mockCalApi).toHaveBeenNthCalledWith(1, "me");
expect(mockCalApi).toHaveBeenNthCalledWith(2, "bookings", {
params: expect.objectContaining({ status: "upcoming" }),
});
expect(result.content[0].type).toBe("text");
expect(JSON.parse(result.content[0].text)).toEqual({
bookings: [{ uid: "abc" }],
bookings: [{ uid: "abc", hosts: [{ id: 1, email: "test@example.com" }] }],
});
});

it("passes date range and sort params", async () => {
mockCalApi.mockResolvedValueOnce({ bookings: [] });
mockCalApi.mockResolvedValueOnce({ id: 1, email: "test@example.com" }).mockResolvedValueOnce({ bookings: [] });

await getBookings({
afterStart: "2024-08-01",
Expand All @@ -131,14 +137,75 @@ describe("getBookings", () => {
teamId: 5,
});

const [, opts] = mockCalApi.mock.calls[0];
const [, opts] = mockCalApi.mock.calls[1];
const params = (opts as { params: Record<string, unknown> }).params;
expect(params).toHaveProperty("afterStart", "2024-08-01");
expect(params).toHaveProperty("beforeEnd", "2024-08-31");
expect(params).toHaveProperty("sortStart", "asc");
expect(params).toHaveProperty("teamId", 5);
});

it("filters admin booking responses to bookings involving the current user", async () => {
mockCalApi
.mockResolvedValueOnce({ data: { id: 1, email: "admin@example.com" } })
.mockResolvedValueOnce({
bookings: [
{ uid: "host", hosts: [{ id: 1, email: "admin@example.com" }], attendees: [] },
{ uid: "attendee", hosts: [], attendees: [{ email: "ADMIN@example.com" }] },
{ uid: "organizer", user: { id: 1, email: "admin@example.com" }, hosts: [], attendees: [] },
{ uid: "other", hosts: [{ id: 2, email: "other@example.com" }], attendees: [] },
],
});

const result = await getBookings({});

expect(JSON.parse(result.content[0].text)).toEqual({
bookings: [
{ uid: "host", hosts: [{ id: 1, email: "admin@example.com" }], attendees: [] },
{ uid: "attendee", hosts: [], attendees: [{ email: "ADMIN@example.com" }] },
{ uid: "organizer", user: { id: 1, email: "admin@example.com" }, hosts: [], attendees: [] },
],
});
});

it("scope='all' skips the me lookup and returns the unfiltered payload", async () => {
mockCalApi.mockResolvedValueOnce({
bookings: [
{ uid: "host", hosts: [{ id: 1, email: "admin@example.com" }], attendees: [] },
{ uid: "other", hosts: [{ id: 2, email: "other@example.com" }], attendees: [] },
],
});

const result = await getBookings({ scope: "all" });

expect(mockCalApi).toHaveBeenCalledTimes(1);
expect(mockCalApi).toHaveBeenCalledWith("bookings", { params: expect.any(Object) });
expect(JSON.parse(result.content[0].text)).toEqual({
bookings: [
{ uid: "host", hosts: [{ id: 1, email: "admin@example.com" }], attendees: [] },
{ uid: "other", hosts: [{ id: 2, email: "other@example.com" }], attendees: [] },
],
});
});

it("scope='mine' is the default and filters to the current user", async () => {
mockCalApi
.mockResolvedValueOnce({ id: 1, email: "admin@example.com" })
.mockResolvedValueOnce({
bookings: [
{ uid: "host", hosts: [{ id: 1, email: "admin@example.com" }], attendees: [] },
{ uid: "other", hosts: [{ id: 2, email: "other@example.com" }], attendees: [] },
],
});

const result = await getBookings({ scope: "mine" });

expect(mockCalApi).toHaveBeenCalledTimes(2);
expect(JSON.parse(result.content[0].text)).toEqual({
bookings: [{ uid: "host", hosts: [{ id: 1, email: "admin@example.com" }], attendees: [] }],
});
});

it("returns error response on CalApiError", async () => {
mockCalApi.mockRejectedValueOnce(new CalApiError(403, "Forbidden", {}));

Expand Down
17 changes: 15 additions & 2 deletions apps/mcp-server/src/tools/bookings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";
import { calApi } from "../utils/api-client.js";
import { extractCurrentUser, filterBookingsForCurrentUser } from "../utils/booking-participation.js";
import { sanitizePathSegment } from "../utils/path-sanitizer.js";
import { handleError, ok } from "../utils/tool-helpers.js";

Expand All @@ -24,6 +25,12 @@ export const getBookingsSchema = {
sortUpdatedAt: z.enum(["asc", "desc"]).optional().describe("Sort by updated time"),
take: z.number().int().optional().describe("Max results to return (default 100, max 250)"),
skip: z.number().int().optional().describe("Results to skip (offset)"),
scope: z
.enum(["mine", "all"])
.optional()
.describe(
"Whose bookings to return. 'mine' (default) returns only bookings where the authenticated user is organizer, host, or attendee. 'all' returns every booking the API key/token can see — useful for admins who want the org-wide view. Pagination (take/skip) operates on the unfiltered API response, so 'mine' may return fewer than `take` items per page.",
),
};

export async function getBookings(params: {
Expand All @@ -47,6 +54,7 @@ export async function getBookings(params: {
sortUpdatedAt?: "asc" | "desc";
take?: number;
skip?: number;
scope?: "mine" | "all";
}) {
try {
const qp: Record<string, string | number | undefined> = {};
Expand All @@ -70,8 +78,13 @@ export async function getBookings(params: {
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 data = await calApi("bookings", { params: qp });
return ok(data);
if (params.scope === "all") {
const data = await calApi("bookings", { params: qp });
return ok(data);
}
const [meRaw, data] = await Promise.all([calApi("me"), calApi("bookings", { params: qp })]);
const currentUser = extractCurrentUser(meRaw);
return ok(filterBookingsForCurrentUser(data, currentUser));
} catch (err) {
return handleError("get_bookings", err);
}
Expand Down
90 changes: 90 additions & 0 deletions apps/mcp-server/src/utils/booking-participation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
type CurrentUser = {
id?: string | number;
email?: string;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

export function extractCurrentUser(data: unknown): CurrentUser | undefined {
const user = isRecord(data) && isRecord(data.data) ? data.data : data;
if (!isRecord(user)) return undefined;

const id = typeof user.id === "string" || typeof user.id === "number" ? user.id : undefined;
const email = typeof user.email === "string" ? user.email : undefined;

return id !== undefined || email !== undefined ? { id, email } : undefined;
}

function idEq(a: unknown, b: unknown): boolean {
return a !== undefined && b !== undefined && String(a) === String(b);
}

function emailEq(a: unknown, b: string | undefined): boolean {
return typeof a === "string" && b !== undefined && a.toLowerCase() === b.toLowerCase();
}

function isPersonMatch(person: unknown, currentUser: CurrentUser): boolean {
if (!isRecord(person)) return false;

return idEq(person.id, currentUser.id) || emailEq(person.email, currentUser.email);
}

function isParticipatingBooking(booking: unknown, currentUser: CurrentUser): boolean {
if (!isRecord(booking)) return false;

const isOrganizer =
isPersonMatch(booking.user, currentUser) || isPersonMatch(booking.organizer, currentUser);
const isHost =
Array.isArray(booking.hosts) && booking.hosts.some((host) => isPersonMatch(host, currentUser));
const isAttendee =
Array.isArray(booking.attendees) &&
booking.attendees.some((attendee) => isPersonMatch(attendee, currentUser));

return isOrganizer || isHost || isAttendee;
}

function filterBookingsArray(bookings: unknown[], currentUser: CurrentUser): unknown[] {
return bookings.filter((booking) => isParticipatingBooking(booking, currentUser));
}

export function filterBookingsForCurrentUser(data: unknown, currentUser: CurrentUser | undefined): unknown {
if (!currentUser) return data;

if (Array.isArray(data)) {
return filterBookingsArray(data, currentUser);
}

if (!isRecord(data)) return data;

if (Array.isArray(data.bookings)) {
return { ...data, bookings: filterBookingsArray(data.bookings, currentUser) };
}

if (Array.isArray(data.items)) {
return { ...data, items: filterBookingsArray(data.items, currentUser) };
}

if (Array.isArray(data.data)) {
return { ...data, data: filterBookingsArray(data.data, currentUser) };
}

if (isRecord(data.data)) {
if (Array.isArray(data.data.bookings)) {
return {
...data,
data: { ...data.data, bookings: filterBookingsArray(data.data.bookings, currentUser) },
};
}

if (Array.isArray(data.data.items)) {
return {
...data,
data: { ...data.data, items: filterBookingsArray(data.data.items, currentUser) },
};
}
}

return data;
}
Loading