UNPKG

@piotr-agier/google-drive-mcp

Version:

Google Drive MCP Server - Model Context Protocol server providing secure access to Google Drive, Docs, Sheets, and Slides through MCP clients e.g. Claude Desktop

1,377 lines (1,363 loc) 134 kB
#!/usr/bin/env node // src/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { google } from "googleapis"; import { v4 as uuidv4 } from "uuid"; // src/auth/client.ts import { OAuth2Client } from "google-auth-library"; import * as fs from "fs/promises"; // src/auth/utils.ts import * as path from "path"; import * as os from "os"; import { fileURLToPath } from "url"; function getProjectRoot() { const __dirname2 = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.join(__dirname2, "..", ".."); return path.resolve(projectRoot); } function getSecureTokenPath() { const customTokenPath = process.env.GOOGLE_DRIVE_MCP_TOKEN_PATH; if (customTokenPath) { return path.resolve(customTokenPath); } const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); const tokenDir = path.join(configHome, "google-drive-mcp"); return path.join(tokenDir, "tokens.json"); } function getLegacyTokenPath() { const projectRoot = getProjectRoot(); return path.join(projectRoot, ".gcp-saved-tokens.json"); } function getAdditionalLegacyPaths() { return [ process.env.GOOGLE_TOKEN_PATH, path.join(process.cwd(), "google-tokens.json"), path.join(process.cwd(), ".gcp-saved-tokens.json") ].filter(Boolean); } function getKeysFilePath() { const envCredentialsPath = process.env.GOOGLE_DRIVE_OAUTH_CREDENTIALS; if (envCredentialsPath) { return path.resolve(envCredentialsPath); } const projectRoot = getProjectRoot(); const keysPath = path.join(projectRoot, "gcp-oauth.keys.json"); return keysPath; } function generateCredentialsErrorMessage() { return ` OAuth credentials not found. Please provide credentials using one of these methods: 1. Environment variable: Set GOOGLE_DRIVE_OAUTH_CREDENTIALS to the path of your credentials file: export GOOGLE_DRIVE_OAUTH_CREDENTIALS="/path/to/gcp-oauth.keys.json" 2. Default file path: Place your gcp-oauth.keys.json file in the package root directory. Token storage: - Tokens are saved to: ${getSecureTokenPath()} - To use a custom token location, set GOOGLE_DRIVE_MCP_TOKEN_PATH environment variable To get OAuth credentials: 1. Go to the Google Cloud Console (https://console.cloud.google.com/) 2. Create or select a project 3. Enable the Google Drive, Docs, Sheets, and Slides APIs 4. Create OAuth 2.0 credentials (Desktop app type) 5. Download the credentials file as gcp-oauth.keys.json `.trim(); } // src/auth/client.ts async function loadCredentialsFromFile() { const keysContent = await fs.readFile(getKeysFilePath(), "utf-8"); const keys = JSON.parse(keysContent); if (keys.installed) { const { client_id, client_secret, redirect_uris } = keys.installed; return { client_id, client_secret, redirect_uris }; } else if (keys.web) { const { client_id, client_secret, redirect_uris } = keys.web; return { client_id, client_secret, redirect_uris }; } else if (keys.client_id) { return { client_id: keys.client_id, client_secret: keys.client_secret, redirect_uris: keys.redirect_uris || ["http://localhost:3000/oauth2callback"] }; } else { throw new Error('Invalid credentials file format. Expected either "installed", "web" object or direct client_id field.'); } } async function loadCredentialsWithFallback() { try { return await loadCredentialsFromFile(); } catch (fileError) { const legacyPath = process.env.GOOGLE_CLIENT_SECRET_PATH || "client_secret.json"; try { const legacyContent = await fs.readFile(legacyPath, "utf-8"); const legacyKeys = JSON.parse(legacyContent); console.error("Warning: Using legacy client_secret.json. Please migrate to gcp-oauth.keys.json"); if (legacyKeys.installed) { return legacyKeys.installed; } else if (legacyKeys.web) { return legacyKeys.web; } else { throw new Error("Invalid legacy credentials format"); } } catch (legacyError) { const errorMessage = generateCredentialsErrorMessage(); throw new Error(`${errorMessage} Original error: ${fileError instanceof Error ? fileError.message : fileError}`); } } } async function initializeOAuth2Client() { try { const credentials = await loadCredentialsWithFallback(); return new OAuth2Client({ clientId: credentials.client_id, clientSecret: credentials.client_secret || void 0, redirectUri: credentials.redirect_uris?.[0] || "http://localhost:3000/oauth2callback" }); } catch (error) { throw new Error(`Error loading OAuth keys: ${error instanceof Error ? error.message : error}`); } } async function loadCredentials() { try { const credentials = await loadCredentialsWithFallback(); if (!credentials.client_id) { throw new Error("Client ID missing in credentials."); } return { client_id: credentials.client_id, client_secret: credentials.client_secret }; } catch (error) { throw new Error(`Error loading credentials: ${error instanceof Error ? error.message : error}`); } } // src/auth/server.ts import express from "express"; import { OAuth2Client as OAuth2Client2 } from "google-auth-library"; // src/auth/tokenManager.ts import * as fs2 from "fs/promises"; import * as path2 from "path"; import { GaxiosError } from "gaxios"; var TokenManager = class { constructor(oauth2Client) { this.oauth2Client = oauth2Client; this.tokenPath = getSecureTokenPath(); this.setupTokenRefresh(); } // Method to expose the token path getTokenPath() { return this.tokenPath; } async ensureTokenDirectoryExists() { try { const dir = path2.dirname(this.tokenPath); await fs2.mkdir(dir, { recursive: true }); } catch (error) { if (error instanceof Error && "code" in error && error.code !== "EEXIST") { console.error("Failed to create token directory:", error); throw error; } } } setupTokenRefresh() { this.oauth2Client.on("tokens", async (newTokens) => { try { await this.ensureTokenDirectoryExists(); const currentTokens = JSON.parse(await fs2.readFile(this.tokenPath, "utf-8")); const updatedTokens = { ...currentTokens, ...newTokens, refresh_token: newTokens.refresh_token || currentTokens.refresh_token }; await fs2.writeFile(this.tokenPath, JSON.stringify(updatedTokens, null, 2), { mode: 384 }); console.error("Tokens updated and saved"); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { try { await fs2.writeFile(this.tokenPath, JSON.stringify(newTokens, null, 2), { mode: 384 }); console.error("New tokens saved"); } catch (writeError) { console.error("Error saving initial tokens:", writeError); } } else { console.error("Error saving updated tokens:", error); } } }); } async migrateLegacyTokens() { const legacyPaths = [getLegacyTokenPath(), ...getAdditionalLegacyPaths()]; for (const legacyPath of legacyPaths) { try { if (!await fs2.access(legacyPath).then(() => true).catch(() => false)) { continue; } const legacyTokens = JSON.parse(await fs2.readFile(legacyPath, "utf-8")); if (!legacyTokens || typeof legacyTokens !== "object") { console.error("Invalid legacy token format at", legacyPath, ", skipping"); continue; } await this.ensureTokenDirectoryExists(); await fs2.writeFile(this.tokenPath, JSON.stringify(legacyTokens, null, 2), { mode: 384 }); console.error("Migrated tokens from legacy location:", legacyPath, "to:", this.tokenPath); try { await fs2.unlink(legacyPath); console.error("Removed legacy token file"); } catch (unlinkErr) { console.error("Warning: Could not remove legacy token file:", unlinkErr); } return true; } catch (error) { console.error("Error migrating legacy tokens from", legacyPath, ":", error); } } return false; } async loadSavedTokens() { try { await this.ensureTokenDirectoryExists(); const tokenExists = await fs2.access(this.tokenPath).then(() => true).catch(() => false); if (!tokenExists) { const migrated = await this.migrateLegacyTokens(); if (!migrated) { console.error("No token file found at:", this.tokenPath); return false; } } const tokens = JSON.parse(await fs2.readFile(this.tokenPath, "utf-8")); if (!tokens || typeof tokens !== "object") { console.error("Invalid token format in file:", this.tokenPath); return false; } this.oauth2Client.setCredentials(tokens); console.error("Tokens loaded and set on OAuth2Client:", { hasAccessToken: !!tokens.access_token, hasRefreshToken: !!tokens.refresh_token, tokenLength: tokens.access_token?.length, expiryDate: tokens.expiry_date, scope: tokens.scope }); console.error("OAuth2Client after setCredentials:", { hasCredentials: !!this.oauth2Client.credentials, credentialsAccessToken: !!this.oauth2Client.credentials?.access_token }); return true; } catch (error) { console.error("Error loading tokens:", error); if (error instanceof Error && "code" in error && error.code !== "ENOENT") { try { await fs2.unlink(this.tokenPath); console.error("Removed potentially corrupted token file"); } catch (unlinkErr) { } } return false; } } async refreshTokensIfNeeded() { const expiryDate = this.oauth2Client.credentials.expiry_date; const isExpired = expiryDate ? Date.now() >= expiryDate - 5 * 60 * 1e3 : !this.oauth2Client.credentials.access_token; if (isExpired && this.oauth2Client.credentials.refresh_token) { console.error("Auth token expired or nearing expiry, refreshing..."); try { const response = await this.oauth2Client.refreshAccessToken(); const newTokens = response.credentials; if (!newTokens.access_token) { throw new Error("Received invalid tokens during refresh"); } this.oauth2Client.setCredentials(newTokens); console.error("Token refreshed successfully"); return true; } catch (refreshError) { if (refreshError instanceof GaxiosError && refreshError.response?.data?.error === "invalid_grant") { console.error("Error refreshing auth token: Invalid grant. Token likely expired or revoked. Please re-authenticate."); await this.clearTokens(); return false; } else { console.error("Error refreshing auth token:", refreshError); return false; } } } else if (!this.oauth2Client.credentials.access_token && !this.oauth2Client.credentials.refresh_token) { console.error("No access or refresh token available. Please re-authenticate."); return false; } else { return true; } } async validateTokens() { if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) { if (!await this.loadSavedTokens()) { return false; } if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) { return false; } } return this.refreshTokensIfNeeded(); } async saveTokens(tokens) { try { await this.ensureTokenDirectoryExists(); await fs2.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), { mode: 384 }); this.oauth2Client.setCredentials(tokens); console.error("Tokens saved successfully to:", this.tokenPath); } catch (error) { console.error("Error saving tokens:", error); throw error; } } async clearTokens() { try { this.oauth2Client.setCredentials({}); await fs2.unlink(this.tokenPath); console.error("Tokens cleared successfully"); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { console.error("Token file already deleted"); } else { console.error("Error clearing tokens:", error); } } } }; // src/auth/server.ts import open from "open"; var SCOPES = [ "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/presentations" ]; var AuthServer = class { // Flag for standalone script constructor(oauth2Client) { // Used by TokenManager for validation/refresh this.flowOAuth2Client = null; this.server = null; this.authCompletedSuccessfully = false; this.baseOAuth2Client = oauth2Client; this.tokenManager = new TokenManager(oauth2Client); this.app = express(); this.portRange = { start: 3e3, end: 3004 }; this.setupRoutes(); } setupRoutes() { this.app.get("/", (req, res) => { const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client; const authUrl = clientForUrl.generateAuthUrl({ access_type: "offline", scope: SCOPES, prompt: "consent" }); res.send(`<h1>Google Drive Authentication</h1><a href="${authUrl}">Authenticate with Google</a>`); }); this.app.get("/oauth2callback", async (req, res) => { const code = req.query.code; if (!code) { res.status(400).send("Authorization code missing"); return; } if (!this.flowOAuth2Client) { res.status(500).send("Authentication flow not properly initiated."); return; } try { const { tokens } = await this.flowOAuth2Client.getToken(code); await this.tokenManager.saveTokens(tokens); this.authCompletedSuccessfully = true; const tokenPath = this.tokenManager.getTokenPath(); res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authentication Successful</title> <style> body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; } .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } h1 { color: #4CAF50; } p { color: #333; margin-bottom: 0.5em; } code { background-color: #eee; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; } </style> </head> <body> <div class="container"> <h1>Authentication Successful!</h1> <p>Your authentication tokens have been saved successfully to:</p> <p><code>${tokenPath}</code></p> <p>You can now close this browser window.</p> </div> </body> </html> `); } catch (error) { this.authCompletedSuccessfully = false; const message = error instanceof Error ? error.message : "Unknown error"; res.status(500).send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authentication Failed</title> <style> body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; } .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } h1 { color: #F44336; } p { color: #333; } </style> </head> <body> <div class="container"> <h1>Authentication Failed</h1> <p>An error occurred during authentication:</p> <p><code>${message}</code></p> <p>Please try again or check the server logs.</p> </div> </body> </html> `); } }); } async start(openBrowser = true) { if (await this.tokenManager.validateTokens()) { this.authCompletedSuccessfully = true; return true; } const port = await this.startServerOnAvailablePort(); if (port === null) { this.authCompletedSuccessfully = false; return false; } try { const { client_id, client_secret } = await loadCredentials(); this.flowOAuth2Client = new OAuth2Client2( client_id, client_secret || void 0, `http://localhost:${port}/oauth2callback` ); } catch (error) { console.error("Failed to load credentials for auth flow:", error); this.authCompletedSuccessfully = false; await this.stop(); return false; } if (openBrowser) { const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({ access_type: "offline", scope: SCOPES, prompt: "consent" }); console.error("\n\u{1F510} AUTHENTICATION REQUIRED"); console.error("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"); console.error("\nOpening your browser to authenticate..."); console.error(`If the browser doesn't open, visit: ${authorizeUrl} `); await open(authorizeUrl); } return true; } async startServerOnAvailablePort() { for (let port = this.portRange.start; port <= this.portRange.end; port++) { try { await new Promise((resolve2, reject) => { const testServer = this.app.listen(port, () => { this.server = testServer; console.error(`Authentication server listening on http://localhost:${port}`); resolve2(); }); testServer.on("error", (err) => { if (err.code === "EADDRINUSE") { testServer.close(() => reject(err)); } else { reject(err); } }); }); return port; } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) { console.error("Failed to start auth server:", error); return null; } } } console.error("No available ports for authentication server (tried ports", this.portRange.start, "-", this.portRange.end, ")"); return null; } getRunningPort() { if (this.server) { const address = this.server.address(); if (typeof address === "object" && address !== null) { return address.port; } } return null; } async stop() { return new Promise((resolve2, reject) => { if (this.server) { this.server.close((err) => { if (err) { reject(err); } else { this.server = null; resolve2(); } }); } else { resolve2(); } }); } }; // src/auth.ts async function authenticate() { console.error("Initializing authentication..."); const oauth2Client = await initializeOAuth2Client(); const tokenManager = new TokenManager(oauth2Client); if (await tokenManager.validateTokens()) { console.error("Authentication successful - using existing tokens"); console.error("OAuth2Client credentials:", { hasAccessToken: !!oauth2Client.credentials?.access_token, hasRefreshToken: !!oauth2Client.credentials?.refresh_token, expiryDate: oauth2Client.credentials?.expiry_date }); return oauth2Client; } console.error("\n\u{1F510} No valid authentication tokens found."); console.error("Starting authentication flow...\n"); const authServer = new AuthServer(oauth2Client); const authSuccess = await authServer.start(true); if (!authSuccess) { throw new Error("Authentication failed. Please check your credentials and try again."); } await new Promise((resolve2) => { const checkInterval = setInterval(async () => { if (authServer.authCompletedSuccessfully) { clearInterval(checkInterval); await authServer.stop(); resolve2(); } }, 1e3); }); return oauth2Client; } // src/index.ts import { z } from "zod"; import { fileURLToPath as fileURLToPath2 } from "url"; import { readFileSync } from "fs"; import { join as join2, dirname as dirname3 } from "path"; var drive = null; function ensureDriveService() { if (!authClient) { throw new Error("Authentication required"); } log("About to create drive service", { authClientType: authClient?.constructor?.name, hasCredentials: !!authClient.credentials, credentialsKeys: authClient.credentials ? Object.keys(authClient.credentials) : [], accessTokenLength: authClient.credentials?.access_token?.length, accessTokenPrefix: authClient.credentials?.access_token?.substring(0, 20), expiryDate: authClient.credentials?.expiry_date, isExpired: authClient.credentials?.expiry_date ? Date.now() > authClient.credentials.expiry_date : "no expiry" }); drive = google.drive({ version: "v3", auth: authClient }); log("Drive service created/updated", { hasAuth: !!authClient, hasCredentials: !!authClient.credentials, hasAccessToken: !!authClient.credentials?.access_token }); drive.about.get({ fields: "user" }).then((response) => { log("Auth test successful, user:", response.data.user?.emailAddress); }).catch((error) => { log("Auth test failed:", error.message || error); if (error.response) { log("Auth test error details:", { status: error.response.status, statusText: error.response.statusText, headers: error.response.headers, data: error.response.data }); } }); } var FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; var TEXT_MIME_TYPES = { txt: "text/plain", md: "text/markdown" }; var authClient = null; var authenticationPromise = null; var __filename = fileURLToPath2(import.meta.url); var __dirname = dirname3(__filename); var packageJsonPath = join2(__dirname, "..", "package.json"); var packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); var VERSION = packageJson.version; function log(message, data) { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const logMessage = data ? `[${timestamp}] ${message}: ${JSON.stringify(data)}` : `[${timestamp}] ${message}`; console.error(logMessage); } function getExtensionFromFilename(filename) { return filename.split(".").pop()?.toLowerCase() || ""; } function getMimeTypeFromFilename(filename) { const ext = getExtensionFromFilename(filename); return TEXT_MIME_TYPES[ext] || "text/plain"; } async function resolvePath(pathStr) { if (!pathStr || pathStr === "/") return "root"; const parts = pathStr.replace(/^\/+|\/+$/g, "").split("/"); let currentFolderId = "root"; for (const part of parts) { if (!part) continue; let response = await drive.files.list({ q: `'${currentFolderId}' in parents and name = '${part}' and mimeType = '${FOLDER_MIME_TYPE}' and trashed = false`, fields: "files(id)", spaces: "drive" }); if (!response.data.files?.length) { const folderMetadata = { name: part, mimeType: FOLDER_MIME_TYPE, parents: [currentFolderId] }; const folder = await drive.files.create({ requestBody: folderMetadata, fields: "id" }); if (!folder.data.id) { throw new Error(`Failed to create intermediate folder: ${part}`); } currentFolderId = folder.data.id; } else { currentFolderId = response.data.files[0].id; } } return currentFolderId; } async function resolveFolderId(input) { if (!input) return "root"; if (input.startsWith("/")) { return resolvePath(input); } else { return input; } } function validateTextFileExtension(name) { const ext = getExtensionFromFilename(name); if (!["txt", "md"].includes(ext)) { throw new Error("File name must end with .txt or .md for text files."); } } function convertA1ToGridRange(a1Notation, sheetId) { const rangeRegex = /^([A-Z]*)([0-9]*)(:([A-Z]*)([0-9]*))?$/; const match = a1Notation.match(rangeRegex); if (!match) { throw new Error(`Invalid A1 notation: ${a1Notation}`); } const [, startCol, startRow, , endCol, endRow] = match; const gridRange = { sheetId }; const colToNum = (col) => { let num = 0; for (let i = 0; i < col.length; i++) { num = num * 26 + (col.charCodeAt(i) - "A".charCodeAt(0) + 1); } return num - 1; }; if (startCol) gridRange.startColumnIndex = colToNum(startCol); if (startRow) gridRange.startRowIndex = parseInt(startRow) - 1; if (endCol) { gridRange.endColumnIndex = colToNum(endCol) + 1; } else if (startCol && !endCol) { gridRange.endColumnIndex = gridRange.startColumnIndex + 1; } if (endRow) { gridRange.endRowIndex = parseInt(endRow); } else if (startRow && !endRow) { gridRange.endRowIndex = gridRange.startRowIndex + 1; } return gridRange; } async function checkFileExists(name, parentFolderId = "root") { try { const escapedName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const query = `name = '${escapedName}' and '${parentFolderId}' in parents and trashed = false`; const res = await drive.files.list({ q: query, fields: "files(id, name, mimeType)", pageSize: 1 }); if (res.data.files && res.data.files.length > 0) { return res.data.files[0].id || null; } return null; } catch (error) { log("Error checking file existence:", error); return null; } } var SearchSchema = z.object({ query: z.string().min(1, "Search query is required") }); var CreateTextFileSchema = z.object({ name: z.string().min(1, "File name is required"), content: z.string(), parentFolderId: z.string().optional() }); var UpdateTextFileSchema = z.object({ fileId: z.string().min(1, "File ID is required"), content: z.string(), name: z.string().optional() }); var CreateFolderSchema = z.object({ name: z.string().min(1, "Folder name is required"), parent: z.string().optional() }); var ListFolderSchema = z.object({ folderId: z.string().optional(), pageSize: z.number().min(1).max(100).optional(), pageToken: z.string().optional() }); var DeleteItemSchema = z.object({ itemId: z.string().min(1, "Item ID is required") }); var RenameItemSchema = z.object({ itemId: z.string().min(1, "Item ID is required"), newName: z.string().min(1, "New name is required") }); var MoveItemSchema = z.object({ itemId: z.string().min(1, "Item ID is required"), destinationFolderId: z.string().optional() }); var CreateGoogleDocSchema = z.object({ name: z.string().min(1, "Document name is required"), content: z.string(), parentFolderId: z.string().optional() }); var UpdateGoogleDocSchema = z.object({ documentId: z.string().min(1, "Document ID is required"), content: z.string() }); var CreateGoogleSheetSchema = z.object({ name: z.string().min(1, "Sheet name is required"), data: z.array(z.array(z.string())), parentFolderId: z.string().optional() }); var UpdateGoogleSheetSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required"), data: z.array(z.array(z.string())) }); var GetGoogleSheetContentSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required") }); var FormatGoogleSheetCellsSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required"), backgroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional(), horizontalAlignment: z.enum(["LEFT", "CENTER", "RIGHT"]).optional(), verticalAlignment: z.enum(["TOP", "MIDDLE", "BOTTOM"]).optional(), wrapStrategy: z.enum(["OVERFLOW_CELL", "CLIP", "WRAP"]).optional() }); var FormatGoogleSheetTextSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required"), bold: z.boolean().optional(), italic: z.boolean().optional(), strikethrough: z.boolean().optional(), underline: z.boolean().optional(), fontSize: z.number().min(1).optional(), fontFamily: z.string().optional(), foregroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional() }); var FormatGoogleSheetNumbersSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required"), pattern: z.string().min(1, "Pattern is required"), type: z.enum(["NUMBER", "CURRENCY", "PERCENT", "DATE", "TIME", "DATE_TIME", "SCIENTIFIC"]).optional() }); var SetGoogleSheetBordersSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required"), style: z.enum(["SOLID", "DASHED", "DOTTED", "DOUBLE"]), width: z.number().min(1).max(3).optional(), color: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional(), top: z.boolean().optional(), bottom: z.boolean().optional(), left: z.boolean().optional(), right: z.boolean().optional(), innerHorizontal: z.boolean().optional(), innerVertical: z.boolean().optional() }); var MergeGoogleSheetCellsSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required"), mergeType: z.enum(["MERGE_ALL", "MERGE_COLUMNS", "MERGE_ROWS"]) }); var AddGoogleSheetConditionalFormatSchema = z.object({ spreadsheetId: z.string().min(1, "Spreadsheet ID is required"), range: z.string().min(1, "Range is required"), condition: z.object({ type: z.enum(["NUMBER_GREATER", "NUMBER_LESS", "TEXT_CONTAINS", "TEXT_STARTS_WITH", "TEXT_ENDS_WITH", "CUSTOM_FORMULA"]), value: z.string() }), format: z.object({ backgroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional(), textFormat: z.object({ bold: z.boolean().optional(), foregroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional() }).optional() }) }); var CreateGoogleSlidesSchema = z.object({ name: z.string().min(1, "Presentation name is required"), slides: z.array(z.object({ title: z.string(), content: z.string() })).min(1, "At least one slide is required"), parentFolderId: z.string().optional() }); var UpdateGoogleSlidesSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), slides: z.array(z.object({ title: z.string(), content: z.string() })).min(1, "At least one slide is required") }); var FormatGoogleDocTextSchema = z.object({ documentId: z.string().min(1, "Document ID is required"), startIndex: z.number().min(1, "Start index must be at least 1"), endIndex: z.number().min(1, "End index must be at least 1"), bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), strikethrough: z.boolean().optional(), fontSize: z.number().optional(), foregroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional() }); var FormatGoogleDocParagraphSchema = z.object({ documentId: z.string().min(1, "Document ID is required"), startIndex: z.number().min(1, "Start index must be at least 1"), endIndex: z.number().min(1, "End index must be at least 1"), namedStyleType: z.enum(["NORMAL_TEXT", "TITLE", "SUBTITLE", "HEADING_1", "HEADING_2", "HEADING_3", "HEADING_4", "HEADING_5", "HEADING_6"]).optional(), alignment: z.enum(["START", "CENTER", "END", "JUSTIFIED"]).optional(), lineSpacing: z.number().optional(), spaceAbove: z.number().optional(), spaceBelow: z.number().optional() }); var GetGoogleDocContentSchema = z.object({ documentId: z.string().min(1, "Document ID is required") }); var GetGoogleSlidesContentSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), slideIndex: z.number().min(0).optional() }); var FormatGoogleSlidesTextSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), objectId: z.string().min(1, "Object ID is required"), startIndex: z.number().min(0).optional(), endIndex: z.number().min(0).optional(), bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), strikethrough: z.boolean().optional(), fontSize: z.number().optional(), fontFamily: z.string().optional(), foregroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional() }); var FormatGoogleSlidesParagraphSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), objectId: z.string().min(1, "Object ID is required"), alignment: z.enum(["START", "CENTER", "END", "JUSTIFIED"]).optional(), lineSpacing: z.number().optional(), bulletStyle: z.enum(["NONE", "DISC", "ARROW", "SQUARE", "DIAMOND", "STAR", "NUMBERED"]).optional() }); var StyleGoogleSlidesShapeSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), objectId: z.string().min(1, "Shape object ID is required"), backgroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional(), alpha: z.number().min(0).max(1).optional() }).optional(), outlineColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional() }).optional(), outlineWeight: z.number().optional(), outlineDashStyle: z.enum(["SOLID", "DOT", "DASH", "DASH_DOT", "LONG_DASH", "LONG_DASH_DOT"]).optional() }); var SetGoogleSlidesBackgroundSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), pageObjectIds: z.array(z.string()).min(1, "At least one page object ID is required"), backgroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional(), alpha: z.number().min(0).max(1).optional() }) }); var CreateGoogleSlidesTextBoxSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), pageObjectId: z.string().min(1, "Page object ID is required"), text: z.string().min(1, "Text content is required"), x: z.number(), y: z.number(), width: z.number(), height: z.number(), fontSize: z.number().optional(), bold: z.boolean().optional(), italic: z.boolean().optional() }); var CreateGoogleSlidesShapeSchema = z.object({ presentationId: z.string().min(1, "Presentation ID is required"), pageObjectId: z.string().min(1, "Page object ID is required"), shapeType: z.enum(["RECTANGLE", "ELLIPSE", "DIAMOND", "TRIANGLE", "STAR", "ROUND_RECTANGLE", "ARROW"]), x: z.number(), y: z.number(), width: z.number(), height: z.number(), backgroundColor: z.object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional(), alpha: z.number().min(0).max(1).optional() }).optional() }); var server = new Server( { name: "google-drive-mcp", version: VERSION }, { capabilities: { resources: {}, tools: {} } } ); async function ensureAuthenticated() { if (!authClient) { if (authenticationPromise) { log("Authentication already in progress, waiting..."); authClient = await authenticationPromise; return; } log("Initializing authentication"); authenticationPromise = authenticate(); try { authClient = await authenticationPromise; log("Authentication complete", { authClientType: authClient?.constructor?.name, hasCredentials: !!authClient?.credentials, hasAccessToken: !!authClient?.credentials?.access_token }); ensureDriveService(); } finally { authenticationPromise = null; } } ensureDriveService(); } server.setRequestHandler(ListResourcesRequestSchema, async (request) => { await ensureAuthenticated(); log("Handling ListResources request", { params: request.params }); const pageSize = 10; const params = { pageSize, fields: "nextPageToken, files(id, name, mimeType)", q: `trashed = false` }; if (request.params?.cursor) { params.pageToken = request.params.cursor; } const res = await drive.files.list(params); log("Listed files", { count: res.data.files?.length }); const files = res.data.files || []; return { resources: files.map((file) => ({ uri: `gdrive:///${file.id}`, mimeType: file.mimeType || "application/octet-stream", name: file.name || "Untitled" })), nextCursor: res.data.nextPageToken }; }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { await ensureAuthenticated(); log("Handling ReadResource request", { uri: request.params.uri }); const fileId = request.params.uri.replace("gdrive:///", ""); const file = await drive.files.get({ fileId, fields: "mimeType" }); const mimeType = file.data.mimeType; if (!mimeType) { throw new Error("File has no MIME type."); } if (mimeType.startsWith("application/vnd.google-apps")) { let exportMimeType; switch (mimeType) { case "application/vnd.google-apps.document": exportMimeType = "text/markdown"; break; case "application/vnd.google-apps.spreadsheet": exportMimeType = "text/csv"; break; case "application/vnd.google-apps.presentation": exportMimeType = "text/plain"; break; case "application/vnd.google-apps.drawing": exportMimeType = "image/png"; break; default: exportMimeType = "text/plain"; break; } const res = await drive.files.export( { fileId, mimeType: exportMimeType }, { responseType: "text" } ); log("Successfully read resource", { fileId, mimeType }); return { contents: [ { uri: request.params.uri, mimeType: exportMimeType, text: res.data } ] }; } else { const res = await drive.files.get( { fileId, alt: "media" }, { responseType: "arraybuffer" } ); const contentMime = mimeType || "application/octet-stream"; if (contentMime.startsWith("text/") || contentMime === "application/json") { return { contents: [ { uri: request.params.uri, mimeType: contentMime, text: Buffer.from(res.data).toString("utf-8") } ] }; } else { return { contents: [ { uri: request.params.uri, mimeType: contentMime, blob: Buffer.from(res.data).toString("base64") } ] }; } } }); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "search", description: "Search for files in Google Drive", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } }, { name: "createTextFile", description: "Create a new text or markdown file", inputSchema: { type: "object", properties: { name: { type: "string", description: "File name (.txt or .md)" }, content: { type: "string", description: "File content" }, parentFolderId: { type: "string", description: "Optional parent folder ID", optional: true } }, required: ["name", "content"] } }, { name: "updateTextFile", description: "Update an existing text or markdown file", inputSchema: { type: "object", properties: { fileId: { type: "string", description: "ID of the file to update" }, content: { type: "string", description: "New file content" }, name: { type: "string", description: "Optional new name (.txt or .md)", optional: true } }, required: ["fileId", "content"] } }, { name: "createFolder", description: "Create a new folder in Google Drive", inputSchema: { type: "object", properties: { name: { type: "string", description: "Folder name" }, parent: { type: "string", description: "Optional parent folder ID or path", optional: true } }, required: ["name"] } }, { name: "listFolder", description: "List contents of a folder (defaults to root)", inputSchema: { type: "object", properties: { folderId: { type: "string", description: "Folder ID", optional: true }, pageSize: { type: "number", description: "Items to return (default 50, max 100)", optional: true }, pageToken: { type: "string", description: "Token for next page", optional: true } } } }, { name: "deleteItem", description: "Move a file or folder to trash (can be restored from Google Drive trash)", inputSchema: { type: "object", properties: { itemId: { type: "string", description: "ID of the item to delete" } }, required: ["itemId"] } }, { name: "renameItem", description: "Rename a file or folder", inputSchema: { type: "object", properties: { itemId: { type: "string", description: "ID of the item to rename" }, newName: { type: "string", description: "New name" } }, required: ["itemId", "newName"] } }, { name: "moveItem", description: "Move a file or folder", inputSchema: { type: "object", properties: { itemId: { type: "string", description: "ID of the item to move" }, destinationFolderId: { type: "string", description: "Destination folder ID", optional: true } }, required: ["itemId"] } }, { name: "createGoogleDoc", description: "Create a new Google Doc", inputSchema: { type: "object", properties: { name: { type: "string", description: "Doc name" }, content: { type: "string", description: "Doc content" }, parentFolderId: { type: "string", description: "Parent folder ID", optional: true } }, required: ["name", "content"] } }, { name: "updateGoogleDoc", description: "Update an existing Google Doc", inputSchema: { type: "object", properties: { documentId: { type: "string", description: "Doc ID" }, content: { type: "string", description: "New content" } }, required: ["documentId", "content"] } }, { name: "createGoogleSheet", description: "Create a new Google Sheet", inputSchema: { type: "object", properties: { name: { type: "string", description: "Sheet name" }, data: { type: "array", description: "Data as array of arrays", items: { type: "array", items: { type: "string" } } }, parentFolderId: { type: "string", description: "Parent folder ID (defaults to root)", optional: true } }, required: ["name", "data"] } }, { name: "updateGoogleSheet", description: "Update an existing Google Sheet", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", description: "Sheet ID" }, range: { type: "string", description: "Range to update" }, data: { type: "array", items: { type: "array", items: { type: "string" } } } }, required: ["spreadsheetId", "range", "data"] } }, { name: "getGoogleSheetContent", description: "Get content of a Google Sheet with cell information", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", description: "Spreadsheet ID" }, range: { type: "string", description: "Range to get (e.g., 'Sheet1!A1:C10')" } }, required: ["spreadsheetId", "range"] } }, { name: "formatGoogleSheetCells", description: "Format cells in a Google Sheet (background, borders, alignment)", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", description: "Spreadsheet ID" }, range: { type: "string", description: "Range to format (e.g., 'A1:C10')" }, backgroundColor: { type: "object", description: "Background color (RGB values 0-1)", properties: { red: { type: "number", optional: true }, green: { type: "number", optional: true }, blue: { type: "number", optional: true } }, optional: true }, horizontalAlignment: { type: "string", description: "Horizontal alignment", enum: ["LEFT", "CENTER", "RIGHT"], optional: true }, verticalAlignment: { type: "string", description: "Vertical alignment", enum: ["TOP", "MIDDLE", "BOTTOM"], optional: true }, wrapStrategy: { type: "string", description: "Text wrapping", enum: ["OVERFLOW_CELL", "CLIP", "WRAP"], optional: true } }, required: ["spreadsheetId", "range"] } }, { name: "formatGoogleSheetText", description: "Apply text formatting to cells in a Google Sheet", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", description: "Spreadsheet ID" }, range: { type: "string", description: "Range to format (e.g., 'A1:C10')" }, bold: { type: "boolean", description: "Make text bold", optional: true }, italic: { type: "boolean", description: "Make text italic", optional: true }, strikethrough: { type: "boolean", description: "Strikethrough text", optional: true }, underline: { type: "boolean", description: "Underline text", optional: true }, fontSize: { type: "number", description: "Font size in points", optional: true }, fontFamily: { type: "string", description: "Font family name", optional: true }, foregroundColor: { type: "object", description: "Text color (RGB values 0-1)", properties: { red: { type: "number", optional: true }, green: { type: "number", optional: true }, blue: { type: "number", optional: true } }, optional: true } }, required: ["spreadsheetId", "range"] } }, { name: "formatGoogleSheetNumbers", description: "Apply number formatting to cells in a Google Sheet", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", description: "Spreadsheet ID" }, range: { type: "string", description: "Range to format (e.g., 'A1:C10')" }, pattern: { type: "string", description: "Number format pattern (e.g., '#,##0.00', 'yyyy-mm-dd', '$#,##0.00', '0.00%')" }, type: { type: "string", description: "Format type", enum: ["NUMBER", "CURRENCY", "PERCENT