google-tools-mcp 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getCalendarClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'get_free',
8
+ description:
9
+ 'Finds available time slots where all specified people are free. ' +
10
+ 'Takes a duration and working hours as boundaries, then returns open slots. ' +
11
+ 'Uses the freebusy API under the hood — works across your org without calendar sharing.',
12
+ parameters: z.object({
13
+ time_min: z
14
+ .string()
15
+ .describe('Start of search range in RFC3339 format (e.g. "2025-03-15T00:00:00Z").'),
16
+ time_max: z
17
+ .string()
18
+ .describe('End of search range in RFC3339 format (e.g. "2025-03-21T23:59:59Z").'),
19
+ duration_minutes: z
20
+ .number()
21
+ .describe('Required slot duration in minutes (e.g. 30, 60).'),
22
+ calendar_ids: z
23
+ .array(z.string())
24
+ .optional()
25
+ .default(['primary'])
26
+ .describe('Calendar IDs or email addresses to check mutual availability. Defaults to ["primary"].'),
27
+ working_hours_start: z
28
+ .string()
29
+ .optional()
30
+ .default('09:00')
31
+ .describe('Start of working hours in HH:MM format (e.g. "09:00"). Defaults to "09:00".'),
32
+ working_hours_end: z
33
+ .string()
34
+ .optional()
35
+ .default('17:00')
36
+ .describe('End of working hours in HH:MM format (e.g. "17:00"). Defaults to "17:00".'),
37
+ timezone: z
38
+ .string()
39
+ .optional()
40
+ .default('UTC')
41
+ .describe('Timezone for working hours (e.g. "America/New_York"). Defaults to "UTC".'),
42
+ max_results: z
43
+ .number()
44
+ .optional()
45
+ .default(10)
46
+ .describe('Maximum number of available slots to return. Defaults to 10.'),
47
+ }),
48
+ execute: async (args, { log }) => {
49
+ const calendar = await getCalendarClient();
50
+ log.info(
51
+ `Finding ${args.duration_minutes}min free slots for ${args.calendar_ids.join(', ')} ` +
52
+ `between ${args.working_hours_start}-${args.working_hours_end} ${args.timezone}`
53
+ );
54
+
55
+ try {
56
+ // 1. Query freebusy for all calendars
57
+ const response = await calendar.freebusy.query({
58
+ requestBody: {
59
+ timeMin: args.time_min,
60
+ timeMax: args.time_max,
61
+ timeZone: args.timezone,
62
+ items: args.calendar_ids.map((id) => ({ id })),
63
+ },
64
+ });
65
+
66
+ const calendars = response.data.calendars || {};
67
+
68
+ // Check for errors
69
+ const errors = [];
70
+ for (const [calId, data] of Object.entries(calendars)) {
71
+ if (data.errors?.length) {
72
+ errors.push(`${calId}: ${data.errors[0].reason}`);
73
+ }
74
+ }
75
+ if (errors.length) {
76
+ throw new UserError(
77
+ `Could not check availability for some calendars:\n${errors.join('\n')}`
78
+ );
79
+ }
80
+
81
+ // 2. Merge all busy blocks
82
+ const allBusy = [];
83
+ for (const data of Object.values(calendars)) {
84
+ for (const block of data.busy || []) {
85
+ allBusy.push({
86
+ start: new Date(block.start).getTime(),
87
+ end: new Date(block.end).getTime(),
88
+ });
89
+ }
90
+ }
91
+
92
+ // Sort and merge overlapping busy blocks
93
+ allBusy.sort((a, b) => a.start - b.start);
94
+ const merged = [];
95
+ for (const block of allBusy) {
96
+ if (merged.length && block.start <= merged[merged.length - 1].end) {
97
+ merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, block.end);
98
+ } else {
99
+ merged.push({ ...block });
100
+ }
101
+ }
102
+
103
+ // 3. Find free slots within working hours
104
+ const rangeStart = new Date(args.time_min);
105
+ const rangeEnd = new Date(args.time_max);
106
+ const durationMs = args.duration_minutes * 60 * 1000;
107
+ const [whStartH, whStartM] = args.working_hours_start.split(':').map(Number);
108
+ const [whEndH, whEndM] = args.working_hours_end.split(':').map(Number);
109
+
110
+ const freeSlots = [];
111
+ const currentDay = new Date(rangeStart);
112
+
113
+ // Iterate day by day
114
+ while (currentDay < rangeEnd && freeSlots.length < args.max_results) {
115
+ const dayStart = getTimeInTimezone(currentDay, whStartH, whStartM, args.timezone);
116
+ const dayEnd = getTimeInTimezone(currentDay, whEndH, whEndM, args.timezone);
117
+
118
+ if (dayEnd > rangeStart.getTime() && dayStart < rangeEnd.getTime()) {
119
+ const effectiveStart = Math.max(dayStart, rangeStart.getTime());
120
+ const effectiveEnd = Math.min(dayEnd, rangeEnd.getTime());
121
+
122
+ // Find free windows in this day
123
+ const dayBusy = merged.filter(
124
+ (b) => b.start < effectiveEnd && b.end > effectiveStart
125
+ );
126
+
127
+ let cursor = effectiveStart;
128
+ for (const block of dayBusy) {
129
+ if (block.start > cursor) {
130
+ // Free gap before this busy block
131
+ const gapEnd = Math.min(block.start, effectiveEnd);
132
+ if (gapEnd - cursor >= durationMs && freeSlots.length < args.max_results) {
133
+ freeSlots.push({
134
+ start: new Date(cursor).toISOString(),
135
+ end: new Date(gapEnd).toISOString(),
136
+ duration_minutes: Math.round((gapEnd - cursor) / 60000),
137
+ });
138
+ }
139
+ }
140
+ cursor = Math.max(cursor, block.end);
141
+ }
142
+ // Free gap after last busy block
143
+ if (effectiveEnd - cursor >= durationMs && freeSlots.length < args.max_results) {
144
+ freeSlots.push({
145
+ start: new Date(cursor).toISOString(),
146
+ end: new Date(effectiveEnd).toISOString(),
147
+ duration_minutes: Math.round((effectiveEnd - cursor) / 60000),
148
+ });
149
+ }
150
+ }
151
+
152
+ // Move to next day
153
+ currentDay.setDate(currentDay.getDate() + 1);
154
+ }
155
+
156
+ return JSON.stringify(
157
+ {
158
+ query: {
159
+ calendars: args.calendar_ids,
160
+ duration_minutes: args.duration_minutes,
161
+ working_hours: `${args.working_hours_start}-${args.working_hours_end} ${args.timezone}`,
162
+ },
163
+ available_slots: freeSlots,
164
+ total_found: freeSlots.length,
165
+ },
166
+ null,
167
+ 2
168
+ );
169
+ } catch (error) {
170
+ if (error instanceof UserError) throw error;
171
+ log.error(`Error finding free slots: ${error.message || error}`);
172
+ throw new UserError(`Failed to find available slots: ${error.message || 'Unknown error'}`);
173
+ }
174
+ },
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Get a timestamp for a specific time-of-day on a given date in a timezone.
180
+ * Uses Intl.DateTimeFormat to resolve the timezone offset.
181
+ */
182
+ function getTimeInTimezone(date, hours, minutes, timezone) {
183
+ // Build a date string for the target day
184
+ const year = date.getFullYear();
185
+ const month = String(date.getMonth() + 1).padStart(2, '0');
186
+ const day = String(date.getDate()).padStart(2, '0');
187
+ const hh = String(hours).padStart(2, '0');
188
+ const mm = String(minutes).padStart(2, '0');
189
+
190
+ // Create a date in the local timezone of the server, then adjust
191
+ // We use a formatter to figure out the offset
192
+ try {
193
+ const formatter = new Intl.DateTimeFormat('en-US', {
194
+ timeZone: timezone,
195
+ year: 'numeric',
196
+ month: '2-digit',
197
+ day: '2-digit',
198
+ hour: '2-digit',
199
+ minute: '2-digit',
200
+ second: '2-digit',
201
+ hour12: false,
202
+ });
203
+
204
+ // Create a reference point at midnight UTC for this date
205
+ const refUtc = new Date(`${year}-${month}-${day}T${hh}:${mm}:00Z`);
206
+
207
+ // Format that UTC time in the target timezone to see what time it appears as
208
+ const parts = formatter.formatToParts(refUtc);
209
+ const tzParts = {};
210
+ for (const p of parts) {
211
+ if (p.type !== 'literal') tzParts[p.type] = parseInt(p.value, 10);
212
+ }
213
+
214
+ // Calculate the offset: what we wanted vs what we got
215
+ const gotMinutes = tzParts.hour * 60 + tzParts.minute;
216
+ const wantedMinutes = hours * 60 + minutes;
217
+ const diffMs = (gotMinutes - wantedMinutes) * 60 * 1000;
218
+
219
+ // Adjust: if the timezone shows a later time, the UTC equivalent is earlier
220
+ return refUtc.getTime() - diffMs;
221
+ } catch {
222
+ // Fallback: treat as UTC
223
+ return new Date(`${year}-${month}-${day}T${hh}:${mm}:00Z`).getTime();
224
+ }
225
+ }
@@ -0,0 +1,19 @@
1
+ import { register as listCalendars } from './listCalendars.js';
2
+ import { register as getEvents } from './getEvents.js';
3
+ import { register as manageEvent } from './manageEvent.js';
4
+ import { register as getBusy } from './getBusy.js';
5
+ import { register as getFree } from './getFree.js';
6
+ import { register as moveEvent } from './moveEvent.js';
7
+ import { register as listRecurringInstances } from './listRecurringInstances.js';
8
+ import { register as manageCalendar } from './manageCalendar.js';
9
+
10
+ export function registerCalendarTools(server) {
11
+ listCalendars(server);
12
+ getEvents(server);
13
+ manageEvent(server);
14
+ getBusy(server);
15
+ getFree(server);
16
+ moveEvent(server);
17
+ listRecurringInstances(server);
18
+ manageCalendar(server);
19
+ }
@@ -0,0 +1,38 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getCalendarClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'list_calendars',
8
+ description:
9
+ 'Lists all calendars accessible to the authenticated user. Returns calendar name, ID, access role, and whether it is the primary calendar.',
10
+ parameters: z.object({}),
11
+ execute: async (_args, { log }) => {
12
+ const calendar = await getCalendarClient();
13
+ log.info('Listing calendars');
14
+
15
+ try {
16
+ const response = await calendar.calendarList.list();
17
+ const calendars = response.data.items || [];
18
+
19
+ const results = calendars.map((cal) => ({
20
+ id: cal.id,
21
+ summary: cal.summary || cal.id,
22
+ description: cal.description || null,
23
+ primary: cal.primary || false,
24
+ accessRole: cal.accessRole,
25
+ timeZone: cal.timeZone,
26
+ backgroundColor: cal.backgroundColor || null,
27
+ }));
28
+
29
+ return JSON.stringify(results, null, 2);
30
+ } catch (error) {
31
+ log.error(`Error listing calendars: ${error.message || error}`);
32
+ if (error.code === 401)
33
+ throw new UserError('Authentication failed. Try logging out and re-authenticating.');
34
+ throw new UserError(`Failed to list calendars: ${error.message || 'Unknown error'}`);
35
+ }
36
+ },
37
+ });
38
+ }
@@ -0,0 +1,81 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getCalendarClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'list_recurring_event_instances',
8
+ description:
9
+ 'Lists individual occurrences of a recurring event. Use this to find specific instances ' +
10
+ 'you want to modify or cancel (e.g. "cancel next Tuesday\'s standup"). ' +
11
+ 'Each instance has its own event ID that you can pass to manage_event.',
12
+ parameters: z.object({
13
+ calendar_id: z
14
+ .string()
15
+ .optional()
16
+ .default('primary')
17
+ .describe('Calendar ID. Defaults to "primary".'),
18
+ event_id: z
19
+ .string()
20
+ .describe('The recurring event ID (the parent event, not an instance).'),
21
+ time_min: z
22
+ .string()
23
+ .optional()
24
+ .describe('Start of range in RFC3339 format. Defaults to now.'),
25
+ time_max: z
26
+ .string()
27
+ .optional()
28
+ .describe('End of range in RFC3339 format.'),
29
+ max_results: z
30
+ .number()
31
+ .optional()
32
+ .default(10)
33
+ .describe('Maximum number of instances to return. Defaults to 10.'),
34
+ }),
35
+ execute: async (args, { log }) => {
36
+ const calendar = await getCalendarClient();
37
+ log.info(`Listing instances of recurring event ${args.event_id}`);
38
+
39
+ try {
40
+ const params = {
41
+ calendarId: args.calendar_id,
42
+ eventId: args.event_id,
43
+ maxResults: args.max_results,
44
+ };
45
+
46
+ if (args.time_min) params.timeMin = args.time_min;
47
+ else params.timeMin = new Date().toISOString();
48
+
49
+ if (args.time_max) params.timeMax = args.time_max;
50
+
51
+ const response = await calendar.events.instances(params);
52
+ const instances = response.data.items || [];
53
+
54
+ const results = instances.map((event) => ({
55
+ id: event.id,
56
+ summary: event.summary || '(No title)',
57
+ start: event.start?.dateTime || event.start?.date,
58
+ end: event.end?.dateTime || event.end?.date,
59
+ status: event.status,
60
+ recurringEventId: event.recurringEventId,
61
+ htmlLink: event.htmlLink,
62
+ }));
63
+
64
+ return JSON.stringify(results, null, 2);
65
+ } catch (error) {
66
+ log.error(`Error listing recurring instances: ${error.message || error}`);
67
+ if (error.code === 404)
68
+ throw new UserError(
69
+ 'Recurring event not found. Check the event_id — it should be the parent recurring event ID.'
70
+ );
71
+ if (error.code === 400)
72
+ throw new UserError(
73
+ 'The specified event_id does not appear to be a recurring event.'
74
+ );
75
+ throw new UserError(
76
+ `Failed to list recurring instances: ${error.message || 'Unknown error'}`
77
+ );
78
+ }
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,121 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getCalendarClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'manage_calendar',
8
+ description:
9
+ 'Create, update, or delete a calendar. Use this to create project-specific calendars, ' +
10
+ 'rename calendars, change timezone/description, or remove calendars you own.',
11
+ parameters: z.object({
12
+ action: z.enum(['create', 'update', 'delete']).describe('The operation to perform.'),
13
+ calendar_id: z
14
+ .string()
15
+ .optional()
16
+ .describe('Calendar ID — required for update and delete. Cannot delete primary calendar.'),
17
+ summary: z
18
+ .string()
19
+ .optional()
20
+ .describe('Calendar name/title. Required for create.'),
21
+ description: z
22
+ .string()
23
+ .optional()
24
+ .describe('Calendar description.'),
25
+ timezone: z
26
+ .string()
27
+ .optional()
28
+ .describe('Calendar timezone (e.g. "America/New_York").'),
29
+ }),
30
+ execute: async (args, { log }) => {
31
+ const calendar = await getCalendarClient();
32
+
33
+ try {
34
+ if (args.action === 'create') {
35
+ if (!args.summary) throw new UserError('summary is required for create.');
36
+ log.info(`Creating calendar "${args.summary}"`);
37
+
38
+ const body = { summary: args.summary };
39
+ if (args.description) body.description = args.description;
40
+ if (args.timezone) body.timeZone = args.timezone;
41
+
42
+ const response = await calendar.calendars.insert({ requestBody: body });
43
+ return JSON.stringify(
44
+ {
45
+ success: true,
46
+ action: 'created',
47
+ id: response.data.id,
48
+ summary: response.data.summary,
49
+ timeZone: response.data.timeZone,
50
+ },
51
+ null,
52
+ 2
53
+ );
54
+ }
55
+
56
+ if (args.action === 'update') {
57
+ if (!args.calendar_id) throw new UserError('calendar_id is required for update.');
58
+ log.info(`Updating calendar ${args.calendar_id}`);
59
+
60
+ // Fetch existing to preserve fields
61
+ const existing = await calendar.calendars.get({
62
+ calendarId: args.calendar_id,
63
+ });
64
+
65
+ const body = {
66
+ summary: args.summary || existing.data.summary,
67
+ description:
68
+ args.description !== undefined
69
+ ? args.description
70
+ : existing.data.description,
71
+ timeZone: args.timezone || existing.data.timeZone,
72
+ };
73
+
74
+ const response = await calendar.calendars.update({
75
+ calendarId: args.calendar_id,
76
+ requestBody: body,
77
+ });
78
+
79
+ return JSON.stringify(
80
+ {
81
+ success: true,
82
+ action: 'updated',
83
+ id: response.data.id,
84
+ summary: response.data.summary,
85
+ timeZone: response.data.timeZone,
86
+ },
87
+ null,
88
+ 2
89
+ );
90
+ }
91
+
92
+ if (args.action === 'delete') {
93
+ if (!args.calendar_id) throw new UserError('calendar_id is required for delete.');
94
+ if (args.calendar_id === 'primary')
95
+ throw new UserError('Cannot delete the primary calendar.');
96
+ log.info(`Deleting calendar ${args.calendar_id}`);
97
+
98
+ await calendar.calendars.delete({ calendarId: args.calendar_id });
99
+ return JSON.stringify({
100
+ success: true,
101
+ action: 'deleted',
102
+ id: args.calendar_id,
103
+ message: `Calendar ${args.calendar_id} deleted.`,
104
+ });
105
+ }
106
+
107
+ throw new UserError(`Unknown action: ${args.action}`);
108
+ } catch (error) {
109
+ if (error instanceof UserError) throw error;
110
+ log.error(`Error in manage_calendar (${args.action}): ${error.message || error}`);
111
+ if (error.code === 404)
112
+ throw new UserError('Calendar not found. Check the calendar_id.');
113
+ if (error.code === 403)
114
+ throw new UserError('Permission denied. You can only modify calendars you own.');
115
+ throw new UserError(
116
+ `Failed to ${args.action} calendar: ${error.message || 'Unknown error'}`
117
+ );
118
+ }
119
+ },
120
+ });
121
+ }