@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
JavaScript
#!/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