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..5e21db3a --- /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/b5fffe71-647a-461c-aa39-3da07b86cc96/Google-Meets.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/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 new file mode 100644 index 00000000..bd3c4935 --- /dev/null +++ b/google-calendar/server/lib/google-client.ts @@ -0,0 +1,353 @@ +/** + * 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; + } +} + +// Re-export getGoogleAccessToken from env.ts for convenience +export { getGoogleAccessToken as getAccessToken } from "./env.ts"; diff --git a/google-calendar/server/lib/types.ts b/google-calendar/server/lib/types.ts new file mode 100644 index 00000000..5134b8b3 --- /dev/null +++ b/google-calendar/server/lib/types.ts @@ -0,0 +1,225 @@ +/** + * Google Calendar API types + */ + +export type CalendarAccessRole = + | "freeBusyReader" + | "reader" + | "writer" + | "owner"; + +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: CalendarAccessRole; + 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: CalendarAccessRole; + 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..ace1c143 --- /dev/null +++ b/google-calendar/server/main.ts @@ -0,0 +1,119 @@ +/** + * Google Calendar MCP Server + * + * This MCP provides tools for interacting with Google Calendar API, + * including calendar management, event CRUD operations, and availability checks. + */ +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"; + +export type { Env }; + +const GOOGLE_CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar", + "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({ + tools: (env: Env) => tools.map((createTool) => createTool(env)), + 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) => { + // 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(); + + // Store for later use in exchangeCode + lastRedirectUri = 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!); + 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, + }: 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", + ); + } + + 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, + }); + + // 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, + }; + }, + }, +}); + +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..ce7cd617 --- /dev/null +++ b/google-calendar/server/tools/advanced.ts @@ -0,0 +1,338 @@ +/** + * 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.coerce + .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.coerce + .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, + ); + + // 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, + summary: + context.newSummary || `Copy of ${originalEvent.summary || "Event"}`, + description: originalEvent.description, + location: originalEvent.location, + start: context.newStart || originalEvent.start, + end: newEnd, + 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..2b62dbab --- /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.coerce + .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..460148b2 --- /dev/null +++ b/google-calendar/server/tools/events.ts @@ -0,0 +1,534 @@ +/** + * 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.coerce + .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.coerce + .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/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>>; + }>; + }; +}; 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",