From b8dc9c8aca2cc477311371eb94d42bca49f077e0 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 08:05:56 -0300 Subject: [PATCH 1/9] feat: add Google Calendar MCP with OAuth 2.0 support - Add calendar management tools (list, get, create, delete) - Add event management tools (list, get, create, update, delete, quick_add) - Add availability tool (get_freebusy) - Add advanced tools (move_event, find_available_slots, duplicate_event) - Implement OAuth 2.0 with PKCE for Google authentication - Add Google Calendar API client with full TypeScript types - Add google-calendar to monorepo workspaces Total: 14 tools for complete calendar management --- google-calendar/.env.example | 12 + google-calendar/.gitignore | 4 + google-calendar/README.md | 219 ++++++++ google-calendar/app.json | 12 + google-calendar/package.json | 28 ++ google-calendar/server/constants.ts | 55 ++ google-calendar/server/lib/google-client.ts | 365 ++++++++++++++ google-calendar/server/lib/types.ts | 219 ++++++++ google-calendar/server/main.ts | 86 ++++ google-calendar/server/tools/advanced.ts | 302 +++++++++++ google-calendar/server/tools/calendars.ts | 231 +++++++++ google-calendar/server/tools/events.ts | 530 ++++++++++++++++++++ google-calendar/server/tools/freebusy.ts | 108 ++++ google-calendar/server/tools/index.ts | 29 ++ google-calendar/tsconfig.json | 36 ++ package.json | 1 + 16 files changed, 2237 insertions(+) create mode 100644 google-calendar/.env.example create mode 100644 google-calendar/.gitignore create mode 100644 google-calendar/README.md create mode 100644 google-calendar/app.json create mode 100644 google-calendar/package.json create mode 100644 google-calendar/server/constants.ts create mode 100644 google-calendar/server/lib/google-client.ts create mode 100644 google-calendar/server/lib/types.ts create mode 100644 google-calendar/server/main.ts create mode 100644 google-calendar/server/tools/advanced.ts create mode 100644 google-calendar/server/tools/calendars.ts create mode 100644 google-calendar/server/tools/events.ts create mode 100644 google-calendar/server/tools/freebusy.ts create mode 100644 google-calendar/server/tools/index.ts create mode 100644 google-calendar/tsconfig.json diff --git a/google-calendar/.env.example b/google-calendar/.env.example new file mode 100644 index 00000000..7a4868ac --- /dev/null +++ b/google-calendar/.env.example @@ -0,0 +1,12 @@ +# Google Calendar MCP - Environment Variables +# Copy this file to .env and fill in your credentials + +# Google OAuth 2.0 Credentials +# Get these from Google Cloud Console: https://console.cloud.google.com/ +# 1. Create a project or select existing +# 2. Enable Google Calendar API +# 3. Create OAuth 2.0 credentials (Web application type) +# 4. Add authorized redirect URIs for your deployment + +GOOGLE_CLIENT_ID=your_client_id_here +GOOGLE_CLIENT_SECRET=your_client_secret_here diff --git a/google-calendar/.gitignore b/google-calendar/.gitignore new file mode 100644 index 00000000..ed7bba3b --- /dev/null +++ b/google-calendar/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env + diff --git a/google-calendar/README.md b/google-calendar/README.md new file mode 100644 index 00000000..f6491561 --- /dev/null +++ b/google-calendar/README.md @@ -0,0 +1,219 @@ +# Google Calendar MCP + +MCP Server for Google Calendar integration. Manage calendars, events and check availability using the Google Calendar API. + +## Features + +### Calendar Management +- **list_calendars** - List all user's calendars +- **get_calendar** - Get details of a specific calendar +- **create_calendar** - Create a new secondary calendar +- **delete_calendar** - Delete a calendar + +### Event Management +- **list_events** - List events with date filters and search +- **get_event** - Get details of an event +- **create_event** - Create event with attendees and reminders +- **update_event** - Update existing event +- **delete_event** - Delete event +- **quick_add_event** - Create event using natural language + +### Availability +- **get_freebusy** - Check busy/free time slots + +### Advanced Operations +- **move_event** - Move an event between calendars +- **find_available_slots** - Find free time slots across multiple calendars +- **duplicate_event** - Create a copy of an existing event + +## Setup + +### 1. Create Project in Google Cloud Console + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable **Google Calendar API**: + - Sidebar → APIs & Services → Library + - Search for "Google Calendar API" and enable it + +### 2. Configure OAuth 2.0 + +1. Go to "APIs & Services" → "Credentials" +2. Click "Create credentials" → "OAuth client ID" +3. Select "Web application" +4. Configure: + - Name: Google Calendar MCP + - Authorized JavaScript origins: your URL + - Authorized redirect URIs: your callback URL + +### 3. Configure Environment Variables + +Create a `.env` file with: + +```bash +GOOGLE_CLIENT_ID=your_client_id +GOOGLE_CLIENT_SECRET=your_client_secret +``` + +## Development + +```bash +# Install dependencies (from monorepo root) +bun install + +# Run in development (hot reload) +bun run dev + +# Type check +bun run check + +# Build for production +bun run build +``` + +## Usage Examples + +### List events for next week + +```json +{ + "tool": "list_events", + "input": { + "timeMin": "2024-01-15T00:00:00Z", + "timeMax": "2024-01-22T00:00:00Z", + "singleEvents": true, + "orderBy": "startTime" + } +} +``` + +### Create event with attendees + +```json +{ + "tool": "create_event", + "input": { + "summary": "Planning Meeting", + "description": "Q1 roadmap discussion", + "location": "Conference Room", + "start": { + "dateTime": "2024-01-15T14:00:00-03:00", + "timeZone": "America/Sao_Paulo" + }, + "end": { + "dateTime": "2024-01-15T15:00:00-03:00", + "timeZone": "America/Sao_Paulo" + }, + "attendees": [ + { "email": "john@company.com" }, + { "email": "mary@company.com" } + ], + "sendUpdates": "all" + } +} +``` + +### Quick add event with natural language + +```json +{ + "tool": "quick_add_event", + "input": { + "text": "Lunch with client tomorrow at 12pm at Central Restaurant" + } +} +``` + +### Check availability + +```json +{ + "tool": "get_freebusy", + "input": { + "timeMin": "2024-01-15T08:00:00-03:00", + "timeMax": "2024-01-15T18:00:00-03:00", + "calendarIds": ["primary", "work@group.calendar.google.com"] + } +} +``` + +### Find available meeting slots + +```json +{ + "tool": "find_available_slots", + "input": { + "calendarIds": ["primary", "colleague@company.com"], + "timeMin": "2024-01-15T09:00:00-03:00", + "timeMax": "2024-01-15T18:00:00-03:00", + "slotDurationMinutes": 30, + "maxSlots": 5 + } +} +``` + +### Move event to another calendar + +```json +{ + "tool": "move_event", + "input": { + "sourceCalendarId": "primary", + "eventId": "abc123", + "destinationCalendarId": "work@group.calendar.google.com", + "sendUpdates": "all" + } +} +``` + +### Duplicate an event + +```json +{ + "tool": "duplicate_event", + "input": { + "eventId": "abc123", + "newStart": { + "dateTime": "2024-01-22T14:00:00-03:00", + "timeZone": "America/Sao_Paulo" + }, + "newEnd": { + "dateTime": "2024-01-22T15:00:00-03:00", + "timeZone": "America/Sao_Paulo" + } + } +} +``` + +## Project Structure + +``` +google-calendar/ +├── server/ +│ ├── main.ts # Entry point with OAuth +│ ├── constants.ts # API URLs and constants +│ ├── lib/ +│ │ ├── google-client.ts # API client +│ │ └── types.ts # TypeScript types +│ └── tools/ +│ ├── index.ts # Exports all tools +│ ├── calendars.ts # Calendar tools +│ ├── events.ts # Event tools +│ ├── freebusy.ts # Availability tool +│ └── advanced.ts # Advanced tools (move, find slots, duplicate) +├── app.json # MCP configuration +├── package.json +├── tsconfig.json +└── README.md +``` + +## OAuth Scopes + +This MCP requests the following scopes: + +- `https://www.googleapis.com/auth/calendar` - Full calendar access +- `https://www.googleapis.com/auth/calendar.events` - Event management + +## License + +MIT diff --git a/google-calendar/app.json b/google-calendar/app.json new file mode 100644 index 00000000..329eeb47 --- /dev/null +++ b/google-calendar/app.json @@ -0,0 +1,12 @@ +{ + "scopeName": "google", + "name": "google-calendar", + "connection": { + "type": "HTTP", + "url": "https://sites-google-calendar.decocache.com/mcp" + }, + "description": "Integrate and manage your Google Calendar. Create, edit and delete events, check availability and sync your calendars.", + "icon": "https://assets.decocache.com/mcp/google-calendar.svg", + "unlisted": false +} + diff --git a/google-calendar/package.json b/google-calendar/package.json new file mode 100644 index 00000000..b8448e0d --- /dev/null +++ b/google-calendar/package.json @@ -0,0 +1,28 @@ +{ + "name": "google-calendar", + "version": "1.0.0", + "description": "Google Calendar MCP Server - Manage calendars and events", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot server/main.ts", + "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", + "build": "bun run build:server", + "publish": "cat app.json | deco registry publish -w /shared/deco -y", + "check": "tsc --noEmit" + }, + "dependencies": { + "@decocms/runtime": "^1.0.3", + "zod": "^3.24.3" + }, + "devDependencies": { + "@decocms/mcps-shared": "workspace:*", + "@modelcontextprotocol/sdk": "1.25.1", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22.0.0" + } +} + diff --git a/google-calendar/server/constants.ts b/google-calendar/server/constants.ts new file mode 100644 index 00000000..62446e57 --- /dev/null +++ b/google-calendar/server/constants.ts @@ -0,0 +1,55 @@ +/** + * Google Calendar API constants and configuration + */ + +export const GOOGLE_CALENDAR_API_BASE = + "https://www.googleapis.com/calendar/v3"; + +// API Endpoints +export const ENDPOINTS = { + CALENDAR_LIST: `${GOOGLE_CALENDAR_API_BASE}/users/me/calendarList`, + CALENDARS: `${GOOGLE_CALENDAR_API_BASE}/calendars`, + EVENTS: (calendarId: string) => + `${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`, + EVENT: (calendarId: string, eventId: string) => + `${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, + QUICK_ADD: (calendarId: string) => + `${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/quickAdd`, + FREEBUSY: `${GOOGLE_CALENDAR_API_BASE}/freeBusy`, +}; + +// Default calendar ID +export const PRIMARY_CALENDAR = "primary"; + +// Default pagination +export const DEFAULT_MAX_RESULTS = 50; + +// Event colors (Google Calendar color IDs) +export const EVENT_COLORS = { + LAVENDER: "1", + SAGE: "2", + GRAPE: "3", + FLAMINGO: "4", + BANANA: "5", + TANGERINE: "6", + PEACOCK: "7", + GRAPHITE: "8", + BLUEBERRY: "9", + BASIL: "10", + TOMATO: "11", +} as const; + +// Event visibility options +export const EVENT_VISIBILITY = { + DEFAULT: "default", + PUBLIC: "public", + PRIVATE: "private", + CONFIDENTIAL: "confidential", +} as const; + +// Event status +export const EVENT_STATUS = { + CONFIRMED: "confirmed", + TENTATIVE: "tentative", + CANCELLED: "cancelled", +} as const; diff --git a/google-calendar/server/lib/google-client.ts b/google-calendar/server/lib/google-client.ts new file mode 100644 index 00000000..997df21d --- /dev/null +++ b/google-calendar/server/lib/google-client.ts @@ -0,0 +1,365 @@ +/** + * Google Calendar API client + * Handles all communication with the Google Calendar API + */ + +import { + ENDPOINTS, + DEFAULT_MAX_RESULTS, + PRIMARY_CALENDAR, +} from "../constants.ts"; +import type { + Calendar, + CalendarListEntry, + CalendarListResponse, + CreateCalendarInput, + CreateEventInput, + Event, + EventsListResponse, + FreeBusyRequest, + FreeBusyResponse, + ListEventsInput, + UpdateEventInput, +} from "./types.ts"; + +export interface GoogleCalendarClientConfig { + accessToken: string; +} + +export class GoogleCalendarClient { + private accessToken: string; + + constructor(config: GoogleCalendarClientConfig) { + this.accessToken = config.accessToken; + } + + private async request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error( + `Google Calendar API error: ${response.status} - ${error}`, + ); + } + + // Handle 204 No Content + if (response.status === 204) { + return {} as T; + } + + return response.json() as Promise; + } + + // ==================== Calendar Methods ==================== + + /** + * List all calendars for the authenticated user + */ + async listCalendars( + pageToken?: string, + maxResults: number = DEFAULT_MAX_RESULTS, + ): Promise { + const url = new URL(ENDPOINTS.CALENDAR_LIST); + url.searchParams.set("maxResults", String(maxResults)); + if (pageToken) { + url.searchParams.set("pageToken", pageToken); + } + + return this.request(url.toString()); + } + + /** + * Get a specific calendar by ID + */ + async getCalendar(calendarId: string): Promise { + const url = `${ENDPOINTS.CALENDAR_LIST}/${encodeURIComponent(calendarId)}`; + return this.request(url); + } + + /** + * Create a new calendar + */ + async createCalendar(input: CreateCalendarInput): Promise { + return this.request(ENDPOINTS.CALENDARS, { + method: "POST", + body: JSON.stringify(input), + }); + } + + /** + * Delete a calendar + */ + async deleteCalendar(calendarId: string): Promise { + const url = `${ENDPOINTS.CALENDARS}/${encodeURIComponent(calendarId)}`; + await this.request(url, { method: "DELETE" }); + } + + // ==================== Event Methods ==================== + + /** + * List events from a calendar + */ + async listEvents(input: ListEventsInput = {}): Promise { + const calendarId = input.calendarId || PRIMARY_CALENDAR; + const url = new URL(ENDPOINTS.EVENTS(calendarId)); + + if (input.timeMin) url.searchParams.set("timeMin", input.timeMin); + if (input.timeMax) url.searchParams.set("timeMax", input.timeMax); + if (input.maxResults) + url.searchParams.set("maxResults", String(input.maxResults)); + if (input.pageToken) url.searchParams.set("pageToken", input.pageToken); + if (input.q) url.searchParams.set("q", input.q); + if (input.singleEvents !== undefined) + url.searchParams.set("singleEvents", String(input.singleEvents)); + if (input.orderBy) url.searchParams.set("orderBy", input.orderBy); + if (input.showDeleted !== undefined) + url.searchParams.set("showDeleted", String(input.showDeleted)); + + return this.request(url.toString()); + } + + /** + * Get a specific event by ID + */ + async getEvent(calendarId: string, eventId: string): Promise { + const url = ENDPOINTS.EVENT(calendarId || PRIMARY_CALENDAR, eventId); + return this.request(url); + } + + /** + * Create a new event + */ + async createEvent(input: CreateEventInput): Promise { + const calendarId = input.calendarId || PRIMARY_CALENDAR; + const url = new URL(ENDPOINTS.EVENTS(calendarId)); + + if (input.sendUpdates) { + url.searchParams.set("sendUpdates", input.sendUpdates); + } + if (input.conferenceDataVersion !== undefined) { + url.searchParams.set( + "conferenceDataVersion", + String(input.conferenceDataVersion), + ); + } + + const { + calendarId: _, + sendUpdates: __, + conferenceDataVersion: ___, + ...eventData + } = input; + + return this.request(url.toString(), { + method: "POST", + body: JSON.stringify(eventData), + }); + } + + /** + * Update an existing event + */ + async updateEvent(input: UpdateEventInput): Promise { + const { calendarId, eventId, sendUpdates, ...eventData } = input; + const url = new URL( + ENDPOINTS.EVENT(calendarId || PRIMARY_CALENDAR, eventId), + ); + + if (sendUpdates) { + url.searchParams.set("sendUpdates", sendUpdates); + } + + return this.request(url.toString(), { + method: "PATCH", + body: JSON.stringify(eventData), + }); + } + + /** + * Delete an event + */ + async deleteEvent( + calendarId: string, + eventId: string, + sendUpdates?: "all" | "externalOnly" | "none", + ): Promise { + const url = new URL( + ENDPOINTS.EVENT(calendarId || PRIMARY_CALENDAR, eventId), + ); + + if (sendUpdates) { + url.searchParams.set("sendUpdates", sendUpdates); + } + + await this.request(url.toString(), { method: "DELETE" }); + } + + /** + * Quick add event using natural language + */ + async quickAddEvent( + calendarId: string, + text: string, + sendUpdates?: "all" | "externalOnly" | "none", + ): Promise { + const url = new URL(ENDPOINTS.QUICK_ADD(calendarId || PRIMARY_CALENDAR)); + url.searchParams.set("text", text); + + if (sendUpdates) { + url.searchParams.set("sendUpdates", sendUpdates); + } + + return this.request(url.toString(), { method: "POST" }); + } + + // ==================== FreeBusy Methods ==================== + + /** + * Check free/busy information for calendars + */ + async getFreeBusy(request: FreeBusyRequest): Promise { + return this.request(ENDPOINTS.FREEBUSY, { + method: "POST", + body: JSON.stringify(request), + }); + } + + // ==================== Advanced Methods ==================== + + /** + * Move an event to a different calendar + */ + async moveEvent( + sourceCalendarId: string, + eventId: string, + destinationCalendarId: string, + sendUpdates?: "all" | "externalOnly" | "none", + ): Promise { + const url = new URL(`${ENDPOINTS.EVENT(sourceCalendarId, eventId)}/move`); + url.searchParams.set("destination", destinationCalendarId); + + if (sendUpdates) { + url.searchParams.set("sendUpdates", sendUpdates); + } + + return this.request(url.toString(), { method: "POST" }); + } + + /** + * Find available time slots across multiple calendars + * Returns periods where all specified calendars are free + */ + async findAvailableSlots( + calendarIds: string[], + timeMin: string, + timeMax: string, + slotDurationMinutes: number, + timeZone?: string, + ): Promise> { + // Get free/busy info for all calendars + const freeBusyResponse = await this.getFreeBusy({ + timeMin, + timeMax, + timeZone, + items: calendarIds.map((id) => ({ id })), + }); + + // Merge all busy periods + const allBusyPeriods: Array<{ start: Date; end: Date }> = []; + for (const calendarData of Object.values(freeBusyResponse.calendars)) { + for (const busy of calendarData.busy) { + allBusyPeriods.push({ + start: new Date(busy.start), + end: new Date(busy.end), + }); + } + } + + // Sort by start time + allBusyPeriods.sort((a, b) => a.start.getTime() - b.start.getTime()); + + // Merge overlapping busy periods + const mergedBusy: Array<{ start: Date; end: Date }> = []; + for (const period of allBusyPeriods) { + if (mergedBusy.length === 0) { + mergedBusy.push(period); + } else { + const last = mergedBusy[mergedBusy.length - 1]; + if (period.start <= last.end) { + // Overlapping, extend the end + last.end = new Date( + Math.max(last.end.getTime(), period.end.getTime()), + ); + } else { + mergedBusy.push(period); + } + } + } + + // Find free slots + const availableSlots: Array<{ start: string; end: string }> = []; + const rangeStart = new Date(timeMin); + const rangeEnd = new Date(timeMax); + const slotDurationMs = slotDurationMinutes * 60 * 1000; + + let currentStart = rangeStart; + + for (const busy of mergedBusy) { + // Check if there's a gap before this busy period + if (busy.start > currentStart) { + const gapEnd = busy.start; + // Find slots in this gap + let slotStart = currentStart; + while (slotStart.getTime() + slotDurationMs <= gapEnd.getTime()) { + const slotEnd = new Date(slotStart.getTime() + slotDurationMs); + availableSlots.push({ + start: slotStart.toISOString(), + end: slotEnd.toISOString(), + }); + slotStart = slotEnd; + } + } + currentStart = new Date( + Math.max(currentStart.getTime(), busy.end.getTime()), + ); + } + + // Check for slots after the last busy period + if (currentStart < rangeEnd) { + let slotStart = currentStart; + while (slotStart.getTime() + slotDurationMs <= rangeEnd.getTime()) { + const slotEnd = new Date(slotStart.getTime() + slotDurationMs); + availableSlots.push({ + start: slotStart.toISOString(), + end: slotEnd.toISOString(), + }); + slotStart = slotEnd; + } + } + + return availableSlots; + } +} + +/** + * Get access token from environment context + */ +export function getAccessToken(env: { + MESH_REQUEST_CONTEXT?: { accessToken?: string }; +}): string { + const token = env.MESH_REQUEST_CONTEXT?.accessToken; + if (!token) { + throw new Error( + "Not authenticated. Please authorize with Google Calendar first.", + ); + } + return token; +} diff --git a/google-calendar/server/lib/types.ts b/google-calendar/server/lib/types.ts new file mode 100644 index 00000000..cc6335d9 --- /dev/null +++ b/google-calendar/server/lib/types.ts @@ -0,0 +1,219 @@ +/** + * Google Calendar API types + */ + +export interface CalendarListEntry { + kind: "calendar#calendarListEntry"; + etag: string; + id: string; + summary: string; + description?: string; + location?: string; + timeZone?: string; + summaryOverride?: string; + colorId?: string; + backgroundColor?: string; + foregroundColor?: string; + hidden?: boolean; + selected?: boolean; + accessRole: "freeBusyReader" | "reader" | "writer" | "owner"; + defaultReminders?: Reminder[]; + primary?: boolean; + deleted?: boolean; +} + +export interface Calendar { + kind: "calendar#calendar"; + etag: string; + id: string; + summary: string; + description?: string; + location?: string; + timeZone?: string; +} + +export interface Event { + kind: "calendar#event"; + etag: string; + id: string; + status?: "confirmed" | "tentative" | "cancelled"; + htmlLink?: string; + created?: string; + updated?: string; + summary?: string; + description?: string; + location?: string; + colorId?: string; + creator?: { + id?: string; + email?: string; + displayName?: string; + self?: boolean; + }; + organizer?: { + id?: string; + email?: string; + displayName?: string; + self?: boolean; + }; + start: EventDateTime; + end: EventDateTime; + endTimeUnspecified?: boolean; + recurrence?: string[]; + recurringEventId?: string; + originalStartTime?: EventDateTime; + transparency?: "opaque" | "transparent"; + visibility?: "default" | "public" | "private" | "confidential"; + iCalUID?: string; + sequence?: number; + attendees?: Attendee[]; + attendeesOmitted?: boolean; + hangoutLink?: string; + conferenceData?: ConferenceData; + reminders?: { + useDefault: boolean; + overrides?: Reminder[]; + }; +} + +export interface EventDateTime { + date?: string; // For all-day events (YYYY-MM-DD) + dateTime?: string; // For timed events (RFC3339) + timeZone?: string; +} + +export interface Attendee { + id?: string; + email: string; + displayName?: string; + organizer?: boolean; + self?: boolean; + resource?: boolean; + optional?: boolean; + responseStatus?: "needsAction" | "declined" | "tentative" | "accepted"; + comment?: string; + additionalGuests?: number; +} + +export interface Reminder { + method: "email" | "popup"; + minutes: number; +} + +export interface ConferenceData { + createRequest?: { + requestId: string; + conferenceSolutionKey?: { + type: string; + }; + status?: { + statusCode: string; + }; + }; + entryPoints?: Array<{ + entryPointType: string; + uri: string; + label?: string; + pin?: string; + accessCode?: string; + meetingCode?: string; + passcode?: string; + password?: string; + }>; + conferenceSolution?: { + key: { + type: string; + }; + name: string; + iconUri: string; + }; + conferenceId?: string; +} + +export interface CalendarListResponse { + kind: "calendar#calendarList"; + etag: string; + nextPageToken?: string; + nextSyncToken?: string; + items: CalendarListEntry[]; +} + +export interface EventsListResponse { + kind: "calendar#events"; + etag: string; + summary: string; + description?: string; + updated: string; + timeZone: string; + accessRole: string; + nextPageToken?: string; + nextSyncToken?: string; + items: Event[]; +} + +export interface FreeBusyRequest { + timeMin: string; + timeMax: string; + timeZone?: string; + groupExpansionMax?: number; + calendarExpansionMax?: number; + items: Array<{ id: string }>; +} + +export interface FreeBusyResponse { + kind: "calendar#freeBusy"; + timeMin: string; + timeMax: string; + calendars: { + [calendarId: string]: { + errors?: Array<{ domain: string; reason: string }>; + busy: Array<{ start: string; end: string }>; + }; + }; +} + +export interface CreateEventInput { + calendarId?: string; + summary: string; + description?: string; + location?: string; + start: EventDateTime; + end: EventDateTime; + attendees?: Array<{ + email: string; + displayName?: string; + optional?: boolean; + }>; + reminders?: { + useDefault: boolean; + overrides?: Reminder[]; + }; + colorId?: string; + visibility?: "default" | "public" | "private" | "confidential"; + sendUpdates?: "all" | "externalOnly" | "none"; + conferenceDataVersion?: 0 | 1; +} + +export interface UpdateEventInput extends Partial { + calendarId: string; + eventId: string; +} + +export interface ListEventsInput { + calendarId?: string; + timeMin?: string; + timeMax?: string; + maxResults?: number; + pageToken?: string; + q?: string; + singleEvents?: boolean; + orderBy?: "startTime" | "updated"; + showDeleted?: boolean; +} + +export interface CreateCalendarInput { + summary: string; + description?: string; + location?: string; + timeZone?: string; +} diff --git a/google-calendar/server/main.ts b/google-calendar/server/main.ts new file mode 100644 index 00000000..157f8f23 --- /dev/null +++ b/google-calendar/server/main.ts @@ -0,0 +1,86 @@ +/** + * Google Calendar MCP Server + * + * This MCP provides tools for interacting with Google Calendar API, + * including calendar management, event CRUD operations, and availability checks. + */ +import { type DefaultEnv, withRuntime } from "@decocms/runtime"; +import { serve } from "@decocms/mcps-shared/serve"; + +import { tools } from "./tools/index.ts"; + +/** + * Environment type for the MCP server + */ +export type Env = DefaultEnv; + +const GOOGLE_CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/calendar.events", +].join(" "); + +const runtime = withRuntime({ + oauth: { + mode: "PKCE", + // Used in protected resource metadata to point to the auth server + authorizationServer: "https://accounts.google.com", + + // Generates the URL to redirect users to for authorization + authorizationUrl: (callbackUrl) => { + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", GOOGLE_CALENDAR_SCOPES); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); + }, + + // Exchanges the authorization code for access token + exchangeCode: async ({ code, code_verifier, code_challenge_method }) => { + const params = new URLSearchParams({ + code, + client_id: process.env.GOOGLE_CLIENT_ID!, + client_secret: process.env.GOOGLE_CLIENT_SECRET!, + grant_type: "authorization_code", + }); + + // Add PKCE verifier if provided + if (code_verifier) { + params.set("code_verifier", code_verifier); + } + if (code_challenge_method) { + params.set("code_challenge_method", code_challenge_method); + } + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Google OAuth failed: ${response.status} - ${error}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type: string; + }; + + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + token_type: data.token_type || "Bearer", + expires_in: data.expires_in, + }; + }, + }, + tools, +}); + +serve(runtime.fetch); diff --git a/google-calendar/server/tools/advanced.ts b/google-calendar/server/tools/advanced.ts new file mode 100644 index 00000000..ac9dce5b --- /dev/null +++ b/google-calendar/server/tools/advanced.ts @@ -0,0 +1,302 @@ +/** + * Advanced Calendar Tools + * + * Additional tools for advanced calendar operations: + * - move_event: Move events between calendars + * - find_available_slots: Find free time slots across calendars + * - duplicate_event: Create a copy of an existing event + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { GoogleCalendarClient, getAccessToken } from "../lib/google-client.ts"; +import { PRIMARY_CALENDAR } from "../constants.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const TimeSlotSchema = z.object({ + start: z.string().describe("Start time of the available slot (ISO 8601)"), + end: z.string().describe("End time of the available slot (ISO 8601)"), +}); + +const EventDateTimeSchema = z.object({ + date: z + .string() + .optional() + .describe("Date for all-day events (YYYY-MM-DD format)"), + dateTime: z + .string() + .optional() + .describe("DateTime for timed events (RFC3339 format)"), + timeZone: z + .string() + .optional() + .describe("Timezone (e.g., 'America/Sao_Paulo')"), +}); + +const EventSchema = z.object({ + id: z.string().describe("Event ID"), + summary: z.string().optional().describe("Event title"), + description: z.string().optional().describe("Event description"), + location: z.string().optional().describe("Event location"), + start: EventDateTimeSchema.describe("Event start time"), + end: EventDateTimeSchema.describe("Event end time"), + status: z + .enum(["confirmed", "tentative", "cancelled"]) + .optional() + .describe("Event status"), + htmlLink: z + .string() + .optional() + .describe("Link to the event in Google Calendar"), +}); + +// ============================================================================ +// Move Event Tool +// ============================================================================ + +export const createMoveEventTool = (env: Env) => + createPrivateTool({ + id: "move_event", + description: + "Move an event from one calendar to another. The event will be removed from the source calendar and added to the destination calendar.", + inputSchema: z.object({ + sourceCalendarId: z + .string() + .describe("Calendar ID where the event currently exists"), + eventId: z.string().describe("Event ID to move"), + destinationCalendarId: z + .string() + .describe("Calendar ID to move the event to"), + sendUpdates: z + .enum(["all", "externalOnly", "none"]) + .optional() + .describe("Who should receive email notifications about the move"), + }), + outputSchema: z.object({ + event: EventSchema.describe("The moved event with its new details"), + message: z.string().describe("Success message"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const event = await client.moveEvent( + context.sourceCalendarId, + context.eventId, + context.destinationCalendarId, + context.sendUpdates, + ); + + return { + event: { + id: event.id, + summary: event.summary, + description: event.description, + location: event.location, + start: event.start, + end: event.end, + status: event.status, + htmlLink: event.htmlLink, + }, + message: `Event moved successfully from ${context.sourceCalendarId} to ${context.destinationCalendarId}`, + }; + }, + }); + +// ============================================================================ +// Find Available Slots Tool +// ============================================================================ + +export const createFindAvailableSlotsTool = (env: Env) => + createPrivateTool({ + id: "find_available_slots", + description: + "Find available time slots across one or more calendars. Useful for scheduling meetings by finding times when all participants are free.", + inputSchema: z.object({ + calendarIds: z + .array(z.string()) + .optional() + .describe("List of calendar IDs to check. Defaults to ['primary']"), + timeMin: z + .string() + .describe( + "Start of the search range (RFC3339 format, e.g., '2024-01-15T08:00:00Z')", + ), + timeMax: z + .string() + .describe( + "End of the search range (RFC3339 format, e.g., '2024-01-15T18:00:00Z')", + ), + slotDurationMinutes: z + .number() + .int() + .min(5) + .max(480) + .describe( + "Duration of each slot in minutes (e.g., 30 for 30-minute meetings)", + ), + timeZone: z + .string() + .optional() + .describe("Timezone for the search (e.g., 'America/Sao_Paulo')"), + maxSlots: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Maximum number of slots to return (default: 10)"), + }), + outputSchema: z.object({ + availableSlots: z + .array(TimeSlotSchema) + .describe("List of available time slots"), + totalFound: z.number().describe("Total number of available slots found"), + searchRange: z.object({ + start: z.string().describe("Start of search range"), + end: z.string().describe("End of search range"), + }), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const calendarIds = context.calendarIds || [PRIMARY_CALENDAR]; + const maxSlots = context.maxSlots || 10; + + const slots = await client.findAvailableSlots( + calendarIds, + context.timeMin, + context.timeMax, + context.slotDurationMinutes, + context.timeZone, + ); + + const limitedSlots = slots.slice(0, maxSlots); + + return { + availableSlots: limitedSlots, + totalFound: slots.length, + searchRange: { + start: context.timeMin, + end: context.timeMax, + }, + }; + }, + }); + +// ============================================================================ +// Duplicate Event Tool +// ============================================================================ + +export const createDuplicateEventTool = (env: Env) => + createPrivateTool({ + id: "duplicate_event", + description: + "Create a copy of an existing event. You can optionally change the date/time and target calendar.", + inputSchema: z.object({ + sourceCalendarId: z + .string() + .optional() + .describe( + "Calendar ID where the original event exists (default: 'primary')", + ), + eventId: z.string().describe("Event ID to duplicate"), + targetCalendarId: z + .string() + .optional() + .describe("Calendar ID for the new event (default: same as source)"), + newStart: EventDateTimeSchema.optional().describe( + "New start time for the duplicated event (keeps original if not provided)", + ), + newEnd: EventDateTimeSchema.optional().describe( + "New end time for the duplicated event (keeps original if not provided)", + ), + newSummary: z + .string() + .optional() + .describe( + "New title for the duplicated event (adds 'Copy of' prefix if not provided)", + ), + sendUpdates: z + .enum(["all", "externalOnly", "none"]) + .optional() + .describe("Who should receive email notifications"), + }), + outputSchema: z.object({ + originalEvent: EventSchema.describe("The original event"), + newEvent: EventSchema.describe("The newly created duplicate event"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const sourceCalendarId = context.sourceCalendarId || PRIMARY_CALENDAR; + const targetCalendarId = context.targetCalendarId || sourceCalendarId; + + // Get the original event + const originalEvent = await client.getEvent( + sourceCalendarId, + context.eventId, + ); + + // Create the duplicate + const newEvent = await client.createEvent({ + calendarId: targetCalendarId, + summary: + context.newSummary || `Copy of ${originalEvent.summary || "Event"}`, + description: originalEvent.description, + location: originalEvent.location, + start: context.newStart || originalEvent.start, + end: context.newEnd || originalEvent.end, + attendees: originalEvent.attendees?.map((a) => ({ + email: a.email, + displayName: a.displayName, + optional: a.optional, + })), + colorId: originalEvent.colorId, + visibility: originalEvent.visibility, + sendUpdates: context.sendUpdates, + }); + + return { + originalEvent: { + id: originalEvent.id, + summary: originalEvent.summary, + description: originalEvent.description, + location: originalEvent.location, + start: originalEvent.start, + end: originalEvent.end, + status: originalEvent.status, + htmlLink: originalEvent.htmlLink, + }, + newEvent: { + id: newEvent.id, + summary: newEvent.summary, + description: newEvent.description, + location: newEvent.location, + start: newEvent.start, + end: newEvent.end, + status: newEvent.status, + htmlLink: newEvent.htmlLink, + }, + }; + }, + }); + +// ============================================================================ +// Export all advanced tools +// ============================================================================ + +export const advancedTools = [ + createMoveEventTool, + createFindAvailableSlotsTool, + createDuplicateEventTool, +]; diff --git a/google-calendar/server/tools/calendars.ts b/google-calendar/server/tools/calendars.ts new file mode 100644 index 00000000..0f0db5a4 --- /dev/null +++ b/google-calendar/server/tools/calendars.ts @@ -0,0 +1,231 @@ +/** + * Calendar Management Tools + * + * Tools for listing, getting, creating, and deleting calendars + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { GoogleCalendarClient, getAccessToken } from "../lib/google-client.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const CalendarSchema = z.object({ + id: z.string().describe("Calendar ID"), + summary: z.string().describe("Calendar name/title"), + description: z.string().optional().describe("Calendar description"), + location: z.string().optional().describe("Geographic location"), + timeZone: z.string().optional().describe("Calendar timezone"), + accessRole: z.string().optional().describe("User's access role"), + primary: z + .boolean() + .optional() + .describe("Whether this is the primary calendar"), + backgroundColor: z.string().optional().describe("Background color"), + foregroundColor: z.string().optional().describe("Foreground color"), +}); + +// ============================================================================ +// List Calendars Tool +// ============================================================================ + +export const createListCalendarsTool = (env: Env) => + createPrivateTool({ + id: "list_calendars", + description: + "List all calendars accessible by the authenticated user. Returns calendar IDs, names, colors, and access roles.", + inputSchema: z.object({ + maxResults: z + .number() + .int() + .min(1) + .max(250) + .optional() + .describe("Maximum number of calendars to return (default: 50)"), + pageToken: z + .string() + .optional() + .describe("Token for fetching next page of results"), + }), + outputSchema: z.object({ + calendars: z.array(CalendarSchema).describe("List of calendars"), + nextPageToken: z + .string() + .optional() + .describe("Token for fetching next page"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const response = await client.listCalendars( + context.pageToken, + context.maxResults, + ); + + return { + calendars: response.items.map((cal) => ({ + id: cal.id, + summary: cal.summary, + description: cal.description, + location: cal.location, + timeZone: cal.timeZone, + accessRole: cal.accessRole, + primary: cal.primary, + backgroundColor: cal.backgroundColor, + foregroundColor: cal.foregroundColor, + })), + nextPageToken: response.nextPageToken, + }; + }, + }); + +// ============================================================================ +// Get Calendar Tool +// ============================================================================ + +export const createGetCalendarTool = (env: Env) => + createPrivateTool({ + id: "get_calendar", + description: + "Get detailed information about a specific calendar by its ID.", + inputSchema: z.object({ + calendarId: z + .string() + .describe( + "Calendar ID (use 'primary' for the user's primary calendar)", + ), + }), + outputSchema: z.object({ + calendar: CalendarSchema.describe("Calendar details"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const calendar = await client.getCalendar(context.calendarId); + + return { + calendar: { + id: calendar.id, + summary: calendar.summary, + description: calendar.description, + location: calendar.location, + timeZone: calendar.timeZone, + accessRole: calendar.accessRole, + primary: calendar.primary, + backgroundColor: calendar.backgroundColor, + foregroundColor: calendar.foregroundColor, + }, + }; + }, + }); + +// ============================================================================ +// Create Calendar Tool +// ============================================================================ + +export const createCreateCalendarTool = (env: Env) => + createPrivateTool({ + id: "create_calendar", + description: + "Create a new secondary calendar. Note: You cannot create a new primary calendar.", + inputSchema: z.object({ + summary: z.string().describe("Name of the new calendar"), + description: z + .string() + .optional() + .describe("Description of the calendar"), + location: z + .string() + .optional() + .describe("Geographic location of the calendar"), + timeZone: z + .string() + .optional() + .describe("Timezone (e.g., 'America/Sao_Paulo', 'UTC')"), + }), + outputSchema: z.object({ + calendar: z.object({ + id: z.string().describe("ID of the created calendar"), + summary: z.string().describe("Calendar name"), + description: z.string().optional(), + location: z.string().optional(), + timeZone: z.string().optional(), + }), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const calendar = await client.createCalendar({ + summary: context.summary, + description: context.description, + location: context.location, + timeZone: context.timeZone, + }); + + return { + calendar: { + id: calendar.id, + summary: calendar.summary, + description: calendar.description, + location: calendar.location, + timeZone: calendar.timeZone, + }, + }; + }, + }); + +// ============================================================================ +// Delete Calendar Tool +// ============================================================================ + +export const createDeleteCalendarTool = (env: Env) => + createPrivateTool({ + id: "delete_calendar", + description: + "Delete a secondary calendar. Note: You cannot delete the primary calendar.", + inputSchema: z.object({ + calendarId: z + .string() + .describe("ID of the calendar to delete (cannot be 'primary')"), + }), + outputSchema: z.object({ + success: z.boolean().describe("Whether the deletion was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + if (context.calendarId === "primary") { + throw new Error("Cannot delete the primary calendar"); + } + + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + await client.deleteCalendar(context.calendarId); + + return { + success: true, + message: `Calendar ${context.calendarId} deleted successfully`, + }; + }, + }); + +// ============================================================================ +// Export all calendar tools +// ============================================================================ + +export const calendarTools = [ + createListCalendarsTool, + createGetCalendarTool, + createCreateCalendarTool, + createDeleteCalendarTool, +]; diff --git a/google-calendar/server/tools/events.ts b/google-calendar/server/tools/events.ts new file mode 100644 index 00000000..184fff98 --- /dev/null +++ b/google-calendar/server/tools/events.ts @@ -0,0 +1,530 @@ +/** + * Event Management Tools + * + * Tools for listing, getting, creating, updating, and deleting events + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { GoogleCalendarClient, getAccessToken } from "../lib/google-client.ts"; +import { PRIMARY_CALENDAR } from "../constants.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const EventDateTimeSchema = z.object({ + date: z + .string() + .optional() + .describe("Date for all-day events (YYYY-MM-DD format)"), + dateTime: z + .string() + .optional() + .describe( + "DateTime for timed events (RFC3339 format, e.g., 2024-01-15T10:00:00-03:00)", + ), + timeZone: z + .string() + .optional() + .describe("Timezone (e.g., 'America/Sao_Paulo')"), +}); + +const AttendeeSchema = z.object({ + email: z.string().email().describe("Attendee email address"), + displayName: z.string().optional().describe("Attendee display name"), + optional: z.boolean().optional().describe("Whether attendance is optional"), + responseStatus: z + .enum(["needsAction", "declined", "tentative", "accepted"]) + .optional() + .describe("Attendee response status"), +}); + +const ReminderSchema = z.object({ + method: z.enum(["email", "popup"]).describe("Reminder method"), + minutes: z.number().int().min(0).describe("Minutes before event to remind"), +}); + +const EventSchema = z.object({ + id: z.string().describe("Event ID"), + summary: z.string().optional().describe("Event title"), + description: z.string().optional().describe("Event description"), + location: z.string().optional().describe("Event location"), + start: EventDateTimeSchema.describe("Event start time"), + end: EventDateTimeSchema.describe("Event end time"), + status: z + .enum(["confirmed", "tentative", "cancelled"]) + .optional() + .describe("Event status"), + htmlLink: z + .string() + .optional() + .describe("Link to the event in Google Calendar"), + created: z.string().optional().describe("Creation timestamp"), + updated: z.string().optional().describe("Last update timestamp"), + creator: z + .object({ + email: z.string().optional(), + displayName: z.string().optional(), + self: z.boolean().optional(), + }) + .optional() + .describe("Event creator"), + organizer: z + .object({ + email: z.string().optional(), + displayName: z.string().optional(), + self: z.boolean().optional(), + }) + .optional() + .describe("Event organizer"), + attendees: z.array(AttendeeSchema).optional().describe("Event attendees"), + hangoutLink: z.string().optional().describe("Google Meet link"), + colorId: z.string().optional().describe("Event color ID"), + visibility: z + .enum(["default", "public", "private", "confidential"]) + .optional() + .describe("Event visibility"), +}); + +// ============================================================================ +// List Events Tool +// ============================================================================ + +export const createListEventsTool = (env: Env) => + createPrivateTool({ + id: "list_events", + description: + "List events from a calendar with optional filters for date range, search query, and pagination.", + inputSchema: z.object({ + calendarId: z + .string() + .optional() + .describe("Calendar ID (default: 'primary')"), + timeMin: z + .string() + .optional() + .describe( + "Start of time range (RFC3339 format). Required if singleEvents is true.", + ), + timeMax: z + .string() + .optional() + .describe("End of time range (RFC3339 format)"), + maxResults: z + .number() + .int() + .min(1) + .max(2500) + .optional() + .describe("Maximum number of events to return (default: 50)"), + pageToken: z.string().optional().describe("Token for fetching next page"), + q: z.string().optional().describe("Free text search query"), + singleEvents: z + .boolean() + .optional() + .describe("Expand recurring events into instances (requires timeMin)"), + orderBy: z + .enum(["startTime", "updated"]) + .optional() + .describe("Order by field (startTime requires singleEvents=true)"), + showDeleted: z.boolean().optional().describe("Include deleted events"), + }), + outputSchema: z.object({ + events: z.array(EventSchema).describe("List of events"), + nextPageToken: z.string().optional().describe("Token for next page"), + summary: z.string().optional().describe("Calendar name"), + timeZone: z.string().optional().describe("Calendar timezone"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const response = await client.listEvents({ + calendarId: context.calendarId || PRIMARY_CALENDAR, + timeMin: context.timeMin, + timeMax: context.timeMax, + maxResults: context.maxResults, + pageToken: context.pageToken, + q: context.q, + singleEvents: context.singleEvents, + orderBy: context.orderBy, + showDeleted: context.showDeleted, + }); + + return { + events: response.items.map((event) => ({ + id: event.id, + summary: event.summary, + description: event.description, + location: event.location, + start: event.start, + end: event.end, + status: event.status, + htmlLink: event.htmlLink, + created: event.created, + updated: event.updated, + creator: event.creator, + organizer: event.organizer, + attendees: event.attendees, + hangoutLink: event.hangoutLink, + colorId: event.colorId, + visibility: event.visibility, + })), + nextPageToken: response.nextPageToken, + summary: response.summary, + timeZone: response.timeZone, + }; + }, + }); + +// ============================================================================ +// Get Event Tool +// ============================================================================ + +export const createGetEventTool = (env: Env) => + createPrivateTool({ + id: "get_event", + description: "Get detailed information about a specific event by its ID.", + inputSchema: z.object({ + calendarId: z + .string() + .optional() + .describe("Calendar ID (default: 'primary')"), + eventId: z.string().describe("Event ID"), + }), + outputSchema: z.object({ + event: EventSchema.describe("Event details"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const event = await client.getEvent( + context.calendarId || PRIMARY_CALENDAR, + context.eventId, + ); + + return { + event: { + id: event.id, + summary: event.summary, + description: event.description, + location: event.location, + start: event.start, + end: event.end, + status: event.status, + htmlLink: event.htmlLink, + created: event.created, + updated: event.updated, + creator: event.creator, + organizer: event.organizer, + attendees: event.attendees, + hangoutLink: event.hangoutLink, + colorId: event.colorId, + visibility: event.visibility, + }, + }; + }, + }); + +// ============================================================================ +// Create Event Tool +// ============================================================================ + +export const createCreateEventTool = (env: Env) => + createPrivateTool({ + id: "create_event", + description: + "Create a new event in a calendar. Supports attendees, reminders, and all-day or timed events.", + inputSchema: z.object({ + calendarId: z + .string() + .optional() + .describe("Calendar ID (default: 'primary')"), + summary: z.string().describe("Event title"), + description: z.string().optional().describe("Event description"), + location: z.string().optional().describe("Event location"), + start: EventDateTimeSchema.describe( + "Event start (use 'date' for all-day, 'dateTime' for timed events)", + ), + end: EventDateTimeSchema.describe( + "Event end (use 'date' for all-day, 'dateTime' for timed events)", + ), + attendees: z + .array( + z.object({ + email: z.string().email().describe("Attendee email"), + displayName: z.string().optional().describe("Display name"), + optional: z.boolean().optional().describe("Is attendance optional"), + }), + ) + .optional() + .describe("List of attendees to invite"), + reminders: z + .object({ + useDefault: z.boolean().describe("Use default reminders"), + overrides: z + .array(ReminderSchema) + .optional() + .describe("Custom reminders"), + }) + .optional() + .describe("Reminder settings"), + colorId: z.string().optional().describe("Event color ID (1-11)"), + visibility: z + .enum(["default", "public", "private", "confidential"]) + .optional() + .describe("Event visibility"), + sendUpdates: z + .enum(["all", "externalOnly", "none"]) + .optional() + .describe("Who should receive email notifications"), + }), + outputSchema: z.object({ + event: EventSchema.describe("Created event"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const event = await client.createEvent({ + calendarId: context.calendarId || PRIMARY_CALENDAR, + summary: context.summary, + description: context.description, + location: context.location, + start: context.start, + end: context.end, + attendees: context.attendees, + reminders: context.reminders, + colorId: context.colorId, + visibility: context.visibility, + sendUpdates: context.sendUpdates, + }); + + return { + event: { + id: event.id, + summary: event.summary, + description: event.description, + location: event.location, + start: event.start, + end: event.end, + status: event.status, + htmlLink: event.htmlLink, + created: event.created, + updated: event.updated, + creator: event.creator, + organizer: event.organizer, + attendees: event.attendees, + hangoutLink: event.hangoutLink, + colorId: event.colorId, + visibility: event.visibility, + }, + }; + }, + }); + +// ============================================================================ +// Update Event Tool +// ============================================================================ + +export const createUpdateEventTool = (env: Env) => + createPrivateTool({ + id: "update_event", + description: + "Update an existing event. Only provided fields will be updated.", + inputSchema: z.object({ + calendarId: z + .string() + .optional() + .describe("Calendar ID (default: 'primary')"), + eventId: z.string().describe("Event ID to update"), + summary: z.string().optional().describe("New event title"), + description: z.string().optional().describe("New event description"), + location: z.string().optional().describe("New event location"), + start: EventDateTimeSchema.optional().describe("New start time"), + end: EventDateTimeSchema.optional().describe("New end time"), + attendees: z + .array( + z.object({ + email: z.string().email(), + displayName: z.string().optional(), + optional: z.boolean().optional(), + }), + ) + .optional() + .describe("Updated attendees list"), + colorId: z.string().optional().describe("New color ID"), + visibility: z + .enum(["default", "public", "private", "confidential"]) + .optional() + .describe("New visibility setting"), + sendUpdates: z + .enum(["all", "externalOnly", "none"]) + .optional() + .describe("Who should receive email notifications"), + }), + outputSchema: z.object({ + event: EventSchema.describe("Updated event"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const event = await client.updateEvent({ + calendarId: context.calendarId || PRIMARY_CALENDAR, + eventId: context.eventId, + summary: context.summary, + description: context.description, + location: context.location, + start: context.start, + end: context.end, + attendees: context.attendees, + colorId: context.colorId, + visibility: context.visibility, + sendUpdates: context.sendUpdates, + }); + + return { + event: { + id: event.id, + summary: event.summary, + description: event.description, + location: event.location, + start: event.start, + end: event.end, + status: event.status, + htmlLink: event.htmlLink, + created: event.created, + updated: event.updated, + creator: event.creator, + organizer: event.organizer, + attendees: event.attendees, + hangoutLink: event.hangoutLink, + colorId: event.colorId, + visibility: event.visibility, + }, + }; + }, + }); + +// ============================================================================ +// Delete Event Tool +// ============================================================================ + +export const createDeleteEventTool = (env: Env) => + createPrivateTool({ + id: "delete_event", + description: "Delete an event from a calendar.", + inputSchema: z.object({ + calendarId: z + .string() + .optional() + .describe("Calendar ID (default: 'primary')"), + eventId: z.string().describe("Event ID to delete"), + sendUpdates: z + .enum(["all", "externalOnly", "none"]) + .optional() + .describe("Who should receive cancellation notifications"), + }), + outputSchema: z.object({ + success: z.boolean().describe("Whether deletion was successful"), + message: z.string().describe("Result message"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + await client.deleteEvent( + context.calendarId || PRIMARY_CALENDAR, + context.eventId, + context.sendUpdates, + ); + + return { + success: true, + message: `Event ${context.eventId} deleted successfully`, + }; + }, + }); + +// ============================================================================ +// Quick Add Event Tool +// ============================================================================ + +export const createQuickAddEventTool = (env: Env) => + createPrivateTool({ + id: "quick_add_event", + description: + "Create an event using natural language text. Google Calendar will parse the text to extract event details like date, time, and title. Examples: 'Meeting with John tomorrow at 3pm', 'Dentist appointment on Friday at 10am'", + inputSchema: z.object({ + calendarId: z + .string() + .optional() + .describe("Calendar ID (default: 'primary')"), + text: z + .string() + .describe( + "Natural language description of the event (e.g., 'Meeting with John tomorrow at 3pm')", + ), + sendUpdates: z + .enum(["all", "externalOnly", "none"]) + .optional() + .describe("Who should receive email notifications"), + }), + outputSchema: z.object({ + event: EventSchema.describe("Created event"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const event = await client.quickAddEvent( + context.calendarId || PRIMARY_CALENDAR, + context.text, + context.sendUpdates, + ); + + return { + event: { + id: event.id, + summary: event.summary, + description: event.description, + location: event.location, + start: event.start, + end: event.end, + status: event.status, + htmlLink: event.htmlLink, + created: event.created, + updated: event.updated, + creator: event.creator, + organizer: event.organizer, + attendees: event.attendees, + hangoutLink: event.hangoutLink, + colorId: event.colorId, + visibility: event.visibility, + }, + }; + }, + }); + +// ============================================================================ +// Export all event tools +// ============================================================================ + +export const eventTools = [ + createListEventsTool, + createGetEventTool, + createCreateEventTool, + createUpdateEventTool, + createDeleteEventTool, + createQuickAddEventTool, +]; diff --git a/google-calendar/server/tools/freebusy.ts b/google-calendar/server/tools/freebusy.ts new file mode 100644 index 00000000..62e6a9f2 --- /dev/null +++ b/google-calendar/server/tools/freebusy.ts @@ -0,0 +1,108 @@ +/** + * Free/Busy Tool + * + * Tool for checking availability across calendars + */ + +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { GoogleCalendarClient, getAccessToken } from "../lib/google-client.ts"; +import { PRIMARY_CALENDAR } from "../constants.ts"; + +// ============================================================================ +// Schema Definitions +// ============================================================================ + +const BusyPeriodSchema = z.object({ + start: z.string().describe("Start time of busy period (RFC3339)"), + end: z.string().describe("End time of busy period (RFC3339)"), +}); + +const CalendarFreeBusySchema = z.object({ + calendarId: z.string().describe("Calendar ID"), + busy: z.array(BusyPeriodSchema).describe("List of busy time periods"), + errors: z + .array( + z.object({ + domain: z.string(), + reason: z.string(), + }), + ) + .optional() + .describe("Any errors for this calendar"), +}); + +// ============================================================================ +// Get FreeBusy Tool +// ============================================================================ + +export const createGetFreeBusyTool = (env: Env) => + createPrivateTool({ + id: "get_freebusy", + description: + "Check free/busy information for one or more calendars within a time range. Useful for finding available meeting times or checking someone's availability.", + inputSchema: z.object({ + timeMin: z + .string() + .describe( + "Start of the time range to query (RFC3339 format, e.g., '2024-01-15T00:00:00Z')", + ), + timeMax: z + .string() + .describe( + "End of the time range to query (RFC3339 format, e.g., '2024-01-22T00:00:00Z')", + ), + calendarIds: z + .array(z.string()) + .optional() + .describe( + "List of calendar IDs to query. Defaults to ['primary'] if not specified.", + ), + timeZone: z + .string() + .optional() + .describe("Timezone for the query (e.g., 'America/Sao_Paulo')"), + }), + outputSchema: z.object({ + timeMin: z.string().describe("Start of queried time range"), + timeMax: z.string().describe("End of queried time range"), + calendars: z + .array(CalendarFreeBusySchema) + .describe("Free/busy information for each calendar"), + }), + execute: async ({ context }) => { + const client = new GoogleCalendarClient({ + accessToken: getAccessToken(env), + }); + + const calendarIds = context.calendarIds || [PRIMARY_CALENDAR]; + + const response = await client.getFreeBusy({ + timeMin: context.timeMin, + timeMax: context.timeMax, + timeZone: context.timeZone, + items: calendarIds.map((id) => ({ id })), + }); + + const calendars = Object.entries(response.calendars).map( + ([calendarId, data]) => ({ + calendarId, + busy: data.busy, + errors: data.errors, + }), + ); + + return { + timeMin: response.timeMin, + timeMax: response.timeMax, + calendars, + }; + }, + }); + +// ============================================================================ +// Export freebusy tools +// ============================================================================ + +export const freebusyTools = [createGetFreeBusyTool]; diff --git a/google-calendar/server/tools/index.ts b/google-calendar/server/tools/index.ts new file mode 100644 index 00000000..d92aa015 --- /dev/null +++ b/google-calendar/server/tools/index.ts @@ -0,0 +1,29 @@ +/** + * Central export point for all Google Calendar tools + * + * This file aggregates all tools from different modules into a single + * export, making it easy to import all tools in main.ts. + * + * Tools: + * - calendarTools: Calendar management (list, get, create, delete) + * - eventTools: Event management (list, get, create, update, delete, quick_add) + * - freebusyTools: Availability checking (get_freebusy) + * - advancedTools: Advanced operations (move_event, find_available_slots, duplicate_event) + */ + +import { calendarTools } from "./calendars.ts"; +import { eventTools } from "./events.ts"; +import { freebusyTools } from "./freebusy.ts"; +import { advancedTools } from "./advanced.ts"; + +// Export all tools from all modules +export const tools = [ + // Calendar management tools + ...calendarTools, + // Event management tools + ...eventTools, + // Free/busy availability tools + ...freebusyTools, + // Advanced tools + ...advancedTools, +]; diff --git a/google-calendar/tsconfig.json b/google-calendar/tsconfig.json new file mode 100644 index 00000000..a7e0e946 --- /dev/null +++ b/google-calendar/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "allowJs": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "server/*": ["./server/*"] + } + }, + "include": [ + "server" + ] +} + diff --git a/package.json b/package.json index 96b34d63..d472e825 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "data-for-seo", "datajud", "gemini-pro-vision", + "google-calendar", "meta-ads", "nanobanana", "object-storage", From 1b3659ecf3ebf04288f260e6fac67429640f060d Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 08:26:57 -0300 Subject: [PATCH 2/9] fix: handle Google OAuth state parameter correctly Google OAuth doesn't allow 'state' inside redirect_uri. Extract state from callbackUrl and pass it as separate OAuth param. --- google-calendar/server/main.ts | 37 ++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/google-calendar/server/main.ts b/google-calendar/server/main.ts index 157f8f23..8c15ed6e 100644 --- a/google-calendar/server/main.ts +++ b/google-calendar/server/main.ts @@ -27,18 +27,46 @@ const runtime = withRuntime({ // Generates the URL to redirect users to for authorization authorizationUrl: (callbackUrl) => { + // Parse the callback URL to extract base URL and state parameter + // Google OAuth doesn't allow 'state' inside redirect_uri + const callbackUrlObj = new URL(callbackUrl); + const state = callbackUrlObj.searchParams.get("state"); + + // Remove state from redirect_uri (Google requires clean redirect_uri) + callbackUrlObj.searchParams.delete("state"); + const cleanRedirectUri = callbackUrlObj.toString(); + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); - url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("redirect_uri", cleanRedirectUri); url.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", GOOGLE_CALENDAR_SCOPES); url.searchParams.set("access_type", "offline"); url.searchParams.set("prompt", "consent"); + + // Pass state as a separate OAuth parameter (Google will return it in the callback) + if (state) { + url.searchParams.set("state", state); + } + return url.toString(); }, // Exchanges the authorization code for access token - exchangeCode: async ({ code, code_verifier, code_challenge_method }) => { + exchangeCode: async ({ + code, + code_verifier, + code_challenge_method, + redirect_uri, + }) => { + // Clean the redirect_uri (remove state param if present) + let cleanRedirectUri = redirect_uri; + if (redirect_uri) { + const redirectUrlObj = new URL(redirect_uri); + redirectUrlObj.searchParams.delete("state"); + cleanRedirectUri = redirectUrlObj.toString(); + } + const params = new URLSearchParams({ code, client_id: process.env.GOOGLE_CLIENT_ID!, @@ -46,6 +74,11 @@ const runtime = withRuntime({ grant_type: "authorization_code", }); + // Google requires redirect_uri in token exchange + if (cleanRedirectUri) { + params.set("redirect_uri", cleanRedirectUri); + } + // Add PKCE verifier if provided if (code_verifier) { params.set("code_verifier", code_verifier); From c576fda7e0920aedb1524ad411cef544c2518537 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 08:50:23 -0300 Subject: [PATCH 3/9] fix: improve type safety for accessRole and OAuth params - Add CalendarAccessRole type for better type safety - Fix redirect_uri handling in exchangeCode - Fix getAccessToken type compatibility with Env --- google-calendar/server/lib/google-client.ts | 4 +-- google-calendar/server/lib/types.ts | 10 ++++-- google-calendar/server/main.ts | 36 +++++++++++++-------- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/google-calendar/server/lib/google-client.ts b/google-calendar/server/lib/google-client.ts index 997df21d..9fc67669 100644 --- a/google-calendar/server/lib/google-client.ts +++ b/google-calendar/server/lib/google-client.ts @@ -352,9 +352,7 @@ export class GoogleCalendarClient { /** * Get access token from environment context */ -export function getAccessToken(env: { - MESH_REQUEST_CONTEXT?: { accessToken?: string }; -}): string { +export function getAccessToken(env: any): string { const token = env.MESH_REQUEST_CONTEXT?.accessToken; if (!token) { throw new Error( diff --git a/google-calendar/server/lib/types.ts b/google-calendar/server/lib/types.ts index cc6335d9..5134b8b3 100644 --- a/google-calendar/server/lib/types.ts +++ b/google-calendar/server/lib/types.ts @@ -2,6 +2,12 @@ * Google Calendar API types */ +export type CalendarAccessRole = + | "freeBusyReader" + | "reader" + | "writer" + | "owner"; + export interface CalendarListEntry { kind: "calendar#calendarListEntry"; etag: string; @@ -16,7 +22,7 @@ export interface CalendarListEntry { foregroundColor?: string; hidden?: boolean; selected?: boolean; - accessRole: "freeBusyReader" | "reader" | "writer" | "owner"; + accessRole: CalendarAccessRole; defaultReminders?: Reminder[]; primary?: boolean; deleted?: boolean; @@ -145,7 +151,7 @@ export interface EventsListResponse { description?: string; updated: string; timeZone: string; - accessRole: string; + accessRole: CalendarAccessRole; nextPageToken?: string; nextSyncToken?: string; items: Event[]; diff --git a/google-calendar/server/main.ts b/google-calendar/server/main.ts index 8c15ed6e..225dc600 100644 --- a/google-calendar/server/main.ts +++ b/google-calendar/server/main.ts @@ -19,6 +19,9 @@ const GOOGLE_CALENDAR_SCOPES = [ "https://www.googleapis.com/auth/calendar.events", ].join(" "); +// Store the last used redirect_uri for token exchange +let lastRedirectUri: string | null = null; + const runtime = withRuntime({ oauth: { mode: "PKCE", @@ -36,6 +39,12 @@ const runtime = withRuntime({ callbackUrlObj.searchParams.delete("state"); const cleanRedirectUri = callbackUrlObj.toString(); + // Store for later use in exchangeCode + lastRedirectUri = cleanRedirectUri; + + // Debug: log the redirect_uri being used + console.log("[Google Calendar OAuth] redirect_uri:", cleanRedirectUri); + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); url.searchParams.set("redirect_uri", cleanRedirectUri); url.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!); @@ -57,28 +66,29 @@ const runtime = withRuntime({ code, code_verifier, code_challenge_method, - redirect_uri, - }) => { - // Clean the redirect_uri (remove state param if present) - let cleanRedirectUri = redirect_uri; - if (redirect_uri) { - const redirectUrlObj = new URL(redirect_uri); - redirectUrlObj.searchParams.delete("state"); - cleanRedirectUri = redirectUrlObj.toString(); + }: any) => { + // Use the stored redirect_uri from authorizationUrl + const cleanRedirectUri = lastRedirectUri; + + if (!cleanRedirectUri) { + throw new Error( + "redirect_uri is required for Google OAuth token exchange", + ); } + console.log( + "[Google Calendar OAuth] exchangeCode redirect_uri:", + cleanRedirectUri, + ); + const params = new URLSearchParams({ code, client_id: process.env.GOOGLE_CLIENT_ID!, client_secret: process.env.GOOGLE_CLIENT_SECRET!, grant_type: "authorization_code", + redirect_uri: cleanRedirectUri, }); - // Google requires redirect_uri in token exchange - if (cleanRedirectUri) { - params.set("redirect_uri", cleanRedirectUri); - } - // Add PKCE verifier if provided if (code_verifier) { params.set("code_verifier", code_verifier); From bd59c302d0d9343ec28db68c0474a6177a7d1471 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 08:51:01 -0300 Subject: [PATCH 4/9] chore: remove debug console.log statements --- google-calendar/server/main.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/google-calendar/server/main.ts b/google-calendar/server/main.ts index 225dc600..2aa08e8c 100644 --- a/google-calendar/server/main.ts +++ b/google-calendar/server/main.ts @@ -42,9 +42,6 @@ const runtime = withRuntime({ // Store for later use in exchangeCode lastRedirectUri = cleanRedirectUri; - // Debug: log the redirect_uri being used - console.log("[Google Calendar OAuth] redirect_uri:", cleanRedirectUri); - const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); url.searchParams.set("redirect_uri", cleanRedirectUri); url.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!); @@ -76,11 +73,6 @@ const runtime = withRuntime({ ); } - console.log( - "[Google Calendar OAuth] exchangeCode redirect_uri:", - cleanRedirectUri, - ); - const params = new URLSearchParams({ code, client_id: process.env.GOOGLE_CLIENT_ID!, From 088a393973b5b40c322c8e419dea4da2f21f75e1 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 08:52:52 -0300 Subject: [PATCH 5/9] chore: remove debug logs --- google-calendar/server/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/google-calendar/server/main.ts b/google-calendar/server/main.ts index 2aa08e8c..306a7d6e 100644 --- a/google-calendar/server/main.ts +++ b/google-calendar/server/main.ts @@ -67,6 +67,9 @@ const runtime = withRuntime({ // Use the stored redirect_uri from authorizationUrl const cleanRedirectUri = lastRedirectUri; + console.log("[DEBUG] lastRedirectUri:", lastRedirectUri); + console.log("[DEBUG] cleanRedirectUri:", cleanRedirectUri); + if (!cleanRedirectUri) { throw new Error( "redirect_uri is required for Google OAuth token exchange", @@ -81,6 +84,8 @@ const runtime = withRuntime({ redirect_uri: cleanRedirectUri, }); + console.log("[DEBUG] params redirect_uri:", params.get("redirect_uri")); + // Add PKCE verifier if provided if (code_verifier) { params.set("code_verifier", code_verifier); From de17ee1df83afb8651c001eabe536d9b1a284813 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 08:56:23 -0300 Subject: [PATCH 6/9] fix: initialize tools with env context for OAuth Tools need to receive the env parameter to access the authenticated user's access token. --- google-calendar/server/main.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/google-calendar/server/main.ts b/google-calendar/server/main.ts index 306a7d6e..8a9d4353 100644 --- a/google-calendar/server/main.ts +++ b/google-calendar/server/main.ts @@ -23,6 +23,7 @@ const GOOGLE_CALENDAR_SCOPES = [ let lastRedirectUri: string | null = null; const runtime = withRuntime({ + tools: (env: Env) => tools.map((createTool) => createTool(env)), oauth: { mode: "PKCE", // Used in protected resource metadata to point to the auth server @@ -67,9 +68,6 @@ const runtime = withRuntime({ // Use the stored redirect_uri from authorizationUrl const cleanRedirectUri = lastRedirectUri; - console.log("[DEBUG] lastRedirectUri:", lastRedirectUri); - console.log("[DEBUG] cleanRedirectUri:", cleanRedirectUri); - if (!cleanRedirectUri) { throw new Error( "redirect_uri is required for Google OAuth token exchange", @@ -84,8 +82,6 @@ const runtime = withRuntime({ redirect_uri: cleanRedirectUri, }); - console.log("[DEBUG] params redirect_uri:", params.get("redirect_uri")); - // Add PKCE verifier if provided if (code_verifier) { params.set("code_verifier", code_verifier); @@ -120,7 +116,6 @@ const runtime = withRuntime({ }; }, }, - tools, }); serve(runtime.fetch); From be1b0cab6ab85cf5919b06137fd9b78fe536a2ee Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 09:04:58 -0300 Subject: [PATCH 7/9] feat: add typed env and fix numeric coercion in tools - Add shared/deco.gen.ts with typed Env and MeshRequestContext - Add server/lib/env.ts with getGoogleAccessToken helper - Update main.ts to use typed Env from shared/deco.gen.ts - Update google-client.ts to use env.ts helper - Fix all numeric schema fields to use z.coerce.number() for string-to-number conversion --- google-calendar/server/lib/env.ts | 17 ++++++ google-calendar/server/lib/google-client.ts | 14 +---- google-calendar/server/main.ts | 8 +-- google-calendar/server/tools/advanced.ts | 4 +- google-calendar/server/tools/calendars.ts | 2 +- google-calendar/server/tools/events.ts | 8 ++- google-calendar/shared/deco.gen.ts | 63 +++++++++++++++++++++ 7 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 google-calendar/server/lib/env.ts create mode 100644 google-calendar/shared/deco.gen.ts diff --git a/google-calendar/server/lib/env.ts b/google-calendar/server/lib/env.ts new file mode 100644 index 00000000..46a27424 --- /dev/null +++ b/google-calendar/server/lib/env.ts @@ -0,0 +1,17 @@ +import type { Env } from "../../shared/deco.gen.ts"; + +/** + * Get Google OAuth access token from environment context + * @param env - The environment containing the mesh request context + * @returns The OAuth access token + * @throws Error if not authenticated + */ +export const getGoogleAccessToken = (env: Env): string => { + const authorization = env.MESH_REQUEST_CONTEXT?.authorization; + if (!authorization) { + throw new Error( + "Not authenticated. Please authorize with Google Calendar first.", + ); + } + return authorization; +}; diff --git a/google-calendar/server/lib/google-client.ts b/google-calendar/server/lib/google-client.ts index 9fc67669..bd3c4935 100644 --- a/google-calendar/server/lib/google-client.ts +++ b/google-calendar/server/lib/google-client.ts @@ -349,15 +349,5 @@ export class GoogleCalendarClient { } } -/** - * Get access token from environment context - */ -export function getAccessToken(env: any): string { - const token = env.MESH_REQUEST_CONTEXT?.accessToken; - if (!token) { - throw new Error( - "Not authenticated. Please authorize with Google Calendar first.", - ); - } - return token; -} +// Re-export getGoogleAccessToken from env.ts for convenience +export { getGoogleAccessToken as getAccessToken } from "./env.ts"; diff --git a/google-calendar/server/main.ts b/google-calendar/server/main.ts index 8a9d4353..ace1c143 100644 --- a/google-calendar/server/main.ts +++ b/google-calendar/server/main.ts @@ -4,15 +4,13 @@ * This MCP provides tools for interacting with Google Calendar API, * including calendar management, event CRUD operations, and availability checks. */ -import { type DefaultEnv, withRuntime } from "@decocms/runtime"; +import { withRuntime } from "@decocms/runtime"; import { serve } from "@decocms/mcps-shared/serve"; import { tools } from "./tools/index.ts"; +import type { Env } from "../shared/deco.gen.ts"; -/** - * Environment type for the MCP server - */ -export type Env = DefaultEnv; +export type { Env }; const GOOGLE_CALENDAR_SCOPES = [ "https://www.googleapis.com/auth/calendar", diff --git a/google-calendar/server/tools/advanced.ts b/google-calendar/server/tools/advanced.ts index ac9dce5b..58a85ffc 100644 --- a/google-calendar/server/tools/advanced.ts +++ b/google-calendar/server/tools/advanced.ts @@ -132,7 +132,7 @@ export const createFindAvailableSlotsTool = (env: Env) => .describe( "End of the search range (RFC3339 format, e.g., '2024-01-15T18:00:00Z')", ), - slotDurationMinutes: z + slotDurationMinutes: z.coerce .number() .int() .min(5) @@ -144,7 +144,7 @@ export const createFindAvailableSlotsTool = (env: Env) => .string() .optional() .describe("Timezone for the search (e.g., 'America/Sao_Paulo')"), - maxSlots: z + maxSlots: z.coerce .number() .int() .min(1) diff --git a/google-calendar/server/tools/calendars.ts b/google-calendar/server/tools/calendars.ts index 0f0db5a4..2b62dbab 100644 --- a/google-calendar/server/tools/calendars.ts +++ b/google-calendar/server/tools/calendars.ts @@ -38,7 +38,7 @@ export const createListCalendarsTool = (env: Env) => description: "List all calendars accessible by the authenticated user. Returns calendar IDs, names, colors, and access roles.", inputSchema: z.object({ - maxResults: z + maxResults: z.coerce .number() .int() .min(1) diff --git a/google-calendar/server/tools/events.ts b/google-calendar/server/tools/events.ts index 184fff98..460148b2 100644 --- a/google-calendar/server/tools/events.ts +++ b/google-calendar/server/tools/events.ts @@ -43,7 +43,11 @@ const AttendeeSchema = z.object({ const ReminderSchema = z.object({ method: z.enum(["email", "popup"]).describe("Reminder method"), - minutes: z.number().int().min(0).describe("Minutes before event to remind"), + minutes: z.coerce + .number() + .int() + .min(0) + .describe("Minutes before event to remind"), }); const EventSchema = z.object({ @@ -112,7 +116,7 @@ export const createListEventsTool = (env: Env) => .string() .optional() .describe("End of time range (RFC3339 format)"), - maxResults: z + maxResults: z.coerce .number() .int() .min(1) diff --git a/google-calendar/shared/deco.gen.ts b/google-calendar/shared/deco.gen.ts new file mode 100644 index 00000000..3dc5d3ba --- /dev/null +++ b/google-calendar/shared/deco.gen.ts @@ -0,0 +1,63 @@ +// Generated types for Google Calendar MCP + +import { z } from "zod"; + +/** + * Mesh request context injected by the Deco runtime + * Contains authentication and metadata for the current request + */ +export interface MeshRequestContext { + /** OAuth access token from Google */ + authorization?: string; + /** Internal state for OAuth flow */ + state?: string; + /** JWT token for the request */ + token?: string; + /** URL of the mesh server */ + meshUrl?: string; + /** Connection ID for this session */ + connectionId?: string; + /** Function to ensure user is authenticated */ + ensureAuthenticated?: () => Promise; +} + +/** + * Environment type for Google Calendar MCP + * Extends process env with Deco runtime context + */ +export interface Env { + /** Google OAuth Client ID */ + GOOGLE_CLIENT_ID: string; + /** Google OAuth Client Secret */ + GOOGLE_CLIENT_SECRET: string; + /** Mesh request context injected by runtime */ + MESH_REQUEST_CONTEXT: MeshRequestContext; + /** Self-reference MCP (if needed) */ + SELF?: unknown; + /** Whether running locally */ + IS_LOCAL?: boolean; +} + +/** + * State schema for OAuth flow validation + */ +export const StateSchema = z.object({}); + +/** + * MCP type helper for typed tool definitions + */ +export type Mcp Promise>> = { + [K in keyof T]: (( + input: Parameters[0], + ) => Promise>>) & { + asTool: () => Promise<{ + inputSchema: z.ZodType[0]>; + outputSchema?: z.ZodType>>; + description: string; + id: string; + execute: ( + input: Parameters[0], + ) => Promise>>; + }>; + }; +}; From 586a91e25e42f85fae60b19bd856d2d6ebc5ed97 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 09:05:54 -0300 Subject: [PATCH 8/9] fix: preserve event duration when duplicating with newStart only When only newStart is provided without newEnd, calculate the new end time by preserving the original event's duration instead of using the original end time directly, which could create invalid events. --- google-calendar/server/tools/advanced.ts | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/google-calendar/server/tools/advanced.ts b/google-calendar/server/tools/advanced.ts index 58a85ffc..ce7cd617 100644 --- a/google-calendar/server/tools/advanced.ts +++ b/google-calendar/server/tools/advanced.ts @@ -247,6 +247,42 @@ export const createDuplicateEventTool = (env: Env) => context.eventId, ); + // Calculate new end time preserving original duration when only newStart is provided + let newEnd = context.newEnd || originalEvent.end; + if (context.newStart && !context.newEnd) { + // Preserve original event duration + const origStartTime = + originalEvent.start.dateTime || originalEvent.start.date; + const origEndTime = + originalEvent.end.dateTime || originalEvent.end.date; + + if (origStartTime && origEndTime) { + const duration = + new Date(origEndTime).getTime() - new Date(origStartTime).getTime(); + const newStartTime = + context.newStart.dateTime || context.newStart.date; + + if (newStartTime) { + const calculatedEnd = new Date( + new Date(newStartTime).getTime() + duration, + ); + + // Preserve the same format (dateTime vs date) as the original + if (context.newStart.dateTime) { + newEnd = { + dateTime: calculatedEnd.toISOString(), + timeZone: + context.newStart.timeZone || originalEvent.end.timeZone, + }; + } else { + newEnd = { + date: calculatedEnd.toISOString().split("T")[0], + }; + } + } + } + } + // Create the duplicate const newEvent = await client.createEvent({ calendarId: targetCalendarId, @@ -255,7 +291,7 @@ export const createDuplicateEventTool = (env: Env) => description: originalEvent.description, location: originalEvent.location, start: context.newStart || originalEvent.start, - end: context.newEnd || originalEvent.end, + end: newEnd, attendees: originalEvent.attendees?.map((a) => ({ email: a.email, displayName: a.displayName, From ac1272d87d86de48194c312cbe4c7871a5c47ab3 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 5 Jan 2026 09:07:22 -0300 Subject: [PATCH 9/9] fix: update Google Calendar icon URL in app.json --- google-calendar/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google-calendar/app.json b/google-calendar/app.json index 329eeb47..5e21db3a 100644 --- a/google-calendar/app.json +++ b/google-calendar/app.json @@ -6,7 +6,7 @@ "url": "https://sites-google-calendar.decocache.com/mcp" }, "description": "Integrate and manage your Google Calendar. Create, edit and delete events, check availability and sync your calendars.", - "icon": "https://assets.decocache.com/mcp/google-calendar.svg", + "icon": "https://assets.decocache.com/mcp/b5fffe71-647a-461c-aa39-3da07b86cc96/Google-Meets.svg", "unlisted": false }