@brainwavesio/google-docs-mcp 1.0.0 → 1.1.0

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.
package/README.md CHANGED
@@ -8,13 +8,12 @@ Connect Claude Desktop (or other MCP clients) to your Google Docs, Google Sheets
8
8
 
9
9
  ## Quick Start (npx)
10
10
 
11
- If you have Google OAuth credentials, you can run this server without cloning:
11
+ **1. Get Google OAuth credentials** from [Google Cloud Console](https://console.cloud.google.com/):
12
+ - Create a project and enable Google Docs, Sheets, and Drive APIs
13
+ - Create OAuth 2.0 credentials (Desktop app type)
14
+ - Download the client ID and secret
12
15
 
13
- ```bash
14
- npx @brainwavesio/google-docs-mcp
15
- ```
16
-
17
- Or configure in Claude Desktop's `mcp_config.json`:
16
+ **2. Configure Claude Desktop** (`mcp_config.json` or `.mcp.json`):
18
17
 
19
18
  ```json
20
19
  {
@@ -24,15 +23,16 @@ Or configure in Claude Desktop's `mcp_config.json`:
24
23
  "args": ["@brainwavesio/google-docs-mcp"],
25
24
  "env": {
26
25
  "GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
27
- "GOOGLE_CLIENT_SECRET": "your-client-secret",
28
- "GOOGLE_REFRESH_TOKEN": "your-refresh-token"
26
+ "GOOGLE_CLIENT_SECRET": "your-client-secret"
29
27
  }
30
28
  }
31
29
  }
32
30
  }
33
31
  ```
34
32
 
35
- See [Environment Variable Authentication](#alternative-environment-variable-authentication-npx--containers) for details on getting a refresh token.
33
+ **3. First run:** The server will automatically open your browser for Google authorization. After you approve, the token is saved to `~/.config/google-docs-mcp/token.json` for future use.
34
+
35
+ That's it! No manual token copying required.
36
36
 
37
37
  ---
38
38
 
@@ -268,36 +268,28 @@ When `GOOGLE_IMPERSONATE_USER` is set, the server will impersonate that user whe
268
268
 
269
269
  ### Alternative: Environment Variable Authentication (npx / Containers)
270
270
 
271
- For running via `npx` or in containerized environments where you can't rely on local files, you can pass OAuth credentials entirely via environment variables.
271
+ For running via `npx` or in containerized environments, pass OAuth credentials via environment variables.
272
272
 
273
- **Option A: Individual environment variables**
273
+ **Required environment variables:**
274
274
 
275
275
  ```bash
276
- # Your OAuth client credentials (from credentials.json)
277
276
  export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
278
277
  export GOOGLE_CLIENT_SECRET="your-client-secret"
279
- export GOOGLE_REFRESH_TOKEN="your-refresh-token"
280
278
  ```
281
279
 
282
- **Option B: Full credentials JSON as env var**
280
+ **Optional:** If you already have a refresh token, you can skip the browser auth:
283
281
 
284
282
  ```bash
285
- # The entire contents of credentials.json as a single env var
286
- export GOOGLE_CREDENTIALS_JSON='{"installed":{"client_id":"...","client_secret":"..."}}'
287
283
  export GOOGLE_REFRESH_TOKEN="your-refresh-token"
288
284
  ```
289
285
 
290
- **Getting a refresh token:**
291
-
292
- If you have the client ID and secret but no refresh token yet, run the server with just those set:
293
-
294
- ```bash
295
- GOOGLE_CLIENT_ID="..." GOOGLE_CLIENT_SECRET="..." npx @brainwavesio/google-docs-mcp
296
- ```
286
+ **How authentication works:**
297
287
 
298
- The server will prompt you through the OAuth flow and print the refresh token for you to save.
288
+ 1. On first run, if no saved token exists, the server opens your browser for Google authorization
289
+ 2. After you approve, the token is saved to `~/.config/google-docs-mcp/token.json`
290
+ 3. Subsequent runs use the saved token automatically
299
291
 
300
- **Claude Desktop config with env vars:**
292
+ **Claude Desktop config:**
301
293
 
302
294
  ```json
303
295
  {
@@ -307,8 +299,7 @@ The server will prompt you through the OAuth flow and print the refresh token fo
307
299
  "args": ["@brainwavesio/google-docs-mcp"],
308
300
  "env": {
309
301
  "GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
310
- "GOOGLE_CLIENT_SECRET": "your-client-secret",
311
- "GOOGLE_REFRESH_TOKEN": "your-refresh-token"
302
+ "GOOGLE_CLIENT_SECRET": "your-client-secret"
312
303
  }
313
304
  }
314
305
  }
package/dist/auth.js ADDED
@@ -0,0 +1,313 @@
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 http from 'http';
7
+ import * as os from 'os';
8
+ import { fileURLToPath } from 'url';
9
+ import open from 'open';
10
+ // --- Config directory for persistent token storage ---
11
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'google-docs-mcp');
12
+ const TOKEN_PATH = path.join(CONFIG_DIR, 'token.json');
13
+ // --- Legacy paths (for backwards compatibility) ---
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+ const projectRootDir = path.resolve(__dirname, '..');
17
+ const LEGACY_TOKEN_PATH = path.join(projectRootDir, 'token.json');
18
+ const LEGACY_CREDENTIALS_PATH = path.join(projectRootDir, 'credentials.json');
19
+ const SCOPES = [
20
+ 'https://www.googleapis.com/auth/documents',
21
+ 'https://www.googleapis.com/auth/drive',
22
+ 'https://www.googleapis.com/auth/spreadsheets'
23
+ ];
24
+ // --- Ensure config directory exists ---
25
+ async function ensureConfigDir() {
26
+ try {
27
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
28
+ }
29
+ catch (err) {
30
+ // Directory might already exist
31
+ }
32
+ }
33
+ // --- Save token to config directory ---
34
+ async function saveToken(credentials) {
35
+ await ensureConfigDir();
36
+ const payload = JSON.stringify({
37
+ type: 'authorized_user',
38
+ client_id: credentials.client_id,
39
+ client_secret: credentials.client_secret,
40
+ refresh_token: credentials.refresh_token,
41
+ }, null, 2);
42
+ await fs.writeFile(TOKEN_PATH, payload);
43
+ console.error(`Token saved to ${TOKEN_PATH}`);
44
+ }
45
+ // --- Load token from config directory or legacy location ---
46
+ async function loadSavedToken() {
47
+ // Try new config location first
48
+ try {
49
+ const content = await fs.readFile(TOKEN_PATH, 'utf8');
50
+ const token = JSON.parse(content);
51
+ if (token.refresh_token) {
52
+ return token;
53
+ }
54
+ }
55
+ catch (err) {
56
+ // Not found in config dir, try legacy location
57
+ }
58
+ // Try legacy token.json in project root
59
+ try {
60
+ const content = await fs.readFile(LEGACY_TOKEN_PATH, 'utf8');
61
+ const token = JSON.parse(content);
62
+ if (token.refresh_token) {
63
+ return token;
64
+ }
65
+ }
66
+ catch (err) {
67
+ // Not found
68
+ }
69
+ return null;
70
+ }
71
+ // --- Browser-based OAuth flow ---
72
+ async function authenticateWithBrowser(clientId, clientSecret) {
73
+ return new Promise((resolve, reject) => {
74
+ // Find an available port
75
+ const server = http.createServer();
76
+ server.listen(0, '127.0.0.1', async () => {
77
+ const address = server.address();
78
+ if (!address || typeof address === 'string') {
79
+ reject(new Error('Failed to start local server'));
80
+ return;
81
+ }
82
+ const port = address.port;
83
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
84
+ const client = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
85
+ const authorizeUrl = client.generateAuthUrl({
86
+ access_type: 'offline',
87
+ scope: SCOPES,
88
+ prompt: 'consent', // Force consent to ensure we get a refresh token
89
+ });
90
+ console.error('\n========================================');
91
+ console.error('Opening browser for Google authorization...');
92
+ console.error('If the browser does not open, visit this URL:');
93
+ console.error(authorizeUrl);
94
+ console.error('========================================\n');
95
+ // Handle the OAuth callback
96
+ server.on('request', async (req, res) => {
97
+ if (!req.url?.startsWith('/callback')) {
98
+ res.writeHead(404);
99
+ res.end('Not found');
100
+ return;
101
+ }
102
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
103
+ const code = url.searchParams.get('code');
104
+ const error = url.searchParams.get('error');
105
+ if (error) {
106
+ res.writeHead(400, { 'Content-Type': 'text/html' });
107
+ res.end(`
108
+ <html>
109
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
110
+ <h1>Authorization Failed</h1>
111
+ <p>Error: ${error}</p>
112
+ <p>You can close this window.</p>
113
+ </body>
114
+ </html>
115
+ `);
116
+ server.close();
117
+ reject(new Error(`Authorization failed: ${error}`));
118
+ return;
119
+ }
120
+ if (!code) {
121
+ res.writeHead(400, { 'Content-Type': 'text/html' });
122
+ res.end(`
123
+ <html>
124
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
125
+ <h1>Authorization Failed</h1>
126
+ <p>No authorization code received.</p>
127
+ <p>You can close this window.</p>
128
+ </body>
129
+ </html>
130
+ `);
131
+ server.close();
132
+ reject(new Error('No authorization code received'));
133
+ return;
134
+ }
135
+ try {
136
+ const { tokens } = await client.getToken(code);
137
+ client.setCredentials(tokens);
138
+ if (tokens.refresh_token) {
139
+ await saveToken({
140
+ client_id: clientId,
141
+ client_secret: clientSecret,
142
+ refresh_token: tokens.refresh_token,
143
+ });
144
+ }
145
+ res.writeHead(200, { 'Content-Type': 'text/html' });
146
+ res.end(`
147
+ <html>
148
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
149
+ <h1>Authorization Successful!</h1>
150
+ <p>You can close this window and return to your application.</p>
151
+ <script>window.close();</script>
152
+ </body>
153
+ </html>
154
+ `);
155
+ server.close();
156
+ console.error('Authorization successful!');
157
+ resolve(client);
158
+ }
159
+ catch (err) {
160
+ res.writeHead(500, { 'Content-Type': 'text/html' });
161
+ res.end(`
162
+ <html>
163
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
164
+ <h1>Authorization Failed</h1>
165
+ <p>Failed to exchange code for tokens.</p>
166
+ <p>You can close this window.</p>
167
+ </body>
168
+ </html>
169
+ `);
170
+ server.close();
171
+ reject(err);
172
+ }
173
+ });
174
+ // Set a timeout for the auth flow
175
+ const timeout = setTimeout(() => {
176
+ server.close();
177
+ reject(new Error('Authorization timed out after 5 minutes'));
178
+ }, 5 * 60 * 1000);
179
+ server.on('close', () => {
180
+ clearTimeout(timeout);
181
+ });
182
+ // Open the browser
183
+ try {
184
+ await open(authorizeUrl);
185
+ }
186
+ catch (err) {
187
+ console.error('Failed to open browser automatically.');
188
+ console.error('Please open the URL above manually.');
189
+ }
190
+ });
191
+ server.on('error', (err) => {
192
+ reject(err);
193
+ });
194
+ });
195
+ }
196
+ // --- Get OAuth credentials from env vars or files ---
197
+ function getClientCredentials() {
198
+ // Check environment variables first
199
+ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
200
+ return {
201
+ clientId: process.env.GOOGLE_CLIENT_ID,
202
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
203
+ };
204
+ }
205
+ // Check for GOOGLE_CREDENTIALS_JSON env var
206
+ if (process.env.GOOGLE_CREDENTIALS_JSON) {
207
+ try {
208
+ const keys = JSON.parse(process.env.GOOGLE_CREDENTIALS_JSON);
209
+ const key = keys.installed || keys.web;
210
+ if (key) {
211
+ return {
212
+ clientId: key.client_id,
213
+ clientSecret: key.client_secret,
214
+ };
215
+ }
216
+ }
217
+ catch (err) {
218
+ console.error('Failed to parse GOOGLE_CREDENTIALS_JSON');
219
+ }
220
+ }
221
+ return null;
222
+ }
223
+ // --- Load credentials.json file (legacy support) ---
224
+ async function loadCredentialsFile() {
225
+ try {
226
+ const content = await fs.readFile(LEGACY_CREDENTIALS_PATH, 'utf8');
227
+ const keys = JSON.parse(content);
228
+ const key = keys.installed || keys.web;
229
+ if (key) {
230
+ return {
231
+ clientId: key.client_id,
232
+ clientSecret: key.client_secret,
233
+ };
234
+ }
235
+ }
236
+ catch (err) {
237
+ // File not found or invalid
238
+ }
239
+ return null;
240
+ }
241
+ // --- Service Account Authentication ---
242
+ async function authorizeWithServiceAccount() {
243
+ const serviceAccountPath = process.env.SERVICE_ACCOUNT_PATH;
244
+ const impersonateUser = process.env.GOOGLE_IMPERSONATE_USER;
245
+ const keyFileContent = await fs.readFile(serviceAccountPath, 'utf8');
246
+ const serviceAccountKey = JSON.parse(keyFileContent);
247
+ const auth = new JWT({
248
+ email: serviceAccountKey.client_email,
249
+ key: serviceAccountKey.private_key,
250
+ scopes: SCOPES,
251
+ subject: impersonateUser,
252
+ });
253
+ await auth.authorize();
254
+ if (impersonateUser) {
255
+ console.error(`Service Account authentication successful, impersonating: ${impersonateUser}`);
256
+ }
257
+ else {
258
+ console.error('Service Account authentication successful!');
259
+ }
260
+ return auth;
261
+ }
262
+ // --- Main exported function ---
263
+ // Priority order:
264
+ // 1. Service account (SERVICE_ACCOUNT_PATH)
265
+ // 2. Saved token + env credentials
266
+ // 3. Env var refresh token (GOOGLE_REFRESH_TOKEN)
267
+ // 4. Browser-based OAuth flow
268
+ // 5. Legacy file-based credentials
269
+ export async function authorize() {
270
+ // 1. Check for service account
271
+ if (process.env.SERVICE_ACCOUNT_PATH) {
272
+ console.error('Service account path detected. Using service account authentication...');
273
+ return authorizeWithServiceAccount();
274
+ }
275
+ // Get client credentials from env or files
276
+ let credentials = getClientCredentials();
277
+ // 2. Check for saved token
278
+ const savedToken = await loadSavedToken();
279
+ if (savedToken) {
280
+ // Use saved token - prefer env credentials if available, otherwise use token's credentials
281
+ const clientId = credentials?.clientId || savedToken.client_id;
282
+ const clientSecret = credentials?.clientSecret || savedToken.client_secret;
283
+ const client = new google.auth.OAuth2(clientId, clientSecret);
284
+ client.setCredentials({ refresh_token: savedToken.refresh_token });
285
+ console.error('Using saved credentials from ~/.config/google-docs-mcp/');
286
+ return client;
287
+ }
288
+ // 3. Check for refresh token in env var
289
+ if (credentials && process.env.GOOGLE_REFRESH_TOKEN) {
290
+ const client = new google.auth.OAuth2(credentials.clientId, credentials.clientSecret);
291
+ client.setCredentials({ refresh_token: process.env.GOOGLE_REFRESH_TOKEN });
292
+ // Save this token for future use
293
+ await saveToken({
294
+ client_id: credentials.clientId,
295
+ client_secret: credentials.clientSecret,
296
+ refresh_token: process.env.GOOGLE_REFRESH_TOKEN,
297
+ });
298
+ console.error('Using refresh token from environment variable.');
299
+ return client;
300
+ }
301
+ // 4. Try to load credentials from file if not in env
302
+ if (!credentials) {
303
+ credentials = await loadCredentialsFile();
304
+ }
305
+ // 5. If we have client credentials, do browser-based OAuth
306
+ if (credentials) {
307
+ console.error('No saved token found. Starting browser-based authentication...');
308
+ return authenticateWithBrowser(credentials.clientId, credentials.clientSecret);
309
+ }
310
+ // No credentials available
311
+ throw new Error('No Google credentials found. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables, ' +
312
+ 'or place a credentials.json file in the project directory.');
313
+ }