mcp-google-multi 4.2.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/dist/auth.js ADDED
@@ -0,0 +1,178 @@
1
+ import { google } from 'googleapis';
2
+ import http from 'node:http';
3
+ import { URL } from 'node:url';
4
+ import { randomBytes } from 'node:crypto';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import open from 'open';
8
+ import destroyer from 'server-destroy';
9
+ import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
10
+ // ─── Scope tiers ────────────────────────────────────────────────────────
11
+ //
12
+ // BASE: always granted. Existing v3 surface + Tasks + Meet (added in v4.0.0).
13
+ // OPTIONAL: per-account opt-in via env GOOGLE_OPTIONAL_SCOPES="forms,chat".
14
+ // ADMIN: per-account opt-in via env GOOGLE_ADMIN_ACCOUNTS="alias1,alias2".
15
+ //
16
+ // Personal Gmail accounts will 403 on admin scopes — never grant by default.
17
+ // ────────────────────────────────────────────────────────────────────────
18
+ export const BASE_SCOPES = [
19
+ 'https://www.googleapis.com/auth/gmail.modify',
20
+ 'https://www.googleapis.com/auth/gmail.send',
21
+ 'https://www.googleapis.com/auth/drive',
22
+ 'https://www.googleapis.com/auth/calendar',
23
+ 'https://www.googleapis.com/auth/spreadsheets',
24
+ 'https://www.googleapis.com/auth/documents',
25
+ 'https://www.googleapis.com/auth/contacts',
26
+ 'https://www.googleapis.com/auth/webmasters',
27
+ 'https://www.googleapis.com/auth/tasks',
28
+ 'https://www.googleapis.com/auth/meetings.space.readonly',
29
+ ];
30
+ export const OPTIONAL_SCOPE_BUNDLES = {
31
+ forms: [
32
+ 'https://www.googleapis.com/auth/forms.body',
33
+ 'https://www.googleapis.com/auth/forms.responses.readonly',
34
+ ],
35
+ chat: [
36
+ 'https://www.googleapis.com/auth/chat.spaces',
37
+ 'https://www.googleapis.com/auth/chat.messages',
38
+ 'https://www.googleapis.com/auth/chat.messages.create',
39
+ ],
40
+ // Alert Center's apps.alerts scope is NOT grantable through the interactive
41
+ // user-consent flow this server uses — it requires a service account with
42
+ // domain-wide delegation. It lives in its own opt-in bundle so it never
43
+ // blocks the working Admin SDK admin scopes. See README "Alert Center".
44
+ alertcenter: [
45
+ 'https://www.googleapis.com/auth/apps.alerts',
46
+ ],
47
+ };
48
+ export const ADMIN_SCOPES = [
49
+ 'https://www.googleapis.com/auth/admin.reports.audit.readonly',
50
+ 'https://www.googleapis.com/auth/admin.directory.user',
51
+ 'https://www.googleapis.com/auth/admin.directory.group.readonly',
52
+ 'https://www.googleapis.com/auth/admin.directory.group.member.readonly',
53
+ ];
54
+ /** Parse comma-separated env value into a deduplicated string array. */
55
+ function parseCsvEnv(name) {
56
+ return (process.env[name]?.trim() ?? '')
57
+ .split(',')
58
+ .map(s => s.trim())
59
+ .filter(Boolean);
60
+ }
61
+ /** Bundle keys enabled via GOOGLE_OPTIONAL_SCOPES (e.g. ["forms","chat"]). */
62
+ export function getOptionalBundles() {
63
+ return parseCsvEnv('GOOGLE_OPTIONAL_SCOPES').filter(b => b in OPTIONAL_SCOPE_BUNDLES);
64
+ }
65
+ /** Account aliases granted ADMIN_SCOPES via GOOGLE_ADMIN_ACCOUNTS. */
66
+ export function getAdminAccounts() {
67
+ return parseCsvEnv('GOOGLE_ADMIN_ACCOUNTS');
68
+ }
69
+ /**
70
+ * Compose the scope list for a single account at consent time.
71
+ * Resolves env flags: GOOGLE_OPTIONAL_SCOPES (global) and GOOGLE_ADMIN_ACCOUNTS (per-account allowlist).
72
+ */
73
+ export function resolveScopesForAccount(alias) {
74
+ const scopes = [...BASE_SCOPES];
75
+ for (const bundle of getOptionalBundles()) {
76
+ scopes.push(...OPTIONAL_SCOPE_BUNDLES[bundle]);
77
+ }
78
+ if (getAdminAccounts().includes(alias)) {
79
+ scopes.push(...ADMIN_SCOPES);
80
+ }
81
+ return Array.from(new Set(scopes));
82
+ }
83
+ /** True if admin writes are explicitly enabled. Default refuses to prevent accidents on small orgs. */
84
+ export function adminWritesEnabled() {
85
+ return process.env.GOOGLE_ALLOW_ADMIN_WRITES === 'true';
86
+ }
87
+ export async function runAuthFlow(args) {
88
+ const accountIdx = args.indexOf('--account');
89
+ if (accountIdx === -1 || !args[accountIdx + 1]) {
90
+ console.error('Usage: node dist/index.js auth --account <alias>');
91
+ console.error(`Valid aliases: ${ACCOUNTS.join(', ')}`);
92
+ process.exit(1);
93
+ }
94
+ const alias = args[accountIdx + 1];
95
+ if (!ACCOUNTS.includes(alias)) {
96
+ console.error(`Unknown account "${alias}". Valid aliases: ${ACCOUNTS.join(', ')}`);
97
+ process.exit(1);
98
+ }
99
+ const config = ACCOUNT_CONFIG[alias];
100
+ const scopes = resolveScopesForAccount(alias);
101
+ const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
102
+ // CSRF protection for the OAuth callback (RFC 6749 §10.12).
103
+ const expectedState = randomBytes(32).toString('hex');
104
+ const authorizeUrl = oauth2Client.generateAuthUrl({
105
+ access_type: 'offline',
106
+ prompt: 'consent',
107
+ scope: scopes,
108
+ login_hint: config.email,
109
+ state: expectedState,
110
+ });
111
+ console.log(`Authenticating account "${alias}" (${config.email})...`);
112
+ console.log(`Requesting ${scopes.length} scopes.`);
113
+ if (getAdminAccounts().includes(alias)) {
114
+ console.log(' ⚠ Admin scopes included — this account will be granted Workspace admin access.');
115
+ }
116
+ console.log(`Opening browser for authorization...`);
117
+ return new Promise((resolve, reject) => {
118
+ const server = http
119
+ .createServer(async (req, res) => {
120
+ try {
121
+ if (req.url && req.url.startsWith('/oauth2callback')) {
122
+ const qs = new URL(req.url, 'http://localhost:4242').searchParams;
123
+ const error = qs.get('error');
124
+ if (error) {
125
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
126
+ res.end(`Authorization denied: ${error}`);
127
+ server.destroy();
128
+ reject(new Error(`Authorization denied: ${error}`));
129
+ return;
130
+ }
131
+ const code = qs.get('code');
132
+ if (!code) {
133
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
134
+ res.end('No authorization code received.');
135
+ server.destroy();
136
+ reject(new Error('No authorization code received'));
137
+ return;
138
+ }
139
+ const returnedState = qs.get('state');
140
+ if (returnedState !== expectedState) {
141
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
142
+ res.end('State mismatch — possible CSRF attempt. Aborting.');
143
+ server.destroy();
144
+ reject(new Error('OAuth state token mismatch'));
145
+ return;
146
+ }
147
+ const { tokens } = await oauth2Client.getToken(code);
148
+ await fs.mkdir(path.dirname(config.tokenPath), { recursive: true });
149
+ // 0o600: tokens grant full account access; keep them user-only.
150
+ await fs.writeFile(config.tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
151
+ res.writeHead(200, { 'Content-Type': 'text/html' });
152
+ res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
153
+ server.destroy();
154
+ console.log(`Token saved to ${config.tokenPath}`);
155
+ resolve();
156
+ }
157
+ }
158
+ catch (e) {
159
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
160
+ res.end('Internal error during authentication.');
161
+ server.destroy();
162
+ reject(e);
163
+ }
164
+ })
165
+ // Bind to loopback only — never expose the OAuth callback to the local network.
166
+ .listen(4242, '127.0.0.1', () => {
167
+ open(authorizeUrl, { wait: false }).then((cp) => cp.unref());
168
+ });
169
+ destroyer(server);
170
+ server.on('error', (err) => {
171
+ if (err.code === 'EADDRINUSE') {
172
+ console.error('Port 4242 is already in use. Close the process using it and retry.');
173
+ process.exit(1);
174
+ }
175
+ reject(err);
176
+ });
177
+ });
178
+ }
@@ -0,0 +1,2 @@
1
+ import type { Account } from './accounts.js';
2
+ export declare function getClient(account: Account): Promise<import("google-auth-library").OAuth2Client>;
package/dist/client.js ADDED
@@ -0,0 +1,33 @@
1
+ import { google } from 'googleapis';
2
+ import fs from 'node:fs/promises';
3
+ import { ACCOUNT_CONFIG } from './accounts.js';
4
+ export async function getClient(account) {
5
+ const config = ACCOUNT_CONFIG[account];
6
+ if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
7
+ throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
8
+ 'Check that .env exists in the project root or pass them as env vars.');
9
+ }
10
+ const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
11
+ let tokenData;
12
+ try {
13
+ const raw = await fs.readFile(config.tokenPath, 'utf-8');
14
+ tokenData = JSON.parse(raw);
15
+ }
16
+ catch {
17
+ throw new Error(`No token file found for account "${account}" at ${config.tokenPath}. ` +
18
+ `Run: node dist/index.js auth --account ${account}`);
19
+ }
20
+ oauth2Client.setCredentials(tokenData);
21
+ // 0o600: tokens grant full account access; keep them user-only.
22
+ oauth2Client.on('tokens', async (tokens) => {
23
+ try {
24
+ const existing = JSON.parse(await fs.readFile(config.tokenPath, 'utf-8'));
25
+ const merged = { ...existing, ...tokens };
26
+ await fs.writeFile(config.tokenPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
27
+ }
28
+ catch {
29
+ await fs.writeFile(config.tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
30
+ }
31
+ });
32
+ return oauth2Client;
33
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import './accounts.js';
package/dist/index.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import './accounts.js';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { registerGmailTools } from './tools/gmail.js';
9
+ import { registerDriveTools } from './tools/drive.js';
10
+ import { registerCalendarTools } from './tools/calendar.js';
11
+ import { registerSheetsTools } from './tools/sheets.js';
12
+ import { registerDocsTools } from './tools/docs.js';
13
+ import { registerContactsTools } from './tools/contacts.js';
14
+ import { registerSearchConsoleTools } from './tools/searchconsole.js';
15
+ import { registerTasksTools } from './tools/tasks.js';
16
+ import { registerMeetTools } from './tools/meet.js';
17
+ import { registerFormsTools } from './tools/forms.js';
18
+ import { registerChatTools } from './tools/chat.js';
19
+ import { registerAdminTools, registerAlertCenterTools } from './tools/admin.js';
20
+ import { getOptionalBundles, getAdminAccounts } from './auth.js';
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
23
+ async function main() {
24
+ if (process.argv.includes('auth')) {
25
+ const { runAuthFlow } = await import('./auth.js');
26
+ await runAuthFlow(process.argv);
27
+ return;
28
+ }
29
+ // MCP server mode — no console.log (stdio is the MCP channel)
30
+ const server = new McpServer({
31
+ name: 'mcp-google-multi',
32
+ version: pkg.version,
33
+ });
34
+ registerGmailTools(server);
35
+ registerDriveTools(server);
36
+ registerCalendarTools(server);
37
+ registerSheetsTools(server);
38
+ registerDocsTools(server);
39
+ registerContactsTools(server);
40
+ registerSearchConsoleTools(server);
41
+ registerTasksTools(server);
42
+ registerMeetTools(server);
43
+ const optional = new Set(getOptionalBundles());
44
+ if (optional.has('forms'))
45
+ registerFormsTools(server);
46
+ if (optional.has('chat'))
47
+ registerChatTools(server);
48
+ if (optional.has('alertcenter'))
49
+ registerAlertCenterTools(server);
50
+ if (getAdminAccounts().length > 0)
51
+ registerAdminTools(server);
52
+ const transport = new StdioServerTransport();
53
+ await server.connect(transport);
54
+ }
55
+ main().catch((err) => {
56
+ process.stderr.write(`Fatal error: ${err.message}\n`);
57
+ process.exit(1);
58
+ });
@@ -0,0 +1,9 @@
1
+ import type { Account } from '../accounts.js';
2
+ /** Maps googleapis errors to MCP tool responses. 401 → re-auth instruction; 403 + hint → structured hint payload; 429 → retry-after; else → {error, code}. */
3
+ export declare function handleGoogleApiError(error: any, account: Account, forbiddenHint?: string): {
4
+ content: {
5
+ type: "text";
6
+ text: string;
7
+ }[];
8
+ isError: true;
9
+ };
@@ -0,0 +1,42 @@
1
+ /** Maps googleapis errors to MCP tool responses. 401 → re-auth instruction; 403 + hint → structured hint payload; 429 → retry-after; else → {error, code}. */
2
+ export function handleGoogleApiError(error, account, forbiddenHint) {
3
+ if (error.code === 401) {
4
+ return {
5
+ content: [{
6
+ type: 'text',
7
+ text: `Authentication error for account "${account}". Run: node dist/index.js auth --account ${account}`,
8
+ }],
9
+ isError: true,
10
+ };
11
+ }
12
+ if (error.code === 403 && forbiddenHint) {
13
+ return {
14
+ content: [{
15
+ type: 'text',
16
+ text: JSON.stringify({
17
+ error: 'forbidden',
18
+ hint: forbiddenHint,
19
+ original: error.message ?? String(error),
20
+ }),
21
+ }],
22
+ isError: true,
23
+ };
24
+ }
25
+ if (error.code === 429) {
26
+ const retryAfter = error.response?.headers?.['retry-after'] ?? 'unknown';
27
+ return {
28
+ content: [{
29
+ type: 'text',
30
+ text: JSON.stringify({ error: 'rate_limited', retryAfter }),
31
+ }],
32
+ isError: true,
33
+ };
34
+ }
35
+ return {
36
+ content: [{
37
+ type: 'text',
38
+ text: JSON.stringify({ error: error.message ?? String(error), code: error.code }),
39
+ }],
40
+ isError: true,
41
+ };
42
+ }
@@ -0,0 +1,17 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /**
3
+ * Admin SDK tools require Workspace super-admin (or delegated admin) privileges on the target account.
4
+ * Personal `@gmail.com` accounts will 403 on every endpoint.
5
+ *
6
+ * Writes are gated behind GOOGLE_ALLOW_ADMIN_WRITES=true to prevent accidents on small orgs
7
+ * (a stray users.update on a 3-person Workspace is a bad day).
8
+ */
9
+ export declare function registerAdminTools(server: McpServer): void;
10
+ /**
11
+ * Alert Center tools. Gated behind the `alertcenter` optional bundle, NOT the
12
+ * admin bundle: the apps.alerts scope cannot be granted via this server's
13
+ * interactive user-consent OAuth flow — it requires a service account with
14
+ * domain-wide delegation. Registered separately so a missing/ungrantable
15
+ * apps.alerts scope never blocks the working Admin SDK admin tools.
16
+ */
17
+ export declare function registerAlertCenterTools(server: McpServer): void;
@@ -0,0 +1,307 @@
1
+ import { z } from 'zod';
2
+ import { google } from 'googleapis';
3
+ import { ACCOUNTS } from '../accounts.js';
4
+ import { getClient } from '../client.js';
5
+ import { handleGoogleApiError } from './_errors.js';
6
+ import { adminWritesEnabled } from '../auth.js';
7
+ const accountEnum = z.enum(ACCOUNTS);
8
+ /**
9
+ * Admin SDK tools require Workspace super-admin (or delegated admin) privileges on the target account.
10
+ * Personal `@gmail.com` accounts will 403 on every endpoint.
11
+ *
12
+ * Writes are gated behind GOOGLE_ALLOW_ADMIN_WRITES=true to prevent accidents on small orgs
13
+ * (a stray users.update on a 3-person Workspace is a bad day).
14
+ */
15
+ export function registerAdminTools(server) {
16
+ // ─── Reports / audit log ───────────────────────────────────────────────
17
+ server.registerTool('reports_activities_list', {
18
+ description: 'List Admin Activity audit log entries. Filter by application (login, drive, gmail, admin, token, etc.), user, date range, event. The single most useful admin endpoint for SMB visibility.',
19
+ inputSchema: {
20
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
21
+ applicationName: z.enum([
22
+ 'access_transparency', 'admin', 'calendar', 'chat', 'drive', 'gcp', 'gmail',
23
+ 'gplus', 'groups', 'groups_enterprise', 'jamboard', 'login', 'meet', 'mobile',
24
+ 'rules', 'saml', 'token', 'user_accounts', 'context_aware_access', 'chrome',
25
+ 'data_studio', 'keep', 'vault', 'classroom', 'assignments', 'cloud_search',
26
+ 'tasks', 'data_migration', 'meet_hardware', 'directory_sync', 'ldap',
27
+ 'profile', 'contacts', 'takeout',
28
+ ]).describe('Which application\'s activity to query'),
29
+ userKey: z.string().default('all').optional().describe('User profile ID, email, or "all" (default)'),
30
+ startTime: z.string().optional().describe('RFC 3339 lower bound'),
31
+ endTime: z.string().optional().describe('RFC 3339 upper bound'),
32
+ eventName: z.string().optional().describe('Specific event name to filter'),
33
+ actorIpAddress: z.string().optional(),
34
+ filters: z.string().optional().describe('Comma-separated event-parameter filters with relational operators'),
35
+ orgUnitID: z.string().optional(),
36
+ groupIdFilter: z.string().optional(),
37
+ customerId: z.string().optional(),
38
+ maxResults: z.number().min(1).max(1000).optional(),
39
+ pageToken: z.string().optional(),
40
+ },
41
+ }, async ({ account, applicationName, userKey, startTime, endTime, eventName, actorIpAddress, filters, orgUnitID, groupIdFilter, customerId, maxResults, pageToken }) => {
42
+ try {
43
+ const auth = await getClient(account);
44
+ const reports = google.admin({ version: 'reports_v1', auth });
45
+ const res = await reports.activities.list({
46
+ applicationName,
47
+ userKey: userKey ?? 'all',
48
+ startTime,
49
+ endTime,
50
+ eventName,
51
+ actorIpAddress,
52
+ filters,
53
+ orgUnitID,
54
+ groupIdFilter,
55
+ customerId,
56
+ maxResults: maxResults ?? 100,
57
+ pageToken,
58
+ });
59
+ return {
60
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
61
+ };
62
+ }
63
+ catch (error) {
64
+ return handleAdminError(error, account);
65
+ }
66
+ });
67
+ // ─── Directory: users ─────────────────────────────────────────────────
68
+ server.registerTool('admin_users_list', {
69
+ description: 'List users in the Workspace customer. Filter by domain or customer=my_customer.',
70
+ inputSchema: {
71
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
72
+ customer: z.string().default('my_customer').optional(),
73
+ domain: z.string().optional(),
74
+ query: z.string().optional().describe('Search query, e.g. "name:John"'),
75
+ maxResults: z.number().min(1).max(500).optional(),
76
+ pageToken: z.string().optional(),
77
+ orderBy: z.enum(['email', 'familyName', 'givenName']).optional(),
78
+ showDeleted: z.boolean().optional(),
79
+ projection: z.enum(['basic', 'custom', 'full']).optional(),
80
+ },
81
+ }, async ({ account, customer, domain, query, maxResults, pageToken, orderBy, showDeleted, projection }) => {
82
+ try {
83
+ const auth = await getClient(account);
84
+ const directory = google.admin({ version: 'directory_v1', auth });
85
+ const res = await directory.users.list({
86
+ customer: customer ?? 'my_customer',
87
+ domain,
88
+ query,
89
+ maxResults: maxResults ?? 100,
90
+ pageToken,
91
+ orderBy,
92
+ showDeleted: showDeleted !== undefined ? (showDeleted ? 'true' : 'false') : undefined,
93
+ projection: projection ?? 'basic',
94
+ });
95
+ return {
96
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
97
+ };
98
+ }
99
+ catch (error) {
100
+ return handleAdminError(error, account);
101
+ }
102
+ });
103
+ server.registerTool('admin_users_get', {
104
+ description: 'Get a single Workspace user by email or user ID',
105
+ inputSchema: {
106
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
107
+ userKey: z.string().describe('User email or ID'),
108
+ projection: z.enum(['basic', 'custom', 'full']).optional(),
109
+ },
110
+ }, async ({ account, userKey, projection }) => {
111
+ try {
112
+ const auth = await getClient(account);
113
+ const directory = google.admin({ version: 'directory_v1', auth });
114
+ const res = await directory.users.get({
115
+ userKey,
116
+ projection: projection ?? 'basic',
117
+ });
118
+ return {
119
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
120
+ };
121
+ }
122
+ catch (error) {
123
+ return handleAdminError(error, account);
124
+ }
125
+ });
126
+ server.registerTool('admin_users_update', {
127
+ description: 'Update a Workspace user (PATCH semantics). GATED: requires GOOGLE_ALLOW_ADMIN_WRITES=true to prevent accidental destructive ops on small orgs.',
128
+ inputSchema: {
129
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
130
+ userKey: z.string().describe('User email or ID'),
131
+ givenName: z.string().optional(),
132
+ familyName: z.string().optional(),
133
+ suspended: z.boolean().optional(),
134
+ password: z.string().optional(),
135
+ changePasswordAtNextLogin: z.boolean().optional(),
136
+ orgUnitPath: z.string().optional(),
137
+ },
138
+ }, async ({ account, userKey, givenName, familyName, suspended, password, changePasswordAtNextLogin, orgUnitPath }) => {
139
+ try {
140
+ if (!adminWritesEnabled()) {
141
+ return {
142
+ content: [{
143
+ type: 'text',
144
+ text: JSON.stringify({
145
+ error: 'admin_writes_disabled',
146
+ hint: 'Admin write operations are disabled by default. Set GOOGLE_ALLOW_ADMIN_WRITES=true in .env and restart to enable.',
147
+ }),
148
+ }],
149
+ isError: true,
150
+ };
151
+ }
152
+ const auth = await getClient(account);
153
+ const directory = google.admin({ version: 'directory_v1', auth });
154
+ const requestBody = {};
155
+ if (givenName !== undefined || familyName !== undefined) {
156
+ requestBody.name = {};
157
+ if (givenName !== undefined)
158
+ requestBody.name.givenName = givenName;
159
+ if (familyName !== undefined)
160
+ requestBody.name.familyName = familyName;
161
+ }
162
+ if (suspended !== undefined)
163
+ requestBody.suspended = suspended;
164
+ if (password !== undefined)
165
+ requestBody.password = password;
166
+ if (changePasswordAtNextLogin !== undefined)
167
+ requestBody.changePasswordAtNextLogin = changePasswordAtNextLogin;
168
+ if (orgUnitPath !== undefined)
169
+ requestBody.orgUnitPath = orgUnitPath;
170
+ if (Object.keys(requestBody).length === 0) {
171
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No fields to update' }) }], isError: true };
172
+ }
173
+ const res = await directory.users.patch({ userKey, requestBody });
174
+ return {
175
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
176
+ };
177
+ }
178
+ catch (error) {
179
+ return handleAdminError(error, account);
180
+ }
181
+ });
182
+ // ─── Directory: groups ────────────────────────────────────────────────
183
+ server.registerTool('admin_groups_list', {
184
+ description: 'List groups in the Workspace customer',
185
+ inputSchema: {
186
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
187
+ customer: z.string().default('my_customer').optional(),
188
+ domain: z.string().optional(),
189
+ userKey: z.string().optional().describe('Only return groups containing this user'),
190
+ query: z.string().optional(),
191
+ maxResults: z.number().min(1).max(200).optional(),
192
+ pageToken: z.string().optional(),
193
+ },
194
+ }, async ({ account, customer, domain, userKey, query, maxResults, pageToken }) => {
195
+ try {
196
+ const auth = await getClient(account);
197
+ const directory = google.admin({ version: 'directory_v1', auth });
198
+ const res = await directory.groups.list({
199
+ customer: customer ?? 'my_customer',
200
+ domain,
201
+ userKey,
202
+ query,
203
+ maxResults: maxResults ?? 100,
204
+ pageToken,
205
+ });
206
+ return {
207
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
208
+ };
209
+ }
210
+ catch (error) {
211
+ return handleAdminError(error, account);
212
+ }
213
+ });
214
+ server.registerTool('admin_group_members_list', {
215
+ description: 'List members of a Workspace group',
216
+ inputSchema: {
217
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
218
+ groupKey: z.string().describe('Group email or ID'),
219
+ roles: z.string().optional().describe('Comma-separated roles to include (OWNER, MANAGER, MEMBER)'),
220
+ includeDerivedMembership: z.boolean().optional(),
221
+ maxResults: z.number().min(1).max(200).optional(),
222
+ pageToken: z.string().optional(),
223
+ },
224
+ }, async ({ account, groupKey, roles, includeDerivedMembership, maxResults, pageToken }) => {
225
+ try {
226
+ const auth = await getClient(account);
227
+ const directory = google.admin({ version: 'directory_v1', auth });
228
+ const res = await directory.members.list({
229
+ groupKey,
230
+ roles,
231
+ includeDerivedMembership,
232
+ maxResults: maxResults ?? 100,
233
+ pageToken,
234
+ });
235
+ return {
236
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
237
+ };
238
+ }
239
+ catch (error) {
240
+ return handleAdminError(error, account);
241
+ }
242
+ });
243
+ }
244
+ /**
245
+ * Alert Center tools. Gated behind the `alertcenter` optional bundle, NOT the
246
+ * admin bundle: the apps.alerts scope cannot be granted via this server's
247
+ * interactive user-consent OAuth flow — it requires a service account with
248
+ * domain-wide delegation. Registered separately so a missing/ungrantable
249
+ * apps.alerts scope never blocks the working Admin SDK admin tools.
250
+ */
251
+ export function registerAlertCenterTools(server) {
252
+ server.registerTool('alertcenter_alerts_list', {
253
+ description: 'List Workspace security alerts (suspicious login, phishing, leaked password, Drive exfil, etc.). Requires the alertcenter bundle + service-account domain-wide delegation.',
254
+ inputSchema: {
255
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
256
+ pageSize: z.number().min(1).max(1000).optional(),
257
+ pageToken: z.string().optional(),
258
+ filter: z.string().optional().describe('Filter expression, e.g. "type = \\"Suspicious login\\""'),
259
+ orderBy: z.string().optional(),
260
+ customerId: z.string().optional(),
261
+ },
262
+ }, async ({ account, pageSize, pageToken, filter, orderBy, customerId }) => {
263
+ try {
264
+ const auth = await getClient(account);
265
+ const alertcenter = google.alertcenter({ version: 'v1beta1', auth });
266
+ const res = await alertcenter.alerts.list({
267
+ pageSize: pageSize ?? 50,
268
+ pageToken,
269
+ filter,
270
+ orderBy,
271
+ customerId,
272
+ });
273
+ return {
274
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
275
+ };
276
+ }
277
+ catch (error) {
278
+ return handleAlertCenterError(error, account);
279
+ }
280
+ });
281
+ server.registerTool('alertcenter_alert_get', {
282
+ description: 'Get a single alert by ID. Requires the alertcenter bundle + service-account domain-wide delegation.',
283
+ inputSchema: {
284
+ account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
285
+ alertId: z.string().describe('Alert ID'),
286
+ customerId: z.string().optional(),
287
+ },
288
+ }, async ({ account, alertId, customerId }) => {
289
+ try {
290
+ const auth = await getClient(account);
291
+ const alertcenter = google.alertcenter({ version: 'v1beta1', auth });
292
+ const res = await alertcenter.alerts.get({ alertId, customerId });
293
+ return {
294
+ content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
295
+ };
296
+ }
297
+ catch (error) {
298
+ return handleAlertCenterError(error, account);
299
+ }
300
+ });
301
+ }
302
+ function handleAdminError(error, account) {
303
+ return handleGoogleApiError(error, account, "Admin tools require Workspace super-admin privileges AND the account must be listed in GOOGLE_ADMIN_ACCOUNTS (then re-authenticated). Personal Gmail accounts cannot use these endpoints.");
304
+ }
305
+ function handleAlertCenterError(error, account) {
306
+ return handleGoogleApiError(error, account, "Alert Center (apps.alerts) is not grantable via this server's user-consent OAuth flow — it requires a service account with domain-wide delegation. Enabling GOOGLE_OPTIONAL_SCOPES=alertcenter only declares the scope; the API will reject user-OAuth tokens. See README \"Alert Center\".");
307
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerCalendarTools(server: McpServer): void;