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,330 lines (1,308 loc) 358 kB
#!/usr/bin/env node var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { randomUUID } from "crypto"; import { google } from "googleapis"; // 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 getConfigDir() { const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); return path.join(configHome, "google-drive-mcp"); } function getSecureTokenPath() { const customTokenPath = process.env.GOOGLE_DRIVE_MCP_TOKEN_PATH; if (customTokenPath) { return path.resolve(customTokenPath); } return path.join(getConfigDir(), "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 getKeysFilePaths() { const paths = []; const envCredentialsPath = process.env.GOOGLE_DRIVE_OAUTH_CREDENTIALS; if (envCredentialsPath) { paths.push(path.resolve(envCredentialsPath)); } paths.push(path.join(getConfigDir(), "gcp-oauth.keys.json")); const projectRoot = getProjectRoot(); paths.push(path.join(projectRoot, "gcp-oauth.keys.json")); return paths; } function generateCredentialsErrorMessage() { const configDir = getConfigDir(); return ` OAuth credentials not found. Please provide credentials using one of these methods: 1. Config directory (recommended): Place your gcp-oauth.keys.json file in: ${configDir}/ 2. 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" 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 function parseCredentialsFile(keys) { 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 loadCredentialsFromFile() { const paths = getKeysFilePaths(); for (const keysPath of paths) { try { const keysContent = await fs.readFile(keysPath, "utf-8"); const keys = JSON.parse(keysContent); return parseCredentialsFile(keys); } catch (err) { if (err instanceof SyntaxError || err instanceof Error && err.message.includes("Invalid credentials")) { throw new Error(`Invalid credentials file at ${keysPath}: ${err.message}`); } } } throw new Error(`Credentials file not found. Searched: ${paths.join(", ")}`); } 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"; // src/auth/scopes.ts var SCOPE_ALIASES = { drive: "https://www.googleapis.com/auth/drive", "drive.file": "https://www.googleapis.com/auth/drive.file", "drive.readonly": "https://www.googleapis.com/auth/drive.readonly", documents: "https://www.googleapis.com/auth/documents", spreadsheets: "https://www.googleapis.com/auth/spreadsheets", presentations: "https://www.googleapis.com/auth/presentations", calendar: "https://www.googleapis.com/auth/calendar", "calendar.events": "https://www.googleapis.com/auth/calendar.events" }; var SCOPE_PRESETS = { readonly: ["drive.readonly"], "content-editor": ["drive.file", "documents", "spreadsheets", "presentations"], full: ["drive", "documents", "spreadsheets", "presentations", "calendar", "calendar.events"] }; var DEFAULT_SCOPES = [ "drive", "drive.file", "drive.readonly", "documents", "spreadsheets", "presentations", "calendar", "calendar.events" ].map((s) => SCOPE_ALIASES[s]); function resolveOAuthScopes() { const raw = process.env.GOOGLE_DRIVE_MCP_SCOPES?.trim(); if (!raw) return [...DEFAULT_SCOPES]; const scopes = raw.split(",").map((s) => s.trim()).filter(Boolean).map((s) => { if (SCOPE_ALIASES[s]) return SCOPE_ALIASES[s]; if (s.startsWith("https://")) return s; const known = Object.keys(SCOPE_ALIASES).join(", "); throw new Error( `Unknown OAuth scope alias "${s}". Use a full URL (https://...) or one of: ${known}` ); }); if (scopes.length === 0) return [...DEFAULT_SCOPES]; return [...new Set(scopes)]; } // src/auth/server.ts var SCOPES = resolveOAuthScopes(); 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(); const raw = process.env.GOOGLE_DRIVE_MCP_AUTH_PORT; const portStart = raw ? Number(raw) : 3e3; if (!Number.isInteger(portStart) || portStart < 1 || portStart > 65531) { throw new Error( `Invalid GOOGLE_DRIVE_MCP_AUTH_PORT: "${raw}". Must be an integer between 1 and 65531.` ); } this.portRange = { start: portStart, end: portStart + 4 }; 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((resolve3, reject) => { const testServer = this.app.listen(port, () => { this.server = testServer; console.error(`Authentication server listening on http://localhost:${port}`); resolve3(); }); 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((resolve3, reject) => { if (this.server) { this.server.close((err) => { if (err) { reject(err); } else { this.server = null; resolve3(); } }); } else { resolve3(); } }); } }; // src/auth/externalAuth.ts import { OAuth2Client as OAuth2Client3 } from "google-auth-library"; import { GoogleAuth } from "google-auth-library"; function isServiceAccountMode() { return !!process.env.GOOGLE_APPLICATION_CREDENTIALS; } async function createServiceAccountAuth() { const keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; console.error(`Using service account credentials from ${keyFile}`); const auth = new GoogleAuth({ keyFile, scopes: [...DEFAULT_SCOPES] }); const client = await auth.getClient(); console.error("Service account authentication successful"); return client; } function isExternalTokenMode() { return !!process.env.GOOGLE_DRIVE_MCP_ACCESS_TOKEN; } function validateExternalTokenConfig() { const accessToken = process.env.GOOGLE_DRIVE_MCP_ACCESS_TOKEN?.trim(); if (!accessToken) { throw new Error( "GOOGLE_DRIVE_MCP_ACCESS_TOKEN is set but empty. Provide a valid OAuth access token." ); } const refreshToken = process.env.GOOGLE_DRIVE_MCP_REFRESH_TOKEN?.trim(); const clientId = process.env.GOOGLE_DRIVE_MCP_CLIENT_ID?.trim(); const clientSecret = process.env.GOOGLE_DRIVE_MCP_CLIENT_SECRET?.trim(); if (refreshToken) { if (!clientId || !clientSecret) { throw new Error( "GOOGLE_DRIVE_MCP_REFRESH_TOKEN is set but GOOGLE_DRIVE_MCP_CLIENT_ID and/or GOOGLE_DRIVE_MCP_CLIENT_SECRET are missing. All three are required for automatic token refresh." ); } } if (clientId && !clientSecret || !clientId && clientSecret) { throw new Error( "Both GOOGLE_DRIVE_MCP_CLIENT_ID and GOOGLE_DRIVE_MCP_CLIENT_SECRET must be provided together." ); } } function createExternalOAuth2Client() { const accessToken = process.env.GOOGLE_DRIVE_MCP_ACCESS_TOKEN.trim(); const refreshToken = process.env.GOOGLE_DRIVE_MCP_REFRESH_TOKEN?.trim(); const clientId = process.env.GOOGLE_DRIVE_MCP_CLIENT_ID?.trim(); const clientSecret = process.env.GOOGLE_DRIVE_MCP_CLIENT_SECRET?.trim(); const oauth2Client = new OAuth2Client3(clientId, clientSecret); oauth2Client.setCredentials({ access_token: accessToken, refresh_token: refreshToken || void 0 }); if (!refreshToken) { console.error( "Warning: No refresh token provided. The access token will not auto-refresh when it expires." ); } else { console.error("External OAuth tokens configured with auto-refresh support."); } return oauth2Client; } // src/auth.ts async function authenticate() { console.error("Initializing authentication..."); if (isServiceAccountMode()) { return await createServiceAccountAuth(); } if (isExternalTokenMode()) { validateExternalTokenConfig(); return createExternalOAuth2Client(); } 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((resolve3) => { const checkInterval = setInterval(async () => { if (authServer.authCompletedSuccessfully) { clearInterval(checkInterval); await authServer.stop(); resolve3(); } }, 1e3); }); return oauth2Client; } // src/index.ts import { fileURLToPath as fileURLToPath2 } from "url"; import { readFileSync } from "fs"; import { join as join4, dirname as dirname4 } from "path"; // src/utils.ts function buildCalendarEventUpdate(existing, overrides) { return { summary: overrides.summary !== void 0 ? overrides.summary : existing.summary, description: overrides.description !== void 0 ? overrides.description : existing.description, location: overrides.location !== void 0 ? overrides.location : existing.location, start: overrides.start || existing.start, end: overrides.end || existing.end, attendees: overrides.attendees !== void 0 ? overrides.attendees.map((email) => ({ email })) : existing.attendees, recurrence: existing.recurrence, visibility: existing.visibility, reminders: existing.reminders }; } function getExtensionFromFilename(filename) { return filename.split(".").pop()?.toLowerCase() || ""; } var TEXT_MIME_TYPES = { txt: "text/plain", md: "text/markdown" }; function getMimeTypeFromFilename(filename) { const ext = getExtensionFromFilename(filename); return TEXT_MIME_TYPES[ext] || "text/plain"; } function escapeDriveQuery(value) { return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); } function parseA1Range(range) { if (range.includes("!")) { const sheetName = range.split("!")[0].replace(/^'+|'+$/g, ""); const cellRange = range.split("!")[1]; return { sheetName, cellRange }; } return { sheetName: "Sheet1", cellRange: range }; } function colToIndex(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; } 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 }; if (startCol) gridRange.startColumnIndex = colToIndex(startCol); if (startRow) gridRange.startRowIndex = parseInt(startRow) - 1; if (endCol) { gridRange.endColumnIndex = colToIndex(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; } // src/types.ts function errorResponse(message) { return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }; } // src/tools/drive.ts var drive_exports = {}; __export(drive_exports, { handleTool: () => handleTool, toolDefinitions: () => toolDefinitions }); import { z } from "zod"; import { existsSync as existsSync2, statSync as statSync2, createReadStream } from "fs"; import { mkdtemp, readFile as readFile3, writeFile as writeFile2, rm } from "fs/promises"; import { tmpdir } from "os"; import { basename as basename2, extname as extname2, join as join3 } from "path"; import { PDFDocument } from "pdf-lib"; // src/download-file.ts import { createWriteStream, existsSync, renameSync, statSync, unlinkSync } from "fs"; import { basename, dirname as dirname3, extname, isAbsolute, join as join2, relative, resolve as resolve2 } from "path"; import { pipeline } from "stream/promises"; var GOOGLE_WORKSPACE_EXPORT_FORMATS = { "application/vnd.google-apps.document": { pdf: "application/pdf", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", md: "text/markdown", txt: "text/plain", html: "text/html", rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text", epub: "application/epub+zip" }, "application/vnd.google-apps.spreadsheet": { xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", csv: "text/csv", pdf: "application/pdf", ods: "application/vnd.oasis.opendocument.spreadsheet", tsv: "text/tab-separated-values", html: "text/html" }, "application/vnd.google-apps.presentation": { pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", pdf: "application/pdf", txt: "text/plain", odp: "application/vnd.oasis.opendocument.presentation" }, "application/vnd.google-apps.drawing": { png: "image/png", svg: "image/svg+xml", pdf: "application/pdf", jpg: "image/jpeg" } }; var GOOGLE_WORKSPACE_DEFAULT_EXPORT = { "application/vnd.google-apps.document": { mimeType: "application/pdf", ext: ".pdf" }, "application/vnd.google-apps.spreadsheet": { mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, "application/vnd.google-apps.presentation": { mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", ext: ".pptx" }, "application/vnd.google-apps.drawing": { mimeType: "image/png", ext: ".png" } }; function sanitizeDriveFilename(driveName) { return basename(driveName).replace(/^\.+/, "") || "download"; } function isPathWithinDirectory(targetPath, directoryPath) { const relativePath = relative(resolve2(directoryPath), resolve2(targetPath)); return relativePath === "" || !relativePath.startsWith("..") && !isAbsolute(relativePath); } function resolveWorkspaceExport(driveMimeType, args, resolvedPath, isDirectory) { const formatMap = GOOGLE_WORKSPACE_EXPORT_FORMATS[driveMimeType]; if (!formatMap) { throw new Error( `Unsupported Google Workspace type for export: ${driveMimeType}. Supported types: Document, Spreadsheet, Presentation, Drawing.` ); } if (args.exportMimeType) { const validMimes = Object.values(formatMap); if (!validMimes.includes(args.exportMimeType)) { throw new Error( `Unsupported export format '${args.exportMimeType}' for ${driveMimeType}. Supported: ${Object.entries(formatMap).map(([ext, mime]) => `${mime} (.${ext})`).join(", ")}` ); } const extForMime = Object.entries(formatMap).find(([, mime]) => mime === args.exportMimeType)?.[0] || "bin"; return { exportMime: args.exportMimeType, fileExtForName: `.${extForMime}` }; } if (!isDirectory && extname(resolvedPath)) { const ext = extname(resolvedPath).slice(1).toLowerCase(); if (formatMap[ext]) { return { exportMime: formatMap[ext], fileExtForName: `.${ext}` }; } } const defaultExport = GOOGLE_WORKSPACE_DEFAULT_EXPORT[driveMimeType]; return { exportMime: defaultExport.mimeType, fileExtForName: defaultExport.ext }; } function buildTempPath(resolvedPath) { const random = Math.random().toString(16).slice(2); return `${resolvedPath}.download-${Date.now()}-${random}.tmp`; } async function downloadDriveFile(drive, args, log2) { if (!isAbsolute(args.localPath)) { throw new Error("localPath must be an absolute path"); } const normalizedLocalPath = resolve2(args.localPath); const fileMeta = await drive.files.get({ fileId: args.fileId, fields: "id, name, mimeType, size", supportsAllDrives: true }); const driveMimeType = fileMeta.data.mimeType; const driveName = fileMeta.data.name || "download"; if (!driveMimeType) { throw new Error("File has no MIME type"); } const isWorkspaceFile = driveMimeType.startsWith("application/vnd.google-apps"); const overwrite = args.overwrite ?? false; let resolvedPath = normalizedLocalPath; let isDirectory = false; if (existsSync(resolvedPath)) { isDirectory = statSync(resolvedPath).isDirectory(); } else { const parentDir = dirname3(resolvedPath); if (!existsSync(parentDir)) { throw new Error(`Parent directory does not exist: ${parentDir}`); } } let exportMime; let fileExtForName = ""; if (isWorkspaceFile) { const exportSelection = resolveWorkspaceExport(driveMimeType, args, resolvedPath, isDirectory); exportMime = exportSelection.exportMime; fileExtForName = exportSelection.fileExtForName; } if (isDirectory) { const safeName = sanitizeDriveFilename(driveName); let fileName = safeName; if (isWorkspaceFile) { const nameWithoutExt = safeName.replace(/\.[^.]+$/, ""); fileName = `${nameWithoutExt}${fileExtForName}`; } resolvedPath = join2(resolvedPath, fileName); if (!isPathWithinDirectory(resolvedPath, normalizedLocalPath)) { throw new Error("Resolved file path escapes the target directory"); } } const targetExists = existsSync(resolvedPath); if (targetExists && !overwrite) { throw new Error(`File already exists at ${resolvedPath}. Set overwrite: true to replace it.`); } log2("Downloading file", { fileId: args.fileId, driveName, driveMimeType, isWorkspaceFile, exportMime, localPath: resolvedPath }); const response = isWorkspaceFile ? await drive.files.export({ fileId: args.fileId, mimeType: exportMime }, { responseType: "stream" }) : await drive.files.get({ fileId: args.fileId, alt: "media", supportsAllDrives: true }, { responseType: "stream" }); const writePath = overwrite && targetExists ? buildTempPath(resolvedPath) : resolvedPath; const dest = createWriteStream(writePath); try { await pipeline(response.data, dest); if (writePath !== resolvedPath) { renameSync(writePath, resolvedPath); } } catch (downloadErr) { try { unlinkSync(writePath); } catch { } throw downloadErr; } const finalStats = statSync(resolvedPath); log2("File downloaded successfully", { fileId: args.fileId, localPath: resolvedPath, size: finalStats.size }); return { driveName, driveMimeType, exportMime, isWorkspaceFile, resolvedPath, size: finalStats.size }; } // src/tools/drive.ts var FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; var SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; var BINARY_MIME_TYPES = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac", opus: "audio/opus", mp4: "video/mp4", webm: "video/webm", avi: "video/x-msvideo", mov: "video/quicktime", mkv: "video/x-matroska", "3gp": "video/3gpp", pdf: "application/pdf", zip: "application/zip", gz: "application/gzip", tar: "application/x-tar", json: "application/json", xml: "application/xml", csv: "text/csv", html: "text/html", css: "text/css", js: "application/javascript", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }; var SearchSchema = z.object({ query: z.string().min(1, "Search query is required"), pageSize: z.number().int().min(1).max(100).optional(), pageToken: z.string().optional(), rawQuery: z.boolean().optional() }); 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().int().min(1).max(100).optional(), pageToken: z.string().optional() }); var ListSharedDrivesSchema = z.object({ pageSize: z.number().int().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 CopyFileSchema = z.object({ fileId: z.string().min(1, "File ID is required"), newName: z.string().optional(), parentFolderId: z.string().optional() }); var CreateShortcutSchema = z.object({ targetFileId: z.string().min(1, "Target file ID is required"), parentFolderId: z.string().optional(), shortcutName: z.string().optional() }); var LockFileSchema = z.object({ fileId: z.string().min(1, "File ID is required"), reason: z.string().optional(), ownerRestricted: z.boolean().optional() }); var UnlockFileSchema = z.object({ fileId: z.string().min(1, "File ID is required") }); var UploadFileSchema = z.object({ localPath: z.string().min(1, "Local file path is required"), name: z.string().optional(), parentFolderId: z.string().optional(), mimeType: z.string().optional(), convertToGoogleFormat: z.boolean().optional() }); var DownloadFileSchema = z.object({ fileId: z.string().min(1, "File ID is required"), localPath: z.string().min(1, "Local file path is required"), exportMimeType: z.string().optional(), overwrite: z.boolean().optional().default(false) }); var ListPermissionsSchema = z.object({ fileId: z.string().min(1, "File ID is required") }); var AddPermissionSchema = z.object({ fileId: z.string().min(1, "File ID is required"), emailAddress: z.string().email("Valid email is required"), role: z.enum(["owner", "organizer", "fileOrganizer", "writer", "commenter", "reader"]).default("reader"), type: z.enum(["user", "group", "domain", "anyone"]).default("user"), sendNotificationEmail: z.boolean().optional().default(false), emailMessage: z.string().optional() }); var UpdatePermissionSchema = z.object({ fileId: z.string().min(1, "File ID is required"), permissionId: z.string().min(1, "Permission ID is required"), role: z.enum(["owner", "organizer", "fileOrganizer", "writer", "commenter", "reader"]) }); var RemovePermissionSchema = z.object({ fileId: z.string().min(1, "File ID is required"), permissionId: z.string().optional(), emailAddress: z.string().email("Valid email is required").optional() }).superRefine((data, ctx) => { if (!data.permissionId && !data.emailAddress) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Either permissionId or emailAddress is required" }); } }); var ShareFileSchema = z.object({ fileId: z.string().min(1, "File ID is required"), emailAddress: z.string().email("Valid email is required"), role: z.enum(["writer", "commenter", "reader"]).default("reader"), sendNotificationEmail: z.boolean().optional().default(true), emailMessage: z.string().optional() }); var ConvertPdfToGoogleDocSchema = z.object({ fileId: z.string().min(1, "File ID is required"), newName: z.string().optional(), parentFolderId: z.string().optional() }); var BulkConvertFolderPdfsSchema = z.object({ folderId: z.string().min(1, "Folder ID is required"), maxResults: z.number().int().min(1).max(200).optional().default(100), continueOnError: z.boolean().optional().default(true) }); var UploadPdfWithSplitSchema = z.object({ localPath: z.string().min(1, "Local file path is required"), split: z.boolean().optional().default(false), maxPagesPerChunk: z.number().int().min(1).max(500).optional(), parentFolderId: z.string().optional(), namePrefix: z.string().optional() }); async function splitPdfIntoChunkFiles(localPath, maxPagesPerChunk) { const sourceBytes = await readFile3(localPath); const source = await PDFDocument.load(sourceBytes); const pageCount = source.getPageCount(); if (pageCount === 0) { throw new Error("PDF contains no pages."); } const tempDir = await mkdtemp(join3(tmpdir(), "gdrive-mcp-split-")); const files = []; for (let start = 0, part = 1; start < pageCount; start += maxPagesPerChunk, part++) { const end = Math.min(start + maxPagesPerChunk, pageCount); const chunkDoc = await PDFDocument.create(); const pages = await chunkDoc.copyPages(source, Array.from({ length: end - start }, (_, i) => start + i)); for (const page of pages) chunkDoc.addPage(page); const chunkBytes = await chunkDoc.save(); const chunkPath = join3(tempDir, `part-${part}.pdf`); await writeFile2(chunkPath, chunkBytes); files.push(chunkPath); } return { tempDir, files }; } var GetRevisionsSchema = z.object({ fileId: z.string().min(1, "File ID is required"), pageSize: z.number().int().min(1).max(200).optional().default(50), pageToken: z.string().optional() }); var RestoreRevisionSchema = z.object({ fileId: z.string().min(1, "File ID is required"), revisionId: z.string().min(1, "Revision ID is required"), confirm: z.boolean().optional().default(false) }); var AuthTestFileAccessSchema = z.object({ fileId: z.string().optional() }); function getGrantedScopesFromAuthClient(ctx) { const scopeRaw = ctx.authClient?.credentials?.scope; if (!scopeRaw || typeof scopeRaw !== "string") return []; return [...new Set(scopeRaw.split(" ").map((s) => s.trim()).filter(Boolean))]; } function resolveScopeStatus(ctx) { const requestedScopes = resolveOAuthScopes(); const grantedScopes = getGrantedScopesFromAuthClient(ctx); const missingScopes = requestedScopes.filter((s) => !grantedScopes.includes(s)); return { requestedScopes, grantedScopes, missingScopes }; } var toolDefinitions = [ { name: "search", description: "Search for files in Google Drive. Set rawQuery=true to pass a raw Google Drive API query supporting operators like modifiedTime, createdTime, mimeType, name contains, etc.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query. When rawQuery=true, this is passed directly to the Google Drive API as the q parameter." }, pageSize: { type: "number", description: "Results per page (default 50, max 100)" }, pageToken: { type: "string", description: "Token for next page of results" }, rawQuery: { type: "boolean", description: "If true, pass query directly to Google Drive API without wrapping in fullText contains. Enables date filters, mimeType filters, etc." } }, 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: "Parent folder ID" } }, 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: "New name (.txt or .md)" } }, 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: "Parent folder ID or path" } }, required: ["name"] } }, { name: "listFolder", description: "List contents of a folder (defaults to root)", inputSchema: { type: "object", properties: { folderId: { type: "string", description: "Folder ID" }, pageSize: { type: "number", description: "Items to return (default 50, max 100)" }, pageToken: { type: "string", description: "Token for next page" } } } }, { name: "listSharedDrives", description: "List available Google Shared Drives", inputSchema: { type: "object", properties: { pageSize: { type: "number", description: "Drives to return (default 50, max 100)" }, pageToken: { type: "string", description: "Token for next page" } } } }, { 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" } }, required: ["itemId"] } }, { name: "copyFile", description: "Creates a copy of a Google Drive file or document", inputSchema: { type: "object", properties: { fileId: { type: "string", description: "ID of the file to copy" }, newName: { type: "string", description: "Name for the copied file. If not provided, will use 'Copy of [original name]'" }, parentFolderId: { type: "string", description: "ID or path of the destination folder (defaults to same folder as original)" } }, required: ["fileId"] } }, { name: "uploadFile", description: "Upload a local file (any type: image, audio, video, PDF, etc.) to Google Drive", inputSchema: { type: "object", properties: { localPath: { type: "string", description: "Absolute path to the local file to upload" }, name: { type: "string", description: "File name in Drive (defaults to local filename)" }, parentFolderId: { type: "string", description: "Parent folder ID or path (e.g., '/Work/Projects'). Creates folders if needed. Defaults to root." }, mimeType: { type: "string", description: "MIME type (auto-detected from extension if omitted)" }, convertToGoogleFormat: { type: "boolean", description: "Convert uploaded file to Google Workspace format (e.g., .docx to Google Doc, .xlsx to Google Sheet, .pptx to Google Slides). Defaults to false." } }, required: ["localPath"] } }, { name: "downloadFile", description: "Download a Google Drive file to a local path. For Google Workspace files (Docs, Sheets, Slides, Drawings), exports to the specified format. For regular files, downloads as-is. Streams directly to disk.", inputSchema: { type: "object", properties: { fileId: { type: "string", description: "Google Drive file ID" }, localPath: { type: "string", description: "Absolute local path to save the file (must start with /). Can be a directory (filename auto-resolved from Drive metadata) or a full file path. Path is normalized before use." }, exportMimeType: { type: "string", description: "For Google Workspace files: MIME t