diff --git a/README.md b/README.md index 385f6ad..9aaaa00 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,29 @@ ![gtasks mcp logo](./logo.jpg) [![smithery badge](https://smithery.ai/badge/@zcaceres/gtasks)](https://smithery.ai/server/@zcaceres/gtasks) -This MCP server integrates with Google Tasks to allow listing, reading, searching, creating, updating, and deleting tasks. +This MCP server integrates with Google Tasks and Google Calendar to allow: + +- **Tasks**: listing, reading, searching, creating, updating, and deleting tasks. +- **Calendar**: listing events, creating events (with optional Google Meet links), updating, and deleting events. ## Components ### Tools - **search** + - Search for tasks in Google Tasks - Input: `query` (string): Search query - Returns matching tasks with details - **list** + - List all tasks in Google Tasks - Optional input: `cursor` (string): Cursor for pagination - Returns a list of all tasks - **create** + - Create a new task in Google Tasks - Input: - `taskListId` (string, optional): Task list ID @@ -29,6 +35,7 @@ This MCP server integrates with Google Tasks to allow listing, reading, searchin - Returns confirmation of task creation - **update** + - Update an existing task in Google Tasks - Input: - `taskListId` (string, optional): Task list ID @@ -41,6 +48,7 @@ This MCP server integrates with Google Tasks to allow listing, reading, searchin - Returns confirmation of task update - **delete** + - Delete a task in Google Tasks - Input: - `taskListId` (string, required): Task list ID @@ -48,61 +56,184 @@ This MCP server integrates with Google Tasks to allow listing, reading, searchin - Returns confirmation of task deletion - **clear** + - Clear completed tasks from a Google Tasks task list - Input: `taskListId` (string, required): Task list ID - Returns confirmation of cleared tasks +- **calendar_list_events** + + - List events from a Google Calendar + - Input: + - `calendarId` (string, optional): Calendar ID (default: "primary") + - `maxResults` (number, optional): Max results to return + - `timeMin` (string, optional): Min start time (ISO 8601) + - `timeMax` (string, optional): Max start time (ISO 8601) + - `query` (string, optional): Free text search terms + - Returns list of events + +- **calendar_create_event** + + - Create a new event in Google Calendar + - Input: + - `calendarId` (string, optional): Calendar ID (default: "primary") + - `summary` (string, required): Event title + - `description` (string, optional): Event description + - `location` (string, optional): Location + - `startTime` (string, required): Start time (ISO 8601) + - `endTime` (string, required): End time (ISO 8601) + - `createMeet` (boolean, optional): Set to `true` to generate a Google Meet link + - Returns confirmation of event creation + +- **calendar_update_event** + + - Update an existing event in Google Calendar + - Input: + - `calendarId` (string, optional): Calendar ID (default: "primary") + - `eventId` (string, required): ID of event to update + - `summary`, `description`, `location`, `startTime`, `endTime` (optional): Fields to update + - Returns confirmation of event update + +- **calendar_delete_event** + - Delete an event from Google Calendar + - Input: + - `calendarId` (string, optional): Calendar ID (default: "primary") + - `eventId` (string, required): ID of event to delete + - Returns confirmation of deletion + ### Resources The server provides access to Google Tasks resources: - **Tasks** (`gtasks:///`) + - Represents individual tasks in Google Tasks - Supports reading task details including title, status, due date, notes, and other metadata - Can be listed, read, created, updated, and deleted using the provided tools +- **Calendar Events** (`gcalendar:///events//`) + - Represents individual calendar events + - Supports reading event details including summary, time, location, and status + ## Getting started +### Prerequisites + 1. [Create a new Google Cloud project](https://console.cloud.google.com/projectcreate) 2. [Enable the Google Tasks API](https://console.cloud.google.com/workspace-api/products) 3. [Configure an OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) ("internal" is fine for testing) -4. Add scopes `https://www.googleapis.com/auth/tasks` +4. Add scopes: + - `https://www.googleapis.com/auth/tasks` + - `https://www.googleapis.com/auth/calendar` 5. [Create an OAuth Client ID](https://console.cloud.google.com/apis/credentials/oauthclient) for application type "Desktop App" 6. Download the JSON file of your client's OAuth keys -7. Rename the key file to `gcp-oauth.keys.json` and place into the root of this repo (i.e. `gcp-oauth.keys.json`) -Make sure to build the server with either `npm run build` or `npm run watch`. +### Installation -### Installing via Smithery +1. Clone this repository +2. Install dependencies and build: + ```bash + npm install + npm run build + ``` -To install Google Tasks Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@zcaceres/gtasks): +### Quick Usage with `npx` + +Once installed and built, you can use `npx` to run the server or trigger authentication: + +**1. Run the MCP Server:** ```bash -npx -y @smithery/cli install @zcaceres/gtasks --client claude +npx g-tasks-mcp +``` + +(If credentials are not found, this will automatically launch the authentication flow.) + +**2. Manually Trigger Authentication:** + +```bash +npx g-tasks-mcp auth ``` -### Authentication +(This will explicitly launch the authentication flow.) + +_Ensure you have your OAuth credentials configured as described in the "Configuration & Authentication" section below._ + +### Configuration & Authentication + +This server supports multiple ways to provide your Google Cloud OAuth credentials. It handles authentication automatically: if no valid user credentials (`.gtasks-server-credentials.json`) are found, it will launch a browser window to authenticate you when the server starts. + +You can configure the OAuth keys using **one** of the following methods: -To authenticate and save credentials: +#### Method 1: Environment Variables (Recommended) -1. Run the server with the `auth` argument: `npm run start auth` -2. This will open an authentication flow in your system browser -3. Complete the authentication process -4. Credentials will be saved in the root of this repo (i.e. `.gdrive-server-credentials.json`) +Set the following environment variables in your MCP client configuration (e.g., `claude_desktop_config.json`). This is the cleanest way as it doesn't require placing files in the source directory. -### Usage with Desktop App +- `GOOGLE_OAUTH_CREDENTIALS`: Absolute path to your downloaded OAuth JSON key file. + - OR - +- `GOOGLE_CLIENT_ID`: Your OAuth Client ID. +- `GOOGLE_CLIENT_SECRET`: Your OAuth Client Secret. -To integrate this server with the desktop app, add the following to your app's server configuration: +#### Method 2: Key File + +Rename your downloaded OAuth JSON key file to `gcp-oauth.keys.json` and place it in the root directory of this repository. + +### Usage with Claude Desktop + +Add the following to your `claude_desktop_config.json` (typically located at `~/Library/Application Support/Claude/` on macOS): + +#### Option 1: Using Local Installation + +```json +{ + "mcpServers": { + "gtasks": { + "command": "/path/to/your/node", + "args": ["/absolute/path/to/gtasks-mcp/dist/index.js"], + "env": { + "GOOGLE_OAUTH_CREDENTIALS": "/absolute/path/to/your/gcp-oauth.keys.json" + } + } + } +} +``` + +_Replace `/path/to/your/node` (run `which node` to find it) and `/absolute/path/to/...` with your actual paths._ + +#### Option 2: Using `npx` (No Local Installation Required) ```json { "mcpServers": { "gtasks": { - "command": "/opt/homebrew/bin/node", - "args": [ - "{ABSOLUTE PATH TO FILE HERE}/dist/index.js" - ] + "command": "npx", + "args": ["-y", "g-tasks-mcp"], + "env": { + "GOOGLE_OAUTH_CREDENTIALS": "/absolute/path/to/your/gcp-oauth.keys.json" + } } } } ``` + +### Installing via Smithery + +To install Google Tasks Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@zcaceres/gtasks): + +```bash +npx -y @smithery/cli install @zcaceres/gtasks --client claude +``` + +## Build + +To rebuild the project: + +```bash +npm run build +``` + +To watch for changes during development: + +```bash +npm run dev +``` diff --git a/package-lock.json b/package-lock.json index 99aa150..6e6c08b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@modelcontextprotocol/server-gdrive", - "version": "0.6.2", + "name": "g-tasks-mcp", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@modelcontextprotocol/server-gdrive", - "version": "0.6.2", + "name": "g-tasks-mcp", + "version": "0.0.1", "license": "MIT", "dependencies": { "@google-cloud/local-auth": "^3.0.1", @@ -14,7 +14,7 @@ "googleapis": "^144.0.0" }, "bin": { - "mcp-server-gdrive": "dist/index.js" + "g-tasks-mcp": "dist/index.js" }, "devDependencies": { "@types/node": "^22.9.3", diff --git a/package.json b/package.json index 8843d54..3af8733 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@modelcontextprotocol/server-gtasks", - "version": "0.0.1", + "name": "g-tasks-mcp", + "version": "0.0.4", "description": "MCP server for interacting with Google Tasks", "license": "MIT", "author": "zcaceres (@zachcaceres zach.dev)", @@ -8,7 +8,7 @@ "bugs": "https://github.com/modelcontextprotocol/servers/issues", "type": "module", "bin": { - "mcp-server-gtasks": "dist/index.js" + "g-tasks-mcp": "dist/index.js" }, "files": [ "dist" diff --git a/src/Calendar.ts b/src/Calendar.ts new file mode 100644 index 0000000..01f1b38 --- /dev/null +++ b/src/Calendar.ts @@ -0,0 +1,236 @@ +import { + CallToolRequest, + ListResourcesRequest, + ReadResourceRequest, +} from "@modelcontextprotocol/sdk/types.js"; +import { GaxiosResponse } from "gaxios"; +import { calendar_v3 } from "googleapis"; + +const MAX_RESULTS = 100; + +export class CalendarResources { + static async read(request: ReadResourceRequest, calendar: calendar_v3.Calendar) { + // Basic implementation for reading a specific event via URI + // URI format: gcalendar:///events/{calendarId}/{eventId} + const uri = request.params.uri; + const parts = uri.replace("gcalendar:///events/", "").split("/"); + + if (parts.length !== 2) { + throw new Error("Invalid Resource URI. Expected format: gcalendar:///events/{calendarId}/{eventId}"); + } + + const [calendarId, eventId] = parts; + + try { + const response = await calendar.events.get({ + calendarId: decodeURIComponent(calendarId), + eventId: eventId + }); + + return response.data; + } catch (error) { + throw new Error(`Failed to read event: ${error}`); + } + } + + static async list( + request: ListResourcesRequest, + calendar: calendar_v3.Calendar, + ): Promise<[calendar_v3.Schema$CalendarListEntry[], string | null]> { + + const params: any = { + maxResults: MAX_RESULTS, + }; + + if (request.params?.cursor) { + params.pageToken = request.params.cursor; + } + + const response = await calendar.calendarList.list(params); + const items = response.data.items || []; + const nextPageToken = response.data.nextPageToken || null; + + return [items, nextPageToken]; + } +} + +export class CalendarActions { + private static formatEvent(event: calendar_v3.Schema$Event) { + const start = event.start?.dateTime || event.start?.date || "Unknown"; + const end = event.end?.dateTime || event.end?.date || "Unknown"; + return `Event: ${event.summary}\nTime: ${start} - ${end}\nDescription: ${event.description || "None"}\nLocation: ${event.location || "None"}\nID: ${event.id}\nLink: ${event.htmlLink}\nStatus: ${event.status}`; + } + + private static formatEventList(events: calendar_v3.Schema$Event[]) { + return events.map((event) => this.formatEvent(event)).join("\n\n"); + } + + static async list_events(request: CallToolRequest, calendar: calendar_v3.Calendar) { + const calendarId = (request.params.arguments?.calendarId as string) || "primary"; + const maxResults = Number(request.params.arguments?.maxResults) || 10; + + // Optional time filtering + const timeMin = request.params.arguments?.timeMin as string; + const timeMax = request.params.arguments?.timeMax as string; + const query = request.params.arguments?.query as string; + + const params: any = { + calendarId, + maxResults, + singleEvents: true, + orderBy: "startTime", + }; + + if (timeMin) params.timeMin = timeMin; + if (timeMax) params.timeMax = timeMax; + if (query) params.q = query; + + try { + const response = await calendar.events.list(params); + const events = response.data.items || []; + + return { + content: [ + { + type: "text", + text: `Found ${events.length} events in '${calendarId}':\n\n${this.formatEventList(events)}` + } + ], + isError: false + } + + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error listing events: ${error}` + } + ], + isError: true + } + } + } + + static async create_event(request: CallToolRequest, calendar: calendar_v3.Calendar) { + const calendarId = (request.params.arguments?.calendarId as string) || "primary"; + const summary = request.params.arguments?.summary as string; + const description = request.params.arguments?.description as string; + const location = request.params.arguments?.location as string; + const startTime = request.params.arguments?.startTime as string; + const endTime = request.params.arguments?.endTime as string; + + if (!summary) throw new Error("Event summary (title) is required"); + if (!startTime || !endTime) throw new Error("Start and End times are required"); + + const createMeet = request.params.arguments?.createMeet as boolean; + + const event: calendar_v3.Schema$Event = { + summary, + description, + location, + start: { dateTime: startTime }, + end: { dateTime: endTime }, + ...(createMeet && { + conferenceData: { + createRequest: { + requestId: Math.random().toString(36).substring(7), + conferenceSolutionKey: { type: "hangoutsMeet" } + } + } + }) + }; + + try { + const response = await calendar.events.insert({ + calendarId, + requestBody: event, + conferenceDataVersion: createMeet ? 1 : 0 + }); + + return { + content: [ + { + type: "text", + text: `Event created: ${response.data.htmlLink}` + (response.data.conferenceData?.entryPoints?.[0]?.uri ? `\nGoogle Meet: ${response.data.conferenceData.entryPoints[0].uri}` : "") + } + ], + isError: false + }; + } catch(error) { + return { + content: [{ type: "text", text: `Error creating event: ${error}`}], + isError: true + }; + } + } + + static async update_event(request: CallToolRequest, calendar: calendar_v3.Calendar) { + const calendarId = (request.params.arguments?.calendarId as string) || "primary"; + const eventId = request.params.arguments?.eventId as string; + + if (!eventId) throw new Error("Event ID is required for update"); + + // Creating requestBody with only provided fields + const requestBody: any = {}; + if (request.params.arguments?.summary) requestBody.summary = request.params.arguments?.summary; + if (request.params.arguments?.description) requestBody.description = request.params.arguments?.description; + if (request.params.arguments?.location) requestBody.location = request.params.arguments?.location; + if (request.params.arguments?.startTime) requestBody.start = { dateTime: request.params.arguments?.startTime }; + if (request.params.arguments?.endTime) requestBody.end = { dateTime: request.params.arguments?.endTime }; + + try { + const response = await calendar.events.patch({ + calendarId, + eventId, + requestBody + }); + + return { + content: [ + { + type: "text", + text: `Event updated: ${response.data.summary} (${response.data.htmlLink})` + } + ], + isError: false + }; + + } catch (error) { + return { + content: [{ type: "text", text: `Error updating event: ${error}`}], + isError: true + }; + } + } + + static async delete_event(request: CallToolRequest, calendar: calendar_v3.Calendar) { + const calendarId = (request.params.arguments?.calendarId as string) || "primary"; + const eventId = request.params.arguments?.eventId as string; + + if (!eventId) throw new Error("Event ID is required for deletion"); + + try { + await calendar.events.delete({ + calendarId, + eventId + }); + + return { + content: [ + { + type: "text", + text: `Event with ID ${eventId} deleted successfully.` + } + ], + isError: false + }; + + } catch (error) { + return { + content: [{ type: "text", text: `Error deleting event: ${error}`}], + isError: true + }; + } + } +} diff --git a/src/index.ts b/src/index.ts index 0118656..ba7bb0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,11 +11,13 @@ import { ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs"; -import { google, tasks_v1 } from "googleapis"; +import { google, tasks_v1, calendar_v3 } from "googleapis"; import path from "path"; import { TaskActions, TaskResources } from "./Tasks.js"; +import { CalendarActions, CalendarResources } from "./Calendar.js"; const tasks = google.tasks("v1"); +const calendar = google.calendar("v3"); const server = new Server( { @@ -38,40 +40,58 @@ server.setRequestHandler(ListResourcesRequestSchema, async (request) => { mimeType: "text/plain", name: task.title, })), - nextCursor: nextPageToken, + nextCursor: nextPageToken ? nextPageToken : undefined, }; }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const task = await TaskResources.read(request, tasks); + if (request.params.uri.startsWith("gtasks:///")) { + const task = await TaskResources.read(request, tasks); - const taskDetails = [ - `Title: ${task.title || "No title"}`, - `Status: ${task.status || "Unknown"}`, - `Due: ${task.due || "Not set"}`, - `Notes: ${task.notes || "No notes"}`, - `Hidden: ${task.hidden || "Unknown"}`, - `Parent: ${task.parent || "Unknown"}`, - `Deleted?: ${task.deleted || "Unknown"}`, - `Completed Date: ${task.completed || "Unknown"}`, - `Position: ${task.position || "Unknown"}`, - `ETag: ${task.etag || "Unknown"}`, - `Links: ${task.links || "Unknown"}`, - `Kind: ${task.kind || "Unknown"}`, - `Status: ${task.status || "Unknown"}`, - `Created: ${task.updated || "Unknown"}`, - `Updated: ${task.updated || "Unknown"}`, - ].join("\n"); + const taskDetails = [ + `Title: ${task.title || "No title"}`, + `Status: ${task.status || "Unknown"}`, + `Due: ${task.due || "Not set"}`, + `Notes: ${task.notes || "No notes"}`, + `Hidden: ${task.hidden || "Unknown"}`, + `Parent: ${task.parent || "Unknown"}`, + `Deleted?: ${task.deleted || "Unknown"}`, + `Completed Date: ${task.completed || "Unknown"}`, + `Position: ${task.position || "Unknown"}`, + `ETag: ${task.etag || "Unknown"}`, + `Links: ${task.links || "Unknown"}`, + `Kind: ${task.kind || "Unknown"}`, + `Status: ${task.status || "Unknown"}`, + `Created: ${task.updated || "Unknown"}`, + `Updated: ${task.updated || "Unknown"}`, + ].join("\n"); - return { - contents: [ - { - uri: request.params.uri, - mimeType: "text/plain", - text: taskDetails, - }, - ], - }; + return { + contents: [ + { + uri: request.params.uri, + mimeType: "text/plain", + text: taskDetails, + }, + ], + }; + } + + if (request.params.uri.startsWith("gcalendar:///")) { + const event = await CalendarResources.read(request, calendar); + const eventDetails = JSON.stringify(event, null, 2); + return { + contents: [ + { + uri: request.params.uri, + mimeType: "application/json", + text: eventDetails, + }, + ], + }; + } + + throw new Error("Resource not found"); }); server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -201,6 +221,82 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["id", "uri"], }, }, + + { + name: "calendar_list_events", + description: "List events from a Google Calendar", + inputSchema: { + type: "object", + properties: { + calendarId: { + type: "string", + description: "Calendar ID (default: primary)", + }, + maxResults: { + type: "number", + description: "Maximum results to return", + }, + timeMin: { + type: "string", + description: "Minimum time (ISO 8601)", + }, + timeMax: { + type: "string", + description: "Maximum time (ISO 8601)", + }, + query: { + type: "string", + description: "Free text search terms", + }, + }, + }, + }, + { + name: "calendar_create_event", + description: "Create a new event in Google Calendar", + inputSchema: { + type: "object", + properties: { + calendarId: { type: "string", description: "Calendar ID (default: primary)" }, + summary: { type: "string", description: "Event title/summary" }, + description: { type: "string", description: "Event description" }, + location: { type: "string", description: "Event location" }, + startTime: { type: "string", description: "Start time (ISO 8601)" }, + endTime: { type: "string", description: "End time (ISO 8601)" }, + createMeet: { type: "boolean", description: "Create a Google Meet link" }, + }, + required: ["summary", "startTime", "endTime"], + }, + }, + { + name: "calendar_update_event", + description: "Update an existing event in Google Calendar", + inputSchema: { + type: "object", + properties: { + calendarId: { type: "string", description: "Calendar ID (default: primary)" }, + eventId: { type: "string", description: "Event ID to update" }, + summary: { type: "string", description: "New title/summary" }, + description: { type: "string", description: "New description" }, + location: { type: "string", description: "New location" }, + startTime: { type: "string", description: "New start time (ISO 8601)" }, + endTime: { type: "string", description: "New end time (ISO 8601)" }, + }, + required: ["eventId"], + }, + }, + { + name: "calendar_delete_event", + description: "Delete an event from Google Calendar", + inputSchema: { + type: "object", + properties: { + calendarId: { type: "string", description: "Calendar ID (default: primary)" }, + eventId: { type: "string", description: "Event ID to delete" }, + }, + required: ["eventId"], + }, + }, ], }; }); @@ -230,6 +326,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const taskResult = await TaskActions.clear(request, tasks); return taskResult; } + + // Calendar Tools + if (request.params.name === "calendar_list_events") { + return await CalendarActions.list_events(request, calendar); + } + if (request.params.name === "calendar_create_event") { + return await CalendarActions.create_event(request, calendar); + } + if (request.params.name === "calendar_update_event") { + return await CalendarActions.update_event(request, calendar); + } + if (request.params.name === "calendar_delete_event") { + return await CalendarActions.delete_event(request, calendar); + } + throw new Error("Tool not found"); }); @@ -239,32 +350,130 @@ const credentialsPath = path.join( ); async function authenticateAndSaveCredentials() { - console.log("Launching auth flow…"); - const p = path.join( + console.error("Launching auth flow…"); + const keyFilePath = path.join( path.dirname(new URL(import.meta.url).pathname), "../gcp-oauth.keys.json", ); - console.log(p); - const auth = await authenticate({ - keyfilePath: p, - scopes: ["https://www.googleapis.com/auth/tasks"], - }); - fs.writeFileSync(credentialsPath, JSON.stringify(auth.credentials)); - console.log("Credentials saved. You can now run the server."); + let auth; + let tempKeyPath: string | null = null; + + try { + if (process.env.GOOGLE_OAUTH_CREDENTIALS) { + console.error(`Using keys file from GOOGLE_OAUTH_CREDENTIALS environment variable: ${process.env.GOOGLE_OAUTH_CREDENTIALS}`); + auth = await authenticate({ + keyfilePath: process.env.GOOGLE_OAUTH_CREDENTIALS, + scopes: [ + "https://www.googleapis.com/auth/tasks", + "https://www.googleapis.com/auth/calendar", + ], + }); + } else if (fs.existsSync(keyFilePath)) { + console.error(`Using keys file: ${keyFilePath}`); + auth = await authenticate({ + keyfilePath: keyFilePath, + scopes: [ + "https://www.googleapis.com/auth/tasks", + "https://www.googleapis.com/auth/calendar", + ], + }); + } else if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { + console.error("Using credentials from environment variables."); + const keys = { + installed: { + client_id: process.env.GOOGLE_CLIENT_ID, + project_id: "gtasks-mcp", // Placeholder + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + client_secret: process.env.GOOGLE_CLIENT_SECRET, + redirect_uris: ["http://localhost:3000/oauth2callback"], + }, + }; + + tempKeyPath = path.join( + path.dirname(new URL(import.meta.url).pathname), + "../temp-oauth.keys.json" + ); + fs.writeFileSync(tempKeyPath, JSON.stringify(keys)); + + auth = await authenticate({ + keyfilePath: tempKeyPath, + scopes: [ + "https://www.googleapis.com/auth/tasks", + "https://www.googleapis.com/auth/calendar", + ], + }); + } else { + throw new Error( + "No credentials found. Please provide 'gcp-oauth.keys.json' or set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables." + ); + } + + fs.writeFileSync(credentialsPath, JSON.stringify(auth.credentials)); + console.error("Credentials saved. You can now run the server."); + + } finally { + if (tempKeyPath && fs.existsSync(tempKeyPath)) { + fs.unlinkSync(tempKeyPath); + } + } } -async function loadCredentialsAndRunServer() { +function writeCredentials(credentials: object) { + fs.writeFileSync(credentialsPath, JSON.stringify(credentials)); +} + +async function loadOrRefreshAuth() { if (!fs.existsSync(credentialsPath)) { - console.error( - "Credentials not found. Please run with 'auth' argument first.", - ); - process.exit(1); + console.error("Credentials not found. Launching authentication..."); + try { + await authenticateAndSaveCredentials(); + } catch (error) { + console.error("Authentication failed:", error); + process.exit(1); + } } - const credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf-8")); + let credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf-8")); const auth = new google.auth.OAuth2(); auth.setCredentials(credentials); + + auth.on("tokens", (tokens) => { + if (!tokens.access_token && !tokens.refresh_token) { + return; + } + const merged = { + ...auth.credentials, + ...tokens, + refresh_token: tokens.refresh_token || auth.credentials.refresh_token, + }; + writeCredentials(merged); + }); + + try { + const accessToken = await auth.getAccessToken(); + if (!accessToken?.token) { + throw new Error("No access token available."); + } + } catch (error) { + console.error("Stored credentials invalid. Re-authenticating..."); + try { + await authenticateAndSaveCredentials(); + } catch (authError) { + console.error("Authentication failed:", authError); + process.exit(1); + } + credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf-8")); + auth.setCredentials(credentials); + } + + return auth; +} + +async function loadCredentialsAndRunServer() { + const auth = await loadOrRefreshAuth(); google.options({ auth }); const transport = new StdioServerTransport();