diff --git a/README.md b/README.md
index 9431615b..633ab1e7 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,21 @@ Shows your schedule for today or a specified date.
Searches your Google Drive for files matching the given query.
+## Headless / Remote Environments
+
+If you're using the extension over SSH, WSL, Cloud Shell, or another environment
+without a local browser, you can authenticate using the headless login tool:
+
+```bash
+node scripts/auth-utils.js login
+```
+
+This prints an OAuth URL you can open in any browser (local machine, phone,
+etc.). After signing in, paste the credentials JSON into the CLI. Credentials
+are read securely from `/dev/tty` and are never exposed to the AI model. See the
+[development docs](docs/development.md#headless--remote-environments) for more
+details.
+
## Deployment
If you want to host your own version of this extension's infrastructure, see the
diff --git a/cloud_function/index.js b/cloud_function/index.js
index 575810d3..c59fdedb 100644
--- a/cloud_function/index.js
+++ b/cloud_function/index.js
@@ -192,17 +192,25 @@ async function handleCallback(req, res) {
Copied!
-
Keychain Storage Instructions:
-
- - Open your OS Keychain/Credential Manager.
- - Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).
- - Set the **Service** (or equivalent field) to:
${KEYCHAIN_SERVICE_NAME}
- - Set the **Account** (or username field) to:
${KEYCHAIN_ACCOUNT_NAME}
- - Paste the copied JSON into the **Password/Secret** field.
- - Save the entry.
-
-
Your local MCP server will now be able to find and use these credentials automatically.
-
(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)
+
CLI Login (Recommended):
+
In your terminal, run:
+
node dist/headless-login.js
+
Then paste the JSON above when prompted. The CLI will securely store your credentials.
+
+
+ Advanced: Manual Keychain Storage
+
+
+ - Open your OS Keychain/Credential Manager.
+ - Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).
+ - Set the Service (or equivalent field) to:
${KEYCHAIN_SERVICE_NAME}
+ - Set the Account (or username field) to:
${KEYCHAIN_ACCOUNT_NAME}
+ - Paste the copied JSON into the Password/Secret field.
+ - Save the entry.
+
+
(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)
+
+
diff --git a/docs/development.md b/docs/development.md
index fb1e21be..1dd6da4e 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -155,6 +155,7 @@ used to maintain dot notation and avoid breaking existing configurations.
- `src/`: Contains the source code for the server.
- `__tests__/`: Contains all the tests.
- `auth/`: Handles authentication.
+ - `cli/`: CLI tools (e.g., headless OAuth login).
- `services/`: Contains the business logic for each service.
- `utils/`: Contains utility functions.
- `config/`: Contains configuration files.
@@ -176,7 +177,32 @@ node scripts/auth-utils.js
### Commands
+- `login`: Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell). Reads
+ credentials securely from `/dev/tty` so they are not visible to AI models.
- `clear`: Clear all authentication credentials.
- `expire`: Force the access token to expire (for testing refresh).
- `status`: Show current authentication status.
- `help`: Show the help message.
+
+### Headless / Remote Environments
+
+If you are running the server in an environment without a browser (SSH, WSL,
+Cloud Shell, VMs), authentication requires manual steps:
+
+1. Run the login tool:
+ ```bash
+ node scripts/auth-utils.js login
+ ```
+ Or, from the `workspace-server` directory:
+ ```bash
+ node dist/headless-login.js
+ ```
+2. Open the printed OAuth URL in any browser (your local machine, phone, etc.).
+3. Complete Google sign-in. The browser will display a credentials JSON block.
+4. Copy the JSON and paste it into the CLI when prompted.
+
+The CLI reads input from `/dev/tty` (Unix) or `CON` (Windows) rather than
+process stdin, so credentials are never exposed to an AI model that may have
+spawned the process.
+
+Use `--force` to re-authenticate if credentials already exist.
diff --git a/docs/index.md b/docs/index.md
index 0f1fef5e..e86ad356 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -93,6 +93,12 @@ The extension provides the following tools:
assistant). Defaults to the authenticated user and supports filtering by
relation type.
+### Authentication
+
+- `auth.clear`: Clears authentication credentials, forcing a re-login on the
+ next request.
+- `auth.refreshToken`: Manually triggers the token refresh process.
+
## Custom Commands
The extension includes several pre-configured commands for common tasks:
diff --git a/scripts/auth-utils.js b/scripts/auth-utils.js
index 0f57b7aa..d3e04728 100644
--- a/scripts/auth-utils.js
+++ b/scripts/auth-utils.js
@@ -74,6 +74,18 @@ async function showStatus() {
}
}
+async function login() {
+ try {
+ require('../workspace-server/dist/headless-login.js');
+ } catch (error) {
+ console.error(
+ '❌ Failed to load headless-login module. Run "npm run build:headless-login" first.',
+ );
+ console.error(error.message);
+ process.exit(1);
+ }
+}
+
function showHelp() {
console.log(`
Auth Management CLI
@@ -81,12 +93,14 @@ Auth Management CLI
Usage: node scripts/auth-utils.js
Commands:
+ login Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell)
clear Clear all authentication credentials
expire Force the access token to expire (for testing refresh)
status Show current authentication status
help Show this help message
Examples:
+ node scripts/auth-utils.js login
node scripts/auth-utils.js clear
node scripts/auth-utils.js expire
node scripts/auth-utils.js status
@@ -97,6 +111,9 @@ async function main() {
const command = process.argv[2];
switch (command) {
+ case 'login':
+ await login();
+ break;
case 'clear':
await clearAuth();
break;
diff --git a/workspace-server/esbuild.headless-login.js b/workspace-server/esbuild.headless-login.js
new file mode 100644
index 00000000..65f30010
--- /dev/null
+++ b/workspace-server/esbuild.headless-login.js
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const esbuild = require('esbuild');
+
+async function buildHeadlessLogin() {
+ try {
+ await esbuild.build({
+ entryPoints: ['src/cli/headless-login.ts'],
+ bundle: true,
+ platform: 'node',
+ target: 'node20',
+ outfile: 'dist/headless-login.js',
+ minify: true,
+ sourcemap: true,
+ external: [
+ 'keytar', // keytar is a native module and should not be bundled
+ ],
+ format: 'cjs',
+ logLevel: 'info',
+ });
+
+ console.log('Headless Login build completed successfully!');
+ } catch (error) {
+ console.error('Headless Login build failed:', error);
+ process.exit(1);
+ }
+}
+
+buildHeadlessLogin();
diff --git a/workspace-server/package.json b/workspace-server/package.json
index d822dcff..b0f84c38 100644
--- a/workspace-server/package.json
+++ b/workspace-server/package.json
@@ -10,8 +10,9 @@
"test:ci": "cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --ci --coverage --maxWorkers=2",
"start": "ts-node src/index.ts",
"clean": "rm -rf dist node_modules",
- "build": "node esbuild.config.js",
- "build:auth-utils": "node esbuild.auth-utils.js"
+ "build": "node esbuild.config.js && node esbuild.headless-login.js",
+ "build:auth-utils": "node esbuild.auth-utils.js",
+ "build:headless-login": "node esbuild.headless-login.js"
},
"keywords": [],
"author": "Allen Hutchison",
diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts
index 1aa82571..70727cc0 100644
--- a/workspace-server/src/auth/AuthManager.ts
+++ b/workspace-server/src/auth/AuthManager.ts
@@ -174,6 +174,16 @@ export class AuthManager {
}
}
+ // Fail fast in headless environments instead of hanging for 5 minutes
+ if (!shouldLaunchBrowser()) {
+ throw new Error(
+ 'No browser available for authentication. ' +
+ 'Please run: node dist/headless-login.js\n' +
+ '(from the workspace-server directory)\n' +
+ 'After authenticating, retry your request.',
+ );
+ }
+
const webLogin = await this.authWithWeb(oAuth2Client);
await open(webLogin.authUrl);
const msg = 'Waiting for authentication... Check your browser.';
diff --git a/workspace-server/src/auth/scopes.ts b/workspace-server/src/auth/scopes.ts
new file mode 100644
index 00000000..244a38a1
--- /dev/null
+++ b/workspace-server/src/auth/scopes.ts
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * OAuth scopes required by the Google Workspace MCP server.
+ * Shared between the MCP server and the headless login CLI.
+ */
+export const SCOPES = [
+ 'https://www.googleapis.com/auth/documents',
+ 'https://www.googleapis.com/auth/drive',
+ 'https://www.googleapis.com/auth/calendar',
+ 'https://www.googleapis.com/auth/chat.spaces',
+ 'https://www.googleapis.com/auth/chat.messages',
+ 'https://www.googleapis.com/auth/chat.memberships',
+ 'https://www.googleapis.com/auth/userinfo.profile',
+ 'https://www.googleapis.com/auth/gmail.modify',
+ 'https://www.googleapis.com/auth/directory.readonly',
+ 'https://www.googleapis.com/auth/presentations.readonly',
+ 'https://www.googleapis.com/auth/spreadsheets.readonly',
+];
diff --git a/workspace-server/src/cli/headless-login.ts b/workspace-server/src/cli/headless-login.ts
new file mode 100644
index 00000000..ea18f1f6
--- /dev/null
+++ b/workspace-server/src/cli/headless-login.ts
@@ -0,0 +1,239 @@
+#!/usr/bin/env node
+
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Headless OAuth login CLI tool.
+ *
+ * Allows users in headless environments (SSH, WSL, Cloud Shell, VMs) to
+ * complete the OAuth flow by:
+ * 1. Printing an OAuth URL to open in any browser
+ * 2. Reading pasted credentials JSON securely from /dev/tty (not stdin)
+ * 3. Saving credentials via OAuthCredentialStorage
+ *
+ * The /dev/tty approach ensures credentials are never visible to an AI model
+ * that may have spawned this process.
+ */
+
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as readline from 'node:readline';
+import crypto from 'node:crypto';
+import { google } from 'googleapis';
+import { OAuthCredentialStorage } from '../auth/token-storage/oauth-credential-storage';
+import { SCOPES } from '../auth/scopes';
+import { loadConfig } from '../utils/config';
+
+const config = loadConfig();
+const CLIENT_ID = config.clientId;
+const CLOUD_FUNCTION_URL = config.cloudFunctionUrl;
+
+interface CredentialsJson {
+ access_token: string;
+ refresh_token: string;
+ expiry_date: number;
+ scope?: string;
+ token_type?: string;
+}
+
+/**
+ * Opens a readable stream from /dev/tty (Unix) or CON (Windows).
+ * This bypasses process stdin entirely so credentials can't be intercepted
+ * by a parent process.
+ */
+function openTtyRead(): fs.ReadStream {
+ const ttyPath = os.platform() === 'win32' ? 'CON' : '/dev/tty';
+ return fs.createReadStream(ttyPath, { encoding: 'utf8' });
+}
+
+/**
+ * Opens a writable stream to /dev/tty (Unix) or CON (Windows).
+ */
+function openTtyWrite(): fs.WriteStream {
+ const ttyPath = os.platform() === 'win32' ? 'CON' : '/dev/tty';
+ return fs.createWriteStream(ttyPath);
+}
+
+/**
+ * Reads multi-line input from /dev/tty until valid JSON is detected
+ * or the user presses Enter on an empty line.
+ */
+function readCredentialsFromTty(): Promise {
+ return new Promise((resolve, reject) => {
+ let input: fs.ReadStream;
+ let output: fs.WriteStream;
+
+ try {
+ input = openTtyRead();
+ output = openTtyWrite();
+ } catch {
+ reject(
+ new Error(
+ 'Cannot open terminal for secure input. ' +
+ 'This command must be run in an interactive terminal.',
+ ),
+ );
+ return;
+ }
+
+ const rl = readline.createInterface({ input, output, terminal: false });
+ const lines: string[] = [];
+
+ output.write(
+ 'Paste the credentials JSON from the browser, then press Enter twice:\n',
+ );
+
+ rl.on('line', (line) => {
+ lines.push(line);
+
+ // Try to parse accumulated input as JSON after each line
+ const joined = lines.join('\n').trim();
+ if (joined) {
+ try {
+ JSON.parse(joined);
+ // Valid JSON detected, we're done
+ rl.close();
+ return;
+ } catch {
+ // Not valid JSON yet, keep collecting
+ }
+ }
+
+ // Empty line after content means user is done
+ if (line.trim() === '' && lines.length > 1) {
+ rl.close();
+ }
+ });
+
+ rl.on('close', () => {
+ input.destroy();
+ output.end();
+ resolve(lines.join('\n').trim());
+ });
+
+ rl.on('error', (err) => {
+ input.destroy();
+ output.end();
+ reject(err);
+ });
+ });
+}
+
+/**
+ * Validates that the parsed JSON contains the required credential fields.
+ */
+function validateCredentials(
+ data: Record,
+): asserts data is Record & CredentialsJson {
+ if (typeof data.access_token !== 'string' || !data.access_token) {
+ throw new Error('Missing or invalid "access_token" field.');
+ }
+ if (typeof data.refresh_token !== 'string' || !data.refresh_token) {
+ throw new Error('Missing or invalid "refresh_token" field.');
+ }
+ if (typeof data.expiry_date !== 'number' || !data.expiry_date) {
+ throw new Error('Missing or invalid "expiry_date" field.');
+ }
+}
+
+/**
+ * Generates the OAuth URL with manual=true state, matching the pattern
+ * used by AuthManager.authWithWeb().
+ */
+function generateOAuthUrl(): string {
+ const csrfToken = crypto.randomBytes(32).toString('hex');
+
+ const statePayload = {
+ manual: true,
+ csrf: csrfToken,
+ };
+ const state = Buffer.from(JSON.stringify(statePayload)).toString('base64');
+
+ const oAuth2Client = new google.auth.OAuth2({ clientId: CLIENT_ID });
+
+ return oAuth2Client.generateAuthUrl({
+ redirect_uri: CLOUD_FUNCTION_URL,
+ access_type: 'offline',
+ scope: SCOPES,
+ state,
+ prompt: 'consent',
+ });
+}
+
+async function main() {
+ const force = process.argv.includes('--force');
+
+ // Check for existing credentials unless --force is used
+ if (!force) {
+ const existing = await OAuthCredentialStorage.loadCredentials();
+ if (existing && existing.refresh_token) {
+ console.log('Already authenticated. Credentials found in storage.');
+ console.log('Use --force to re-authenticate.');
+ return;
+ }
+ }
+
+ // Generate and display the OAuth URL
+ const authUrl = generateOAuthUrl();
+
+ console.log();
+ console.log('=== Google Workspace MCP Server - Headless Login ===');
+ console.log();
+ console.log('Open this URL in any browser (local machine, phone, etc.):');
+ console.log();
+ console.log(authUrl);
+ console.log();
+ console.log('After signing in, the browser will show your credentials JSON.');
+ console.log('Copy that JSON and paste it below.');
+ console.log();
+
+ // Read credentials securely from /dev/tty
+ const rawInput = await readCredentialsFromTty();
+
+ if (!rawInput) {
+ console.error('No input received.');
+ process.exit(1);
+ }
+
+ // Parse and validate
+ let parsed: Record;
+ try {
+ parsed = JSON.parse(rawInput);
+ } catch {
+ console.error(
+ 'Invalid JSON. Please copy the complete JSON from the browser.',
+ );
+ process.exit(1);
+ }
+
+ try {
+ validateCredentials(parsed);
+ } catch (err) {
+ console.error(
+ `Invalid credentials: ${err instanceof Error ? err.message : err}`,
+ );
+ process.exit(1);
+ }
+
+ // Save credentials
+ await OAuthCredentialStorage.saveCredentials({
+ access_token: parsed.access_token as string,
+ refresh_token: parsed.refresh_token as string,
+ expiry_date: parsed.expiry_date as number,
+ scope: (parsed.scope as string) || SCOPES.join(' '),
+ token_type: (parsed.token_type as string) || 'Bearer',
+ });
+
+ console.log();
+ console.log('Credentials saved successfully!');
+ console.log('You can now start the MCP server.');
+}
+
+main().catch((error) => {
+ console.error('Login failed:', error.message || error);
+ process.exit(1);
+});
diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts
index e2666a41..d2220d07 100644
--- a/workspace-server/src/index.ts
+++ b/workspace-server/src/index.ts
@@ -24,6 +24,7 @@ import { extractDocId } from './utils/IdUtils';
import { setLoggingEnabled } from './utils/logger';
import { applyToolNameNormalization } from './utils/tool-normalization';
+import { SCOPES } from './auth/scopes';
// Shared schemas for Gmail tools
const emailComposeSchema = {
@@ -46,24 +47,16 @@ const emailComposeSchema = {
.describe('Whether the body is HTML (default: false).'),
};
-const SCOPES = [
- 'https://www.googleapis.com/auth/documents',
- 'https://www.googleapis.com/auth/drive',
- 'https://www.googleapis.com/auth/calendar',
- 'https://www.googleapis.com/auth/chat.spaces',
- 'https://www.googleapis.com/auth/chat.messages',
- 'https://www.googleapis.com/auth/chat.memberships',
- 'https://www.googleapis.com/auth/userinfo.profile',
- 'https://www.googleapis.com/auth/gmail.modify',
- 'https://www.googleapis.com/auth/directory.readonly',
- 'https://www.googleapis.com/auth/presentations.readonly',
- 'https://www.googleapis.com/auth/spreadsheets.readonly',
-];
-
// Dynamically import version from package.json
import { version } from '../package.json';
async function main() {
+ // Handle 'login' subcommand for headless OAuth flow
+ if (process.argv.includes('login')) {
+ await import('./cli/headless-login');
+ return;
+ }
+
// 1. Initialize services
if (process.argv.includes('--debug')) {
setLoggingEnabled(true);