@triffon/google-docs-mcp 1.11.3-triffon

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +567 -0
  3. package/dist/auth.js +327 -0
  4. package/dist/cachedToolsList.js +38 -0
  5. package/dist/clients.js +180 -0
  6. package/dist/config.js +9 -0
  7. package/dist/downloadProxy.js +82 -0
  8. package/dist/driveQueryUtils.js +7 -0
  9. package/dist/firestoreTokenStorage.js +32 -0
  10. package/dist/googleDocsApiHelpers.js +1257 -0
  11. package/dist/googleSheetsApiHelpers.js +849 -0
  12. package/dist/index.js +240 -0
  13. package/dist/landingPage.js +103 -0
  14. package/dist/logger.js +58 -0
  15. package/dist/markdown-transformer/docsToMarkdown.js +259 -0
  16. package/dist/markdown-transformer/index.js +128 -0
  17. package/dist/markdown-transformer/markdownToDocs.js +834 -0
  18. package/dist/remoteWrapper.js +72 -0
  19. package/dist/tools/calendar/createEvent.js +89 -0
  20. package/dist/tools/calendar/deleteEvent.js +49 -0
  21. package/dist/tools/calendar/helpers.js +19 -0
  22. package/dist/tools/calendar/index.js +12 -0
  23. package/dist/tools/calendar/listEvents.js +84 -0
  24. package/dist/tools/calendar/quickAddEvent.js +55 -0
  25. package/dist/tools/calendar/updateEvent.js +84 -0
  26. package/dist/tools/docs/addTab.js +84 -0
  27. package/dist/tools/docs/appendTableRows.js +89 -0
  28. package/dist/tools/docs/appendToGoogleDoc.js +85 -0
  29. package/dist/tools/docs/cloneTable.js +159 -0
  30. package/dist/tools/docs/comments/addComment.js +83 -0
  31. package/dist/tools/docs/comments/deleteComment.js +30 -0
  32. package/dist/tools/docs/comments/getComment.js +45 -0
  33. package/dist/tools/docs/comments/index.js +14 -0
  34. package/dist/tools/docs/comments/listComments.js +43 -0
  35. package/dist/tools/docs/comments/replyToComment.js +35 -0
  36. package/dist/tools/docs/comments/resolveComment.js +55 -0
  37. package/dist/tools/docs/deleteRange.js +61 -0
  38. package/dist/tools/docs/deleteTableRows.js +62 -0
  39. package/dist/tools/docs/findAndReplace.js +54 -0
  40. package/dist/tools/docs/findElement.js +43 -0
  41. package/dist/tools/docs/findSectionsByHeading.js +46 -0
  42. package/dist/tools/docs/formatting/applyParagraphStyle.js +83 -0
  43. package/dist/tools/docs/formatting/applyTextStyle.js +49 -0
  44. package/dist/tools/docs/formatting/batchApplyTextStyle.js +86 -0
  45. package/dist/tools/docs/formatting/index.js +16 -0
  46. package/dist/tools/docs/formatting/updateTableBorders.js +81 -0
  47. package/dist/tools/docs/formatting/updateTableCellStyle.js +85 -0
  48. package/dist/tools/docs/formatting/updateTableColumnWidth.js +51 -0
  49. package/dist/tools/docs/formatting/updateTableRowStyle.js +74 -0
  50. package/dist/tools/docs/getTableStructure.js +48 -0
  51. package/dist/tools/docs/index.js +66 -0
  52. package/dist/tools/docs/insertDateChip.js +82 -0
  53. package/dist/tools/docs/insertImage.js +112 -0
  54. package/dist/tools/docs/insertPageBreak.js +47 -0
  55. package/dist/tools/docs/insertPerson.js +53 -0
  56. package/dist/tools/docs/insertRichLink.js +58 -0
  57. package/dist/tools/docs/insertSectionBreak.js +60 -0
  58. package/dist/tools/docs/insertTable.js +42 -0
  59. package/dist/tools/docs/insertTableWithData.js +125 -0
  60. package/dist/tools/docs/insertText.js +49 -0
  61. package/dist/tools/docs/listDocumentTables.js +47 -0
  62. package/dist/tools/docs/listDocumentTabs.js +59 -0
  63. package/dist/tools/docs/listSmartChips.js +41 -0
  64. package/dist/tools/docs/modifyText.js +147 -0
  65. package/dist/tools/docs/readGoogleDoc.js +164 -0
  66. package/dist/tools/docs/renameTab.js +47 -0
  67. package/dist/tools/docs/replaceTableRowData.js +55 -0
  68. package/dist/tools/docs/smartChipHelpers.js +71 -0
  69. package/dist/tools/docs/structureHelpers.js +250 -0
  70. package/dist/tools/docs/tabFieldMasks.js +48 -0
  71. package/dist/tools/docs/tableRowDataHelpers.js +53 -0
  72. package/dist/tools/docs/updateSectionStyle.js +148 -0
  73. package/dist/tools/drive/copyFile.js +63 -0
  74. package/dist/tools/drive/createDocument.js +105 -0
  75. package/dist/tools/drive/createFolder.js +48 -0
  76. package/dist/tools/drive/createFromTemplate.js +82 -0
  77. package/dist/tools/drive/deleteFile.js +72 -0
  78. package/dist/tools/drive/downloadFile.js +266 -0
  79. package/dist/tools/drive/getDocumentInfo.js +48 -0
  80. package/dist/tools/drive/getFolderInfo.js +48 -0
  81. package/dist/tools/drive/index.js +34 -0
  82. package/dist/tools/drive/listDriveFiles.js +129 -0
  83. package/dist/tools/drive/listFolderContents.js +83 -0
  84. package/dist/tools/drive/listGoogleDocs.js +70 -0
  85. package/dist/tools/drive/moveFile.js +54 -0
  86. package/dist/tools/drive/renameFile.js +39 -0
  87. package/dist/tools/drive/savePathGuard.js +86 -0
  88. package/dist/tools/drive/searchDriveFiles.js +148 -0
  89. package/dist/tools/drive/searchGoogleDocs.js +77 -0
  90. package/dist/tools/drive/setFilePermission.js +68 -0
  91. package/dist/tools/gmail/createDraft.js +58 -0
  92. package/dist/tools/gmail/deleteDraft.js +37 -0
  93. package/dist/tools/gmail/getDraft.js +52 -0
  94. package/dist/tools/gmail/getMessage.js +92 -0
  95. package/dist/tools/gmail/helpers.js +113 -0
  96. package/dist/tools/gmail/index.js +28 -0
  97. package/dist/tools/gmail/listDrafts.js +74 -0
  98. package/dist/tools/gmail/listLabels.js +31 -0
  99. package/dist/tools/gmail/listMessages.js +87 -0
  100. package/dist/tools/gmail/modifyMessageLabels.js +54 -0
  101. package/dist/tools/gmail/sendDraft.js +42 -0
  102. package/dist/tools/gmail/sendEmail.js +56 -0
  103. package/dist/tools/gmail/trashMessage.js +37 -0
  104. package/dist/tools/gmail/triageInbox.js +147 -0
  105. package/dist/tools/gmail/updateDraft.js +60 -0
  106. package/dist/tools/index.js +64 -0
  107. package/dist/tools/script/appsScriptShared.js +96 -0
  108. package/dist/tools/script/createAppsScriptProject.js +74 -0
  109. package/dist/tools/script/getAppsScriptContent.js +56 -0
  110. package/dist/tools/script/index.js +14 -0
  111. package/dist/tools/script/updateAppsScriptContent.js +56 -0
  112. package/dist/tools/sheets/addConditionalFormatting.js +143 -0
  113. package/dist/tools/sheets/addSpreadsheetSheet.js +34 -0
  114. package/dist/tools/sheets/appendSpreadsheetRows.js +44 -0
  115. package/dist/tools/sheets/appendTableRows.js +51 -0
  116. package/dist/tools/sheets/autoResizeColumns.js +67 -0
  117. package/dist/tools/sheets/autoResizeRows.js +63 -0
  118. package/dist/tools/sheets/batchWrite.js +61 -0
  119. package/dist/tools/sheets/clearSpreadsheetRange.js +31 -0
  120. package/dist/tools/sheets/comments/commentAnchor.js +95 -0
  121. package/dist/tools/sheets/comments/createSheetsCellNote.js +33 -0
  122. package/dist/tools/sheets/comments/createSheetsComment.js +168 -0
  123. package/dist/tools/sheets/comments/deleteSheetsComment.js +32 -0
  124. package/dist/tools/sheets/comments/getSheetsComment.js +51 -0
  125. package/dist/tools/sheets/comments/index.js +16 -0
  126. package/dist/tools/sheets/comments/listSheetsComments.js +177 -0
  127. package/dist/tools/sheets/comments/replyToSheetsComment.js +37 -0
  128. package/dist/tools/sheets/comments/resolveSheetsComment.js +53 -0
  129. package/dist/tools/sheets/copyFormatting.js +59 -0
  130. package/dist/tools/sheets/copySheetTo.js +36 -0
  131. package/dist/tools/sheets/createSpreadsheet.js +72 -0
  132. package/dist/tools/sheets/createTable.js +120 -0
  133. package/dist/tools/sheets/deleteChart.js +41 -0
  134. package/dist/tools/sheets/deleteConditionalFormatting.js +46 -0
  135. package/dist/tools/sheets/deleteSheet.js +43 -0
  136. package/dist/tools/sheets/deleteTable.js +56 -0
  137. package/dist/tools/sheets/duplicateSheet.js +53 -0
  138. package/dist/tools/sheets/formatCells.js +122 -0
  139. package/dist/tools/sheets/freezeRowsAndColumns.js +58 -0
  140. package/dist/tools/sheets/getConditionalFormatting.js +98 -0
  141. package/dist/tools/sheets/getSpreadsheetInfo.js +44 -0
  142. package/dist/tools/sheets/getTable.js +48 -0
  143. package/dist/tools/sheets/groupRows.js +62 -0
  144. package/dist/tools/sheets/index.js +84 -0
  145. package/dist/tools/sheets/insertChart.js +225 -0
  146. package/dist/tools/sheets/listGoogleSheets.js +66 -0
  147. package/dist/tools/sheets/listTables.js +55 -0
  148. package/dist/tools/sheets/protectRange.js +59 -0
  149. package/dist/tools/sheets/readCellFormat.js +143 -0
  150. package/dist/tools/sheets/readSpreadsheet.js +36 -0
  151. package/dist/tools/sheets/renameSheet.js +48 -0
  152. package/dist/tools/sheets/setCellBorders.js +94 -0
  153. package/dist/tools/sheets/setColumnWidths.js +43 -0
  154. package/dist/tools/sheets/setDropdownValidation.js +51 -0
  155. package/dist/tools/sheets/setRowHeights.js +63 -0
  156. package/dist/tools/sheets/ungroupAllRows.js +66 -0
  157. package/dist/tools/sheets/updateTableRange.js +51 -0
  158. package/dist/tools/sheets/writeSpreadsheet.js +45 -0
  159. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +95 -0
  160. package/dist/tools/utils/index.js +8 -0
  161. package/dist/tools/utils/replaceDocumentWithMarkdown.js +161 -0
  162. package/dist/tools/utils/replaceRangeWithMarkdown.js +72 -0
  163. package/dist/types.js +208 -0
  164. package/dist/upstreamAuth.js +62 -0
  165. package/package.json +51 -0
package/dist/auth.js ADDED
@@ -0,0 +1,327 @@
1
+ // src/auth.ts
2
+ import { google } from 'googleapis';
3
+ import { JWT } from 'google-auth-library';
4
+ import * as fs from 'fs/promises';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+ import * as http from 'http';
8
+ import { fileURLToPath } from 'url';
9
+ import * as crypto from 'crypto';
10
+ import { logger } from './logger.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Paths
13
+ // ---------------------------------------------------------------------------
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+ const projectRootDir = path.resolve(__dirname, '..');
17
+ /** Credentials file path (legacy dev workflow fallback). */
18
+ const CREDENTIALS_PATH = path.join(projectRootDir, 'credentials.json');
19
+ /**
20
+ * Token storage directory following XDG Base Directory spec.
21
+ * Uses $XDG_CONFIG_HOME if set, otherwise ~/.config.
22
+ *
23
+ * When GOOGLE_MCP_PROFILE is set, tokens are stored in a subdirectory
24
+ * per profile, allowing multiple Google accounts (one per project).
25
+ */
26
+ function getConfigDir() {
27
+ const xdg = process.env.XDG_CONFIG_HOME;
28
+ const base = xdg || path.join(os.homedir(), '.config');
29
+ const baseDir = path.join(base, 'google-docs-mcp');
30
+ const profile = process.env.GOOGLE_MCP_PROFILE;
31
+ if (profile && !/^[\w-]+$/.test(profile)) {
32
+ throw new Error('GOOGLE_MCP_PROFILE must contain only alphanumeric characters, hyphens, or underscores.');
33
+ }
34
+ return profile ? path.join(baseDir, profile) : baseDir;
35
+ }
36
+ function getTokenPath() {
37
+ return path.join(getConfigDir(), 'token.json');
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Scopes
41
+ // ---------------------------------------------------------------------------
42
+ const SCOPES = [
43
+ 'https://www.googleapis.com/auth/documents',
44
+ 'https://www.googleapis.com/auth/drive',
45
+ 'https://www.googleapis.com/auth/spreadsheets',
46
+ 'https://www.googleapis.com/auth/script.external_request',
47
+ 'https://www.googleapis.com/auth/script.projects',
48
+ 'https://www.googleapis.com/auth/gmail.modify',
49
+ 'https://www.googleapis.com/auth/calendar.events',
50
+ ];
51
+ const TOKEN_CREDENTIAL_FIELDS = ['access_token', 'refresh_token', 'scope', 'token_type'];
52
+ export function sanitizeStoredTokenCredentials(rawCredentials) {
53
+ if (!rawCredentials || typeof rawCredentials !== 'object' || Array.isArray(rawCredentials)) {
54
+ throw new Error('Invalid saved token format.');
55
+ }
56
+ const raw = rawCredentials;
57
+ const credentials = {};
58
+ for (const field of TOKEN_CREDENTIAL_FIELDS) {
59
+ const value = raw[field];
60
+ if (typeof value === 'string' && value.length > 0) {
61
+ credentials[field] = value;
62
+ }
63
+ }
64
+ if (typeof raw.expiry_date === 'number') {
65
+ credentials.expiry_date = raw.expiry_date;
66
+ }
67
+ if (!credentials.refresh_token && !credentials.access_token) {
68
+ throw new Error('Saved token does not contain OAuth token credentials.');
69
+ }
70
+ return credentials;
71
+ }
72
+ export function createStoredTokenPayload(credentials) {
73
+ return sanitizeStoredTokenCredentials({
74
+ refresh_token: credentials.refresh_token,
75
+ });
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Client secrets resolution
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * Resolves OAuth client ID and secret.
82
+ *
83
+ * Priority:
84
+ * 1. GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET env vars (npx / production)
85
+ * 2. credentials.json in the project root (local dev fallback)
86
+ */
87
+ export function describeMissingCredentials(credentialsPath, envId, envSecret) {
88
+ // A half-configured environment is the most confusing case: the user believes
89
+ // they configured the server, so "no credentials found" reads as a bug.
90
+ if (envId && !envSecret) {
91
+ return ('GOOGLE_CLIENT_ID is set but GOOGLE_CLIENT_SECRET is not, so OAuth cannot start. ' +
92
+ 'Set GOOGLE_CLIENT_SECRET as well, or remove GOOGLE_CLIENT_ID and place a ' +
93
+ `credentials.json file at ${credentialsPath} instead.`);
94
+ }
95
+ if (!envId && envSecret) {
96
+ return ('GOOGLE_CLIENT_SECRET is set but GOOGLE_CLIENT_ID is not, so OAuth cannot start. ' +
97
+ 'Set GOOGLE_CLIENT_ID as well, or remove GOOGLE_CLIENT_SECRET and place a ' +
98
+ `credentials.json file at ${credentialsPath} instead.`);
99
+ }
100
+ return ('No OAuth credentials found. Either set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, ' +
101
+ `or download your OAuth client JSON from the Google Cloud Console and save it as ${credentialsPath}. ` +
102
+ 'Then run `npx @a-bonus/google-docs-mcp auth` to authorize.');
103
+ }
104
+ async function loadClientSecrets() {
105
+ // 1. Environment variables
106
+ const envId = process.env.GOOGLE_CLIENT_ID;
107
+ const envSecret = process.env.GOOGLE_CLIENT_SECRET;
108
+ if (envId && envSecret) {
109
+ return { client_id: envId, client_secret: envSecret };
110
+ }
111
+ // 2. credentials.json fallback
112
+ let content;
113
+ try {
114
+ content = await fs.readFile(CREDENTIALS_PATH, 'utf8');
115
+ }
116
+ catch (err) {
117
+ if (err.code === 'ENOENT') {
118
+ throw new Error(describeMissingCredentials(CREDENTIALS_PATH, envId, envSecret));
119
+ }
120
+ if (err.code === 'EACCES') {
121
+ throw new Error(`credentials.json exists at ${CREDENTIALS_PATH} but is not readable (EACCES). ` +
122
+ 'Fix its permissions, or set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET instead.');
123
+ }
124
+ throw err;
125
+ }
126
+ let keys;
127
+ try {
128
+ keys = JSON.parse(content);
129
+ }
130
+ catch {
131
+ throw new Error(`${CREDENTIALS_PATH} is not valid JSON. Re-download the OAuth client JSON from the ` +
132
+ 'Google Cloud Console (APIs & Services -> Credentials -> your OAuth client -> Download JSON) ' +
133
+ 'and save it unmodified.');
134
+ }
135
+ return extractClientSecrets(keys, CREDENTIALS_PATH);
136
+ }
137
+ /**
138
+ * Validates the shape of a parsed credentials.json and returns the OAuth client
139
+ * pair, or throws an error that says what is wrong with the file.
140
+ *
141
+ * Kept separate from the filesystem so every branch is testable, and because the
142
+ * shape errors are what users actually hit: issue #57 reported the failure
143
+ * surfacing as "Cannot destructure property 'client_secret' of
144
+ * 'credentials.installed' as it is undefined", which names no file and suggests
145
+ * no fix.
146
+ */
147
+ export function extractClientSecrets(keys, credentialsPath) {
148
+ const key = keys?.installed || keys?.web;
149
+ if (!key) {
150
+ // Usually a service-account key or an API key rather than an OAuth client.
151
+ const shape = Object.keys(keys ?? {})
152
+ .slice(0, 5)
153
+ .join(', ') || 'no top-level keys';
154
+ throw new Error(`${credentialsPath} has no "installed" or "web" section, so it is not an OAuth client ` +
155
+ `file (found: ${shape}). Download an OAuth 2.0 Client ID of type "Desktop app" or ` +
156
+ '"Web application" from the Google Cloud Console; a service-account key will not work.');
157
+ }
158
+ const missing = ['client_id', 'client_secret'].filter((field) => !key[field]);
159
+ if (missing.length > 0) {
160
+ throw new Error(`${credentialsPath} is missing ${missing.join(' and ')} inside its ` +
161
+ `"${keys.installed ? 'installed' : 'web'}" section. Re-download the OAuth client JSON ` +
162
+ 'from the Google Cloud Console and save it unmodified.');
163
+ }
164
+ return { client_id: key.client_id, client_secret: key.client_secret };
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // Service account auth (unchanged)
168
+ // ---------------------------------------------------------------------------
169
+ async function authorizeWithServiceAccount() {
170
+ const serviceAccountPath = process.env.SERVICE_ACCOUNT_PATH;
171
+ const impersonateUser = process.env.GOOGLE_IMPERSONATE_USER;
172
+ try {
173
+ const keyFileContent = await fs.readFile(serviceAccountPath, 'utf8');
174
+ const serviceAccountKey = JSON.parse(keyFileContent);
175
+ const auth = new JWT({
176
+ email: serviceAccountKey.client_email,
177
+ key: serviceAccountKey.private_key,
178
+ scopes: SCOPES,
179
+ subject: impersonateUser,
180
+ });
181
+ await auth.authorize();
182
+ if (impersonateUser) {
183
+ logger.info(`Service Account authentication successful, impersonating: ${impersonateUser}`);
184
+ }
185
+ else {
186
+ logger.info('Service Account authentication successful!');
187
+ }
188
+ return auth;
189
+ }
190
+ catch (error) {
191
+ if (error.code === 'ENOENT') {
192
+ logger.error(`FATAL: Service account key file not found at path: ${serviceAccountPath}`);
193
+ throw new Error('Service account key file not found. Please check the path in SERVICE_ACCOUNT_PATH.');
194
+ }
195
+ logger.error('FATAL: Error loading or authorizing the service account key:', error.message);
196
+ throw new Error('Failed to authorize using the service account. Ensure the key file is valid and the path is correct.');
197
+ }
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Token persistence (XDG path)
201
+ // ---------------------------------------------------------------------------
202
+ async function loadSavedCredentialsIfExist() {
203
+ try {
204
+ const tokenPath = getTokenPath();
205
+ const content = await fs.readFile(tokenPath, 'utf8');
206
+ const credentials = sanitizeStoredTokenCredentials(JSON.parse(content));
207
+ const { client_secret, client_id } = await loadClientSecrets();
208
+ const client = new google.auth.OAuth2(client_id, client_secret);
209
+ client.setCredentials(credentials);
210
+ return client;
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
216
+ async function saveCredentials(client) {
217
+ const configDir = getConfigDir();
218
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
219
+ const tokenPath = getTokenPath();
220
+ const payload = JSON.stringify(createStoredTokenPayload(client.credentials), null, 2);
221
+ await fs.writeFile(tokenPath, payload, { mode: 0o600 });
222
+ logger.info('Token stored to', tokenPath);
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // Interactive OAuth browser flow
226
+ // ---------------------------------------------------------------------------
227
+ async function authenticate() {
228
+ const { client_secret, client_id } = await loadClientSecrets();
229
+ // Start a temporary local server to receive the OAuth callback
230
+ const server = http.createServer();
231
+ await new Promise((resolve) => server.listen(0, 'localhost', resolve));
232
+ const port = server.address().port;
233
+ const redirectUri = `http://localhost:${port}`;
234
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
235
+ const state = crypto.randomBytes(32).toString('hex');
236
+ // prompt: 'consent' is what makes re-authorization actually work. Google returns a refresh
237
+ // token only when it re-asks for consent; for an app the user already granted, an offline
238
+ // request without it yields an access token and no refresh token. Since only the refresh
239
+ // token is persisted, a re-auth would then save nothing and leave the old grant — and its
240
+ // old, narrower scope set — in place, while still reporting success. Adding a scope to
241
+ // SCOPES would appear to do nothing at all.
242
+ const authorizeUrl = oAuth2Client.generateAuthUrl({
243
+ access_type: 'offline',
244
+ prompt: 'consent',
245
+ scope: SCOPES.join(' '),
246
+ state,
247
+ });
248
+ logger.info('Authorize this app by visiting this url:', authorizeUrl);
249
+ const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
250
+ const timeout = setTimeout(() => {
251
+ server.close();
252
+ }, AUTH_TIMEOUT_MS);
253
+ // Wait for the OAuth callback
254
+ const code = await new Promise((resolve, reject) => {
255
+ server.on('request', (req, res) => {
256
+ const url = new URL(req.url, `http://localhost:${port}`);
257
+ const authCode = url.searchParams.get('code');
258
+ const error = url.searchParams.get('error');
259
+ if (error) {
260
+ res.writeHead(200, { 'Content-Type': 'text/html' });
261
+ res.end('<h1>Authorization failed</h1><p>You can close this tab.</p>');
262
+ reject(new Error(`Authorization error: ${error}`));
263
+ clearTimeout(timeout);
264
+ server.close();
265
+ return;
266
+ }
267
+ const returnedState = url.searchParams.get('state');
268
+ if (returnedState !== state) {
269
+ res.writeHead(400, { 'Content-Type': 'text/html' });
270
+ res.end('<h1>Invalid state parameter</h1><p>Possible CSRF attack. Please try again.</p>');
271
+ return;
272
+ }
273
+ if (authCode) {
274
+ res.writeHead(200, { 'Content-Type': 'text/html' });
275
+ res.end('<h1>Authorization successful!</h1><p>You can close this tab.</p>');
276
+ resolve(authCode);
277
+ clearTimeout(timeout);
278
+ server.close();
279
+ }
280
+ });
281
+ });
282
+ const { tokens } = await oAuth2Client.getToken(code);
283
+ oAuth2Client.setCredentials(tokens);
284
+ if (!tokens.refresh_token) {
285
+ // Nothing is persisted without a refresh token, so this run changed nothing on disk. Said
286
+ // as a warning it reads as a minor caveat under a success message, and the stale grant
287
+ // goes unnoticed until some tool fails for a reason that looks unrelated.
288
+ throw new Error('Google returned no refresh token, so nothing was saved and the previous authorization ' +
289
+ '(with whatever scopes it had) is still in force. Revoke this app at ' +
290
+ 'https://myaccount.google.com/permissions and run the auth flow again.');
291
+ }
292
+ await saveCredentials(oAuth2Client);
293
+ logger.info(`Authentication successful! Granted scopes: ${tokens.scope ?? '(not reported)'}`);
294
+ return oAuth2Client;
295
+ }
296
+ // ---------------------------------------------------------------------------
297
+ // Public API
298
+ // ---------------------------------------------------------------------------
299
+ /**
300
+ * Main authorization entry point used by the server at startup.
301
+ *
302
+ * Resolution order:
303
+ * 1. SERVICE_ACCOUNT_PATH env var -> service account JWT
304
+ * 2. Saved token in ~/.config/google-docs-mcp/token.json -> OAuth2Client
305
+ * 3. Interactive browser OAuth flow -> OAuth2Client (saves token for next time)
306
+ */
307
+ export async function authorize() {
308
+ if (process.env.SERVICE_ACCOUNT_PATH) {
309
+ logger.info('Service account path detected. Attempting service account authentication...');
310
+ return authorizeWithServiceAccount();
311
+ }
312
+ logger.info('Attempting OAuth 2.0 authentication...');
313
+ const client = await loadSavedCredentialsIfExist();
314
+ if (client) {
315
+ logger.info('Using saved credentials.');
316
+ return client;
317
+ }
318
+ logger.info('No saved token found. Starting interactive authentication flow...');
319
+ return authenticate();
320
+ }
321
+ /**
322
+ * Forces the interactive OAuth browser flow, ignoring any saved token.
323
+ * Used by the `auth` CLI subcommand to let users (re-)authorize.
324
+ */
325
+ export async function runAuthFlow() {
326
+ await authenticate();
327
+ }
@@ -0,0 +1,38 @@
1
+ // FastMCP's default tools/list handler runs toJsonSchema() for every tool on every request.
2
+ // Hosts that poll tools/list frequently (or many concurrent sessions) then burn a full CPU core.
3
+ // We precompute the list once before stdio connects, then replace the handler to return that snapshot.
4
+ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { toJsonSchema } from 'xsschema';
6
+ import { logger } from './logger.js';
7
+ export function collectToolsWhileRegistering(server, out) {
8
+ const add = server.addTool.bind(server);
9
+ server.addTool = (tool) => {
10
+ out.push(tool);
11
+ add(tool);
12
+ };
13
+ }
14
+ export async function buildCachedToolsListPayload(tools) {
15
+ return {
16
+ tools: await Promise.all(tools.map(async (tool) => ({
17
+ annotations: tool.annotations,
18
+ description: tool.description,
19
+ inputSchema: tool.parameters
20
+ ? await toJsonSchema(tool.parameters)
21
+ : {
22
+ additionalProperties: false,
23
+ properties: {},
24
+ type: 'object',
25
+ },
26
+ name: tool.name,
27
+ }))),
28
+ };
29
+ }
30
+ export function installCachedToolsListHandler(server, listPayload) {
31
+ const session = server.sessions[0];
32
+ if (!session) {
33
+ logger.warn('No MCP session; skipping tools/list cache install.');
34
+ return;
35
+ }
36
+ session.server.setRequestHandler(ListToolsRequestSchema, async () => listPayload);
37
+ logger.debug(`Installed cached tools/list (${listPayload.tools.length} tools).`);
38
+ }
@@ -0,0 +1,180 @@
1
+ // src/clients.ts
2
+ import { google } from 'googleapis';
3
+ import { UserError } from 'fastmcp';
4
+ import { authorize } from './auth.js';
5
+ import { logger } from './logger.js';
6
+ import { requestClients } from './remoteWrapper.js';
7
+ const isRemote = process.env.MCP_TRANSPORT === 'httpStream';
8
+ let authClient = null;
9
+ let googleDocs = null;
10
+ let googleDrive = null;
11
+ let googleSheets = null;
12
+ let googleScript = null;
13
+ let googleGmail = null;
14
+ let googleCalendar = null;
15
+ // --- Initialization ---
16
+ export async function initializeGoogleClient() {
17
+ if (googleDocs && googleDrive && googleSheets)
18
+ return {
19
+ authClient,
20
+ googleDocs,
21
+ googleDrive,
22
+ googleSheets,
23
+ googleScript,
24
+ googleGmail,
25
+ googleCalendar,
26
+ };
27
+ if (!authClient) {
28
+ try {
29
+ logger.info('Attempting to authorize Google API client...');
30
+ const client = await authorize();
31
+ authClient = client;
32
+ googleDocs = google.docs({ version: 'v1', auth: authClient });
33
+ googleDrive = google.drive({ version: 'v3', auth: authClient });
34
+ googleSheets = google.sheets({ version: 'v4', auth: authClient });
35
+ googleScript = google.script({ version: 'v1', auth: authClient });
36
+ googleGmail = google.gmail({ version: 'v1', auth: authClient });
37
+ googleCalendar = google.calendar({ version: 'v3', auth: authClient });
38
+ logger.info('Google API client authorized successfully.');
39
+ }
40
+ catch (error) {
41
+ logger.error('FATAL: Failed to initialize Google API client:', error);
42
+ authClient = null;
43
+ googleDocs = null;
44
+ googleDrive = null;
45
+ googleSheets = null;
46
+ googleScript = null;
47
+ googleGmail = null;
48
+ googleCalendar = null;
49
+ throw new Error('Google client initialization failed. Cannot start server tools.');
50
+ }
51
+ }
52
+ if (authClient && !googleDocs) {
53
+ googleDocs = google.docs({ version: 'v1', auth: authClient });
54
+ }
55
+ if (authClient && !googleDrive) {
56
+ googleDrive = google.drive({ version: 'v3', auth: authClient });
57
+ }
58
+ if (authClient && !googleSheets) {
59
+ googleSheets = google.sheets({ version: 'v4', auth: authClient });
60
+ }
61
+ if (authClient && !googleScript) {
62
+ googleScript = google.script({ version: 'v1', auth: authClient });
63
+ }
64
+ if (authClient && !googleGmail) {
65
+ googleGmail = google.gmail({ version: 'v1', auth: authClient });
66
+ }
67
+ if (authClient && !googleCalendar) {
68
+ googleCalendar = google.calendar({ version: 'v3', auth: authClient });
69
+ }
70
+ if (!googleDocs || !googleDrive || !googleSheets) {
71
+ throw new Error('Google Docs, Drive, and Sheets clients could not be initialized.');
72
+ }
73
+ return {
74
+ authClient,
75
+ googleDocs,
76
+ googleDrive,
77
+ googleSheets,
78
+ googleScript,
79
+ googleGmail,
80
+ googleCalendar,
81
+ };
82
+ }
83
+ // --- Helper to get Docs client within tools ---
84
+ export async function getDocsClient() {
85
+ const remote = requestClients.getStore();
86
+ if (remote)
87
+ return remote.docs;
88
+ if (isRemote) {
89
+ throw new UserError('Request context missing. Tool must be called within an MCP request.');
90
+ }
91
+ const { googleDocs: docs } = await initializeGoogleClient();
92
+ if (!docs) {
93
+ throw new UserError('Google Docs client is not initialized. Authentication might have failed during startup or lost connection.');
94
+ }
95
+ return docs;
96
+ }
97
+ // --- Helper to get Drive client within tools ---
98
+ export async function getDriveClient() {
99
+ const remote = requestClients.getStore();
100
+ if (remote)
101
+ return remote.drive;
102
+ if (isRemote) {
103
+ throw new UserError('Request context missing. Tool must be called within an MCP request.');
104
+ }
105
+ const { googleDrive: drive } = await initializeGoogleClient();
106
+ if (!drive) {
107
+ throw new UserError('Google Drive client is not initialized. Authentication might have failed during startup or lost connection.');
108
+ }
109
+ return drive;
110
+ }
111
+ // --- Helper to get Sheets client within tools ---
112
+ export async function getSheetsClient() {
113
+ const remote = requestClients.getStore();
114
+ if (remote)
115
+ return remote.sheets;
116
+ if (isRemote) {
117
+ throw new UserError('Request context missing. Tool must be called within an MCP request.');
118
+ }
119
+ const { googleSheets: sheets } = await initializeGoogleClient();
120
+ if (!sheets) {
121
+ throw new UserError('Google Sheets client is not initialized. Authentication might have failed during startup or lost connection.');
122
+ }
123
+ return sheets;
124
+ }
125
+ // --- Helper to get Auth client for direct API usage ---
126
+ export async function getAuthClient() {
127
+ const remote = requestClients.getStore();
128
+ if (remote)
129
+ return remote.auth;
130
+ if (isRemote) {
131
+ throw new UserError('Request context missing. Tool must be called within an MCP request.');
132
+ }
133
+ const { authClient: client } = await initializeGoogleClient();
134
+ if (!client) {
135
+ throw new UserError('Auth client is not initialized. Authentication might have failed during startup or lost connection.');
136
+ }
137
+ return client;
138
+ }
139
+ // --- Helper to get Script client within tools ---
140
+ export async function getScriptClient() {
141
+ const remote = requestClients.getStore();
142
+ if (remote)
143
+ return remote.script;
144
+ if (isRemote) {
145
+ throw new UserError('Request context missing. Tool must be called within an MCP request.');
146
+ }
147
+ const { googleScript: script } = await initializeGoogleClient();
148
+ if (!script) {
149
+ throw new UserError('Google Script client is not initialized. Authentication might have failed during startup or lost connection.');
150
+ }
151
+ return script;
152
+ }
153
+ // --- Helper to get Gmail client within tools ---
154
+ export async function getGmailClient() {
155
+ const remote = requestClients.getStore();
156
+ if (remote)
157
+ return remote.gmail;
158
+ if (isRemote) {
159
+ throw new UserError('Request context missing. Tool must be called within an MCP request.');
160
+ }
161
+ const { googleGmail: gmail } = await initializeGoogleClient();
162
+ if (!gmail) {
163
+ throw new UserError('Gmail client is not initialized. Authentication might have failed during startup or lost connection.');
164
+ }
165
+ return gmail;
166
+ }
167
+ // --- Helper to get Calendar client within tools ---
168
+ export async function getCalendarClient() {
169
+ const remote = requestClients.getStore();
170
+ if (remote)
171
+ return remote.calendar;
172
+ if (isRemote) {
173
+ throw new UserError('Request context missing. Tool must be called within an MCP request.');
174
+ }
175
+ const { googleCalendar: calendar } = await initializeGoogleClient();
176
+ if (!calendar) {
177
+ throw new UserError('Google Calendar client is not initialized. Authentication might have failed during startup or lost connection.');
178
+ }
179
+ return calendar;
180
+ }
package/dist/config.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parses MCP_STATELESS env var. Stateless mode disables httpStream session
3
+ * tracking so the server survives serverless scale-to-zero without losing
4
+ * MCP sessions.
5
+ */
6
+ export function parseStatelessFlag(value) {
7
+ const raw = (value ?? process.env.MCP_STATELESS ?? '').trim().toLowerCase();
8
+ return raw === 'true' || raw === '1';
9
+ }
@@ -0,0 +1,82 @@
1
+ import crypto from 'node:crypto';
2
+ import { Readable } from 'node:stream';
3
+ import { stream } from 'hono/streaming';
4
+ import { google } from 'googleapis';
5
+ import { OAuth2Client } from 'google-auth-library';
6
+ // Per-process encryption key — never leaves memory, regenerated on restart.
7
+ const ENCRYPTION_KEY = crypto.randomBytes(32);
8
+ function encrypt(plaintext) {
9
+ const iv = crypto.randomBytes(12);
10
+ const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
11
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
12
+ const tag = cipher.getAuthTag();
13
+ // iv (12) + tag (16) + ciphertext — all hex-encoded
14
+ return iv.toString('hex') + tag.toString('hex') + encrypted.toString('hex');
15
+ }
16
+ function decrypt(blob) {
17
+ const iv = Buffer.from(blob.slice(0, 24), 'hex');
18
+ const tag = Buffer.from(blob.slice(24, 56), 'hex');
19
+ const encrypted = Buffer.from(blob.slice(56), 'hex');
20
+ const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
21
+ decipher.setAuthTag(tag);
22
+ return decipher.update(encrypted) + decipher.final('utf8');
23
+ }
24
+ const pending = new Map();
25
+ setInterval(() => {
26
+ const now = Date.now();
27
+ for (const [k, v] of pending)
28
+ if (v.expiresAt < now)
29
+ pending.delete(k);
30
+ }, 60_000).unref();
31
+ export function createDownloadToken(opts) {
32
+ const token = crypto.randomBytes(32).toString('hex');
33
+ pending.set(token, {
34
+ ...opts,
35
+ accessToken: encrypt(opts.accessToken),
36
+ expiresAt: Date.now() + 60 * 1000,
37
+ });
38
+ return token;
39
+ }
40
+ export function registerDownloadRoute(server) {
41
+ const app = server.getApp();
42
+ app.get('/download/:token', async (c) => {
43
+ const token = c.req.param('token');
44
+ const entry = pending.get(token);
45
+ if (!entry || entry.expiresAt < Date.now()) {
46
+ pending.delete(token);
47
+ return c.text('Download link expired or invalid.', 410);
48
+ }
49
+ pending.delete(token);
50
+ let accessToken;
51
+ try {
52
+ accessToken = decrypt(entry.accessToken);
53
+ }
54
+ catch {
55
+ return c.text('Download link expired or invalid.', 410);
56
+ }
57
+ const auth = new OAuth2Client();
58
+ auth.setCredentials({ access_token: accessToken });
59
+ const drive = google.drive({ version: 'v3', auth });
60
+ c.header('Content-Disposition', `attachment; filename="${entry.fileName.replace(/"/g, '\\"')}"`);
61
+ if (entry.isWorkspace && entry.exportMime) {
62
+ c.header('Content-Type', entry.exportMime);
63
+ const res = await drive.files.export({ fileId: entry.fileId, mimeType: entry.exportMime }, { responseType: 'stream' });
64
+ const webStream = Readable.toWeb(res.data);
65
+ return stream(c, async (s) => {
66
+ await s.pipe(webStream);
67
+ });
68
+ }
69
+ else {
70
+ c.header('Content-Type', entry.mimeType);
71
+ const res = await drive.files.get({ fileId: entry.fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'stream' });
72
+ const webStream = Readable.toWeb(res.data);
73
+ return stream(c, async (s) => {
74
+ await s.pipe(webStream);
75
+ });
76
+ }
77
+ });
78
+ }
79
+ /** @internal Exposed only for tests — do not use in production code. */
80
+ export function _testGetPending() {
81
+ return pending;
82
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Escapes a string for safe interpolation into Google Drive API query strings.
3
+ * Backslashes must be escaped first, then single quotes (order matters).
4
+ */
5
+ export function escapeDriveQuery(value) {
6
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
7
+ }
@@ -0,0 +1,32 @@
1
+ import { Firestore } from '@google-cloud/firestore';
2
+ const COLLECTION = 'mcp-oauth-tokens';
3
+ /**
4
+ * Firestore-backed TokenStorage for FastMCP's OAuth proxy.
5
+ * Tokens survive container restarts and redeployments.
6
+ * On Cloud Run, authentication is automatic via the service account.
7
+ */
8
+ export class FirestoreTokenStorage {
9
+ db;
10
+ constructor(projectId) {
11
+ this.db = new Firestore({ projectId });
12
+ }
13
+ async save(key, value, _ttl) {
14
+ const doc = this.db.collection(COLLECTION).doc(encodeKey(key));
15
+ await doc.set({ value, createdAt: Date.now() });
16
+ }
17
+ async get(key) {
18
+ const doc = await this.db.collection(COLLECTION).doc(encodeKey(key)).get();
19
+ if (!doc.exists)
20
+ return null;
21
+ return doc.data().value;
22
+ }
23
+ async delete(key) {
24
+ await this.db.collection(COLLECTION).doc(encodeKey(key)).delete();
25
+ }
26
+ async cleanup() {
27
+ // FastMCP handles token expiry internally via delete() calls.
28
+ }
29
+ }
30
+ function encodeKey(key) {
31
+ return key.replace(/\//g, '__');
32
+ }