import { getGoogleAPIs } from '../auth/google-auth.js';
import {
getLogger,
getConfig,
validateInput,
CalendarListEventsSchema,
CalendarCreateEventSchema,
CalendarUpdateEventSchema,
CalendarDeleteEventSchema,
CalendarFreeBusySchema,
isOperationAllowed,
OperationNotAllowedError,
GoogleAPIError,
withErrorHandling,
type CalendarListEvents,
type CalendarCreateEvent,
type CalendarUpdateEvent,
type CalendarDeleteEvent,
type CalendarFreeBusy,
} from '@company-mcp/core';
const logger = getLogger();
// Types
export interface CalendarEventSummary {
id: string;
summary: string;
start: string;
end: string;
location?: string;
}
export interface CalendarEventsResult {
events: CalendarEventSummary[];
}
export interface CalendarEventPlanned {
summary: string;
description?: string;
location?: string;
start: string;
end: string;
attendees?: string[];
}
export interface CalendarCreateResult {
planned?: CalendarEventPlanned;
created?: {
id: string;
url: string;
};
}
export interface CalendarUpdateResult {
planned?: Partial<CalendarEventPlanned>;
updated?: {
id: string;
url: string;
};
}
export interface CalendarDeleteResult {
planned?: {
eventId: string;
calendarId: string;
};
deleted?: {
eventId: string;
};
}
export interface FreeBusyTimeSlot {
start: string;
end: string;
}
export interface CalendarFreeBusyResult {
calendars: Record<string, {
busy: FreeBusyTimeSlot[];
errors?: string[];
}>;
timeMin: string;
timeMax: string;
}
// Helper to format date/time for display
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatDateTime(dateTime: any): string {
if (!dateTime) return '';
return dateTime.dateTime || dateTime.date || '';
}
// List events
export async function calendarListEvents(
input: unknown
): Promise<CalendarEventsResult> {
return withErrorHandling('calendar_list_events', async () => {
// Check if read is allowed
if (!isOperationAllowed('calendar_read')) {
throw new OperationNotAllowedError('calendar_read');
}
const validation = validateInput(CalendarListEventsSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as CalendarListEvents;
const startTime = Date.now();
const { calendar } = getGoogleAPIs();
const response = await calendar.events.list({
calendarId: params.calendarId,
timeMin: params.timeMinISO,
timeMax: params.timeMaxISO,
q: params.q,
maxResults: params.maxResults,
singleEvents: true,
orderBy: 'startTime',
});
const events: CalendarEventSummary[] = (response.data.items || []).map(
(event) => ({
id: event.id!,
summary: event.summary || '(No title)',
start: formatDateTime(event.start),
end: formatDateTime(event.end),
location: event.location || undefined,
})
);
logger.audit('calendar_list_events', 'list', {
args: {
calendarId: params.calendarId,
timeMinISO: params.timeMinISO,
timeMaxISO: params.timeMaxISO,
q: params.q,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { events };
});
}
// Create event
export async function calendarCreateEvent(
input: unknown
): Promise<CalendarCreateResult> {
return withErrorHandling('calendar_create_event', async () => {
const validation = validateInput(CalendarCreateEventSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as CalendarCreateEvent;
const config = getConfig();
// Use dry-run default from config if not specified
const dryRun = params.dryRun ?? config.features.calendar_dry_run_default;
const startTime = Date.now();
const event = params.event;
const plannedEvent: CalendarEventPlanned = {
summary: event.summary,
description: event.description,
location: event.location,
start: event.startISO,
end: event.endISO,
attendees: event.attendeesEmails,
};
// If dry-run, just return the planned event
if (dryRun) {
logger.audit('calendar_create_event', 'plan', {
args: {
calendarId: params.calendarId,
summary: event.summary,
dryRun: true,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { planned: plannedEvent };
}
// Check if write is allowed for actual creation
if (!isOperationAllowed('calendar_write')) {
throw new OperationNotAllowedError('calendar_write');
}
const { calendar } = getGoogleAPIs();
const requestBody: {
summary: string;
description?: string;
location?: string;
start: { dateTime: string };
end: { dateTime: string };
attendees?: Array<{ email: string }>;
} = {
summary: event.summary,
description: event.description,
location: event.location,
start: { dateTime: event.startISO },
end: { dateTime: event.endISO },
};
if (event.attendeesEmails?.length) {
requestBody.attendees = event.attendeesEmails.map((email) => ({
email,
}));
}
const response = await calendar.events.insert({
calendarId: params.calendarId,
requestBody,
});
logger.audit('calendar_create_event', 'create', {
args: {
calendarId: params.calendarId,
summary: event.summary,
dryRun: false,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
created: {
id: response.data.id!,
url: response.data.htmlLink!,
},
};
});
}
// Update event
export async function calendarUpdateEvent(
input: unknown
): Promise<CalendarUpdateResult> {
return withErrorHandling('calendar_update_event', async () => {
const validation = validateInput(CalendarUpdateEventSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as CalendarUpdateEvent;
const config = getConfig();
// Use dry-run default from config if not specified
const dryRun = params.dryRun ?? config.features.calendar_dry_run_default;
const startTime = Date.now();
const patch = params.patch;
// Build planned changes
const plannedChanges: Partial<CalendarEventPlanned> = {};
if (patch.summary) plannedChanges.summary = patch.summary;
if (patch.description) plannedChanges.description = patch.description;
if (patch.location) plannedChanges.location = patch.location;
if (patch.startISO) plannedChanges.start = patch.startISO;
if (patch.endISO) plannedChanges.end = patch.endISO;
if (patch.attendeesEmails) plannedChanges.attendees = patch.attendeesEmails;
// If dry-run, just return the planned changes
if (dryRun) {
logger.audit('calendar_update_event', 'plan', {
args: {
calendarId: params.calendarId,
eventId: params.eventId,
dryRun: true,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { planned: plannedChanges };
}
// Check if write is allowed for actual update
if (!isOperationAllowed('calendar_write')) {
throw new OperationNotAllowedError('calendar_write');
}
const { calendar } = getGoogleAPIs();
// Verify event exists before patching
await calendar.events.get({
calendarId: params.calendarId,
eventId: params.eventId,
});
// Build update body
const updateBody: {
summary?: string;
description?: string;
location?: string;
start?: { dateTime: string };
end?: { dateTime: string };
attendees?: Array<{ email: string }>;
} = {};
if (patch.summary) updateBody.summary = patch.summary;
if (patch.description) updateBody.description = patch.description;
if (patch.location) updateBody.location = patch.location;
if (patch.startISO) updateBody.start = { dateTime: patch.startISO };
if (patch.endISO) updateBody.end = { dateTime: patch.endISO };
if (patch.attendeesEmails) {
updateBody.attendees = patch.attendeesEmails.map((email) => ({ email }));
}
const response = await calendar.events.patch({
calendarId: params.calendarId,
eventId: params.eventId,
requestBody: updateBody,
});
logger.audit('calendar_update_event', 'update', {
args: {
calendarId: params.calendarId,
eventId: params.eventId,
dryRun: false,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
updated: {
id: response.data.id!,
url: response.data.htmlLink!,
},
};
});
}
// Delete event
export async function calendarDeleteEvent(
input: unknown
): Promise<CalendarDeleteResult> {
return withErrorHandling('calendar_delete_event', async () => {
const validation = validateInput(CalendarDeleteEventSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as CalendarDeleteEvent;
const config = getConfig();
// Use dry-run default from config if not specified
const dryRun = params.dryRun ?? config.features.calendar_dry_run_default;
const startTime = Date.now();
// If dry-run, just return the planned deletion
if (dryRun) {
logger.audit('calendar_delete_event', 'plan', {
args: {
calendarId: params.calendarId,
eventId: params.eventId,
dryRun: true,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
planned: {
eventId: params.eventId,
calendarId: params.calendarId,
},
};
}
// Check if write is allowed for actual deletion
if (!isOperationAllowed('calendar_write')) {
throw new OperationNotAllowedError('calendar_write');
}
const { calendar } = getGoogleAPIs();
// Verify event exists before deleting
await calendar.events.get({
calendarId: params.calendarId,
eventId: params.eventId,
});
await calendar.events.delete({
calendarId: params.calendarId,
eventId: params.eventId,
});
logger.audit('calendar_delete_event', 'delete', {
args: {
calendarId: params.calendarId,
eventId: params.eventId,
dryRun: false,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
deleted: {
eventId: params.eventId,
},
};
});
}
// Get free/busy information
export async function calendarGetFreeBusy(
input: unknown
): Promise<CalendarFreeBusyResult> {
return withErrorHandling('calendar_get_free_busy', async () => {
// Check if read is allowed
if (!isOperationAllowed('calendar_read')) {
throw new OperationNotAllowedError('calendar_read');
}
const validation = validateInput(CalendarFreeBusySchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as CalendarFreeBusy;
const startTime = Date.now();
const { calendar } = getGoogleAPIs();
const response = await calendar.freebusy.query({
requestBody: {
timeMin: params.timeMinISO,
timeMax: params.timeMaxISO,
items: params.calendarIds.map((id) => ({ id })),
},
});
const calendars: Record<string, { busy: FreeBusyTimeSlot[]; errors?: string[] }> = {};
for (const [calendarId, calendarData] of Object.entries(response.data.calendars || {})) {
calendars[calendarId] = {
busy: (calendarData.busy || []).map((slot) => ({
start: slot.start || '',
end: slot.end || '',
})),
};
if (calendarData.errors?.length) {
calendars[calendarId].errors = calendarData.errors.map(
(err) => err.reason || 'Unknown error'
);
}
}
logger.audit('calendar_get_free_busy', 'query', {
args: {
timeMinISO: params.timeMinISO,
timeMaxISO: params.timeMaxISO,
calendarIds: params.calendarIds,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
calendars,
timeMin: params.timeMinISO,
timeMax: params.timeMaxISO,
};
});
}
// Tool definitions for MCP
export const calendarTools = [
{
name: 'calendar_list_events',
description:
'List events from a Google Calendar. Requires CALENDAR_READ_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
calendarId: {
type: 'string',
description: 'Calendar ID (default: "primary" for user\'s main calendar)',
default: 'primary',
},
timeMinISO: {
type: 'string',
description: 'Start of time range in ISO 8601 format',
},
timeMaxISO: {
type: 'string',
description: 'End of time range in ISO 8601 format',
},
q: {
type: 'string',
description: 'Free text search query',
},
maxResults: {
type: 'number',
description: 'Maximum number of results (default: 10)',
default: 10,
},
},
required: [],
},
},
{
name: 'calendar_create_event',
description:
'Create a calendar event. By default runs in dry-run mode (returns plan only). Set dryRun=false to actually create. Requires CALENDAR_WRITE_ENABLED=true for actual creation.',
inputSchema: {
type: 'object',
properties: {
calendarId: {
type: 'string',
description: 'Calendar ID (default: "primary")',
default: 'primary',
},
event: {
type: 'object',
properties: {
summary: {
type: 'string',
description: 'Event title',
},
description: {
type: 'string',
description: 'Event description',
},
location: {
type: 'string',
description: 'Event location',
},
startISO: {
type: 'string',
description: 'Start time in ISO 8601 format',
},
endISO: {
type: 'string',
description: 'End time in ISO 8601 format',
},
attendeesEmails: {
type: 'array',
items: { type: 'string' },
description: 'Email addresses of attendees',
},
},
required: ['summary', 'startISO', 'endISO'],
},
dryRun: {
type: 'boolean',
description:
'If true, only returns planned event without creating. Default from CALENDAR_DRY_RUN_DEFAULT.',
},
},
required: ['event'],
},
},
{
name: 'calendar_update_event',
description:
'Update an existing calendar event. By default runs in dry-run mode. Set dryRun=false to actually update. Requires CALENDAR_WRITE_ENABLED=true for actual update.',
inputSchema: {
type: 'object',
properties: {
calendarId: {
type: 'string',
description: 'Calendar ID (default: "primary")',
default: 'primary',
},
eventId: {
type: 'string',
description: 'ID of the event to update',
},
patch: {
type: 'object',
properties: {
summary: {
type: 'string',
description: 'New event title',
},
description: {
type: 'string',
description: 'New event description',
},
location: {
type: 'string',
description: 'New event location',
},
startISO: {
type: 'string',
description: 'New start time in ISO 8601 format',
},
endISO: {
type: 'string',
description: 'New end time in ISO 8601 format',
},
attendeesEmails: {
type: 'array',
items: { type: 'string' },
description: 'New list of attendee email addresses',
},
},
},
dryRun: {
type: 'boolean',
description:
'If true, only returns planned changes without updating. Default from CALENDAR_DRY_RUN_DEFAULT.',
},
},
required: ['eventId', 'patch'],
},
},
{
name: 'calendar_delete_event',
description:
'Delete a calendar event. By default runs in dry-run mode. Set dryRun=false to actually delete. Requires CALENDAR_WRITE_ENABLED=true for actual deletion.',
inputSchema: {
type: 'object',
properties: {
calendarId: {
type: 'string',
description: 'Calendar ID (default: "primary")',
default: 'primary',
},
eventId: {
type: 'string',
description: 'ID of the event to delete',
},
dryRun: {
type: 'boolean',
description:
'If true, only returns planned deletion without actually deleting. Default from CALENDAR_DRY_RUN_DEFAULT.',
},
},
required: ['eventId'],
},
},
{
name: 'calendar_get_free_busy',
description:
'Get free/busy information for one or more calendars. Requires CALENDAR_READ_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
timeMinISO: {
type: 'string',
description: 'Start of time range in ISO 8601 format',
},
timeMaxISO: {
type: 'string',
description: 'End of time range in ISO 8601 format',
},
calendarIds: {
type: 'array',
items: { type: 'string' },
description: 'List of calendar IDs to check (default: ["primary"])',
default: ['primary'],
},
},
required: ['timeMinISO', 'timeMaxISO'],
},
},
];
// Tool handlers
export const calendarHandlers: Record<
string,
(input: unknown) => Promise<unknown>
> = {
calendar_list_events: calendarListEvents,
calendar_create_event: calendarCreateEvent,
calendar_update_event: calendarUpdateEvent,
calendar_delete_event: calendarDeleteEvent,
calendar_get_free_busy: calendarGetFreeBusy,
};