diff --git a/apps/mcp-server/src/register-tools.ts b/apps/mcp-server/src/register-tools.ts index 75314201..2030f97a 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: "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, }, diff --git a/apps/mcp-server/src/tools/bookings.test.ts b/apps/mcp-server/src/tools/bookings.test.ts index 01c1e23a..790de8c0 100644 --- a/apps/mcp-server/src/tools/bookings.test.ts +++ b/apps/mcp-server/src/tools/bookings.test.ts @@ -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", () => { @@ -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", @@ -131,7 +137,7 @@ describe("getBookings", () => { teamId: 5, }); - const [, opts] = mockCalApi.mock.calls[0]; + const [, opts] = mockCalApi.mock.calls[1]; const params = (opts as { params: Record }).params; expect(params).toHaveProperty("afterStart", "2024-08-01"); expect(params).toHaveProperty("beforeEnd", "2024-08-31"); @@ -139,6 +145,67 @@ describe("getBookings", () => { 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", {})); diff --git a/apps/mcp-server/src/tools/bookings.ts b/apps/mcp-server/src/tools/bookings.ts index 003c8085..e2f6ecd8 100644 --- a/apps/mcp-server/src/tools/bookings.ts +++ b/apps/mcp-server/src/tools/bookings.ts @@ -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"; @@ -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: { @@ -47,6 +54,7 @@ export async function getBookings(params: { sortUpdatedAt?: "asc" | "desc"; take?: number; skip?: number; + scope?: "mine" | "all"; }) { try { const qp: Record = {}; @@ -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); } diff --git a/apps/mcp-server/src/utils/booking-participation.ts b/apps/mcp-server/src/utils/booking-participation.ts new file mode 100644 index 00000000..98e2fc4c --- /dev/null +++ b/apps/mcp-server/src/utils/booking-participation.ts @@ -0,0 +1,90 @@ +type CurrentUser = { + id?: string | number; + email?: string; +}; + +function isRecord(value: unknown): value is Record { + 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; +}