mcp-google-multi 6.0.0-alpha.5 → 6.0.0-alpha.7
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/doctor.d.ts +12 -0
- package/dist/doctor.js +93 -15
- package/dist/tools/drive.d.ts +1 -0
- package/dist/tools/drive.js +44 -22
- package/dist/tools/gmail.js +1 -1
- package/package.json +1 -1
package/dist/doctor.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ToolRegistry } from './registry.js';
|
|
|
2
2
|
import { getAccountSet } from './accounts.js';
|
|
3
3
|
import { type AccountHealth } from './tools/accounts-tool.js';
|
|
4
4
|
import { peekMasterKeyProvenance } from './master-key.js';
|
|
5
|
+
import { type HttpConfig } from './http-config.js';
|
|
5
6
|
export type Verdict = 'ok' | 'warn' | 'fail' | 'unknown';
|
|
6
7
|
export interface DiagnosticSection {
|
|
7
8
|
id: number;
|
|
@@ -38,6 +39,17 @@ export interface DiagnosticsDeps {
|
|
|
38
39
|
/** Optional live section-6 probe; when absent the section reports `unknown`
|
|
39
40
|
* (spec: a section that cannot run is unknown, not FAIL). */
|
|
40
41
|
probeApi?: (alias: string) => Promise<ApiProbeResult[]>;
|
|
42
|
+
/** Optional live section-7 endpoint probe (PRM/AS-metadata self-fetch);
|
|
43
|
+
* when absent, section 7 stays on its offline config checks. */
|
|
44
|
+
probeHttp?: (cfg: HttpConfig) => Promise<HttpProbeResult>;
|
|
45
|
+
}
|
|
46
|
+
/** Live §7 probe outcome. `unreachable` = connection-level failure (server not
|
|
47
|
+
* running), reported as `unknown` rather than FAIL; `problem` = a real
|
|
48
|
+
* metadata fault at a reachable server. */
|
|
49
|
+
export interface HttpProbeResult {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
unreachable?: boolean;
|
|
52
|
+
problem?: string;
|
|
41
53
|
}
|
|
42
54
|
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
43
55
|
export declare function apiEnableLink(api: string): string;
|
package/dist/doctor.js
CHANGED
|
@@ -7,6 +7,8 @@ import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.j
|
|
|
7
7
|
import { hasToken } from './token-store.js';
|
|
8
8
|
import { configDir } from './config-file.js';
|
|
9
9
|
import { probeApiEnablement } from './api-probe.js';
|
|
10
|
+
import { resolveHttpConfig, HttpConfigError } from './http-config.js';
|
|
11
|
+
import { parseOwnerEmails } from './http-transport.js';
|
|
10
12
|
const MIN_NODE_MAJOR = 22;
|
|
11
13
|
const DEFAULT_DEPS = {
|
|
12
14
|
nodeVersion: process.versions.node,
|
|
@@ -25,17 +27,43 @@ const DEFAULT_DEPS = {
|
|
|
25
27
|
anyTokensExist: (aliases) => aliases.some((a) => hasToken(a)),
|
|
26
28
|
fileExists: fs.existsSync,
|
|
27
29
|
probeApi: (alias) => probeApiEnablement(alias),
|
|
30
|
+
probeHttp: (cfg) => probeHttpEndpoints(cfg),
|
|
28
31
|
};
|
|
32
|
+
/** §7 live check: the advertised OAuth metadata must derive from MCP_PUBLIC_URL
|
|
33
|
+
* exactly — one mismatch between PRM `resource` / AS `issuer` and what clients
|
|
34
|
+
* compute from the public URL is the perpetual-401 interop bug (BR4). */
|
|
35
|
+
async function probeHttpEndpoints(cfg) {
|
|
36
|
+
try {
|
|
37
|
+
const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
|
|
38
|
+
signal: AbortSignal.timeout(2000),
|
|
39
|
+
redirect: 'manual',
|
|
40
|
+
});
|
|
41
|
+
if (!prmRes.ok)
|
|
42
|
+
return { ok: false, problem: `PRM endpoint returned HTTP ${prmRes.status}` };
|
|
43
|
+
const prm = (await prmRes.json());
|
|
44
|
+
if (prm.resource !== cfg.resourceUri) {
|
|
45
|
+
return { ok: false, problem: `PRM resource "${prm.resource}" does not match the expected "${cfg.resourceUri}"` };
|
|
46
|
+
}
|
|
47
|
+
const asRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-authorization-server`, {
|
|
48
|
+
signal: AbortSignal.timeout(2000),
|
|
49
|
+
redirect: 'manual',
|
|
50
|
+
});
|
|
51
|
+
if (!asRes.ok)
|
|
52
|
+
return { ok: false, problem: `AS metadata endpoint returned HTTP ${asRes.status}` };
|
|
53
|
+
const as = (await asRes.json());
|
|
54
|
+
if (as.issuer !== cfg.publicUrl) {
|
|
55
|
+
return { ok: false, problem: `AS metadata issuer "${as.issuer}" does not match the public URL "${cfg.publicUrl}"` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { ok: false, unreachable: true };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
29
63
|
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
30
64
|
export function apiEnableLink(api) {
|
|
31
65
|
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
32
66
|
}
|
|
33
|
-
function transportsFrom(env) {
|
|
34
|
-
return (env.MCP_TRANSPORT ?? 'stdio')
|
|
35
|
-
.split(',')
|
|
36
|
-
.map((s) => s.trim().toLowerCase())
|
|
37
|
-
.filter(Boolean);
|
|
38
|
-
}
|
|
39
67
|
const LEGACY_ENV_KEYS = ['GOOGLE_ACCOUNTS', 'GOOGLE_OPTIONAL_SCOPES', 'GOOGLE_ADMIN_ACCOUNTS'];
|
|
40
68
|
function sectionRuntime(deps) {
|
|
41
69
|
const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
|
|
@@ -182,15 +210,65 @@ async function sectionApiEnablement(deps, aliases) {
|
|
|
182
210
|
}
|
|
183
211
|
return { id: 6, title: 'API enablement', verdict: 'ok', lines: lines.length ? lines : ['(probed account, all enabled)'] };
|
|
184
212
|
}
|
|
185
|
-
function
|
|
186
|
-
|
|
213
|
+
async function sectionHttp(deps, aliases) {
|
|
214
|
+
const raw = (deps.env.MCP_TRANSPORT ?? '').trim().toLowerCase();
|
|
215
|
+
if (raw === '' || raw === 'stdio')
|
|
187
216
|
return null;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
217
|
+
let cfg;
|
|
218
|
+
try {
|
|
219
|
+
cfg = resolveHttpConfig(deps.env);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
id: 7,
|
|
224
|
+
title: 'HTTP',
|
|
225
|
+
verdict: 'fail',
|
|
226
|
+
slug: err instanceof HttpConfigError ? err.slug : 'E_HTTP_CONFIG',
|
|
227
|
+
lines: [err.message],
|
|
228
|
+
hint: 'Fix the MCP_* variable above and re-run doctor.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const lines = [`bind ${cfg.host}:${cfg.port}, public URL ${cfg.publicUrl} (resource ${cfg.resourceUri})`];
|
|
232
|
+
let verdict = 'ok';
|
|
233
|
+
let slug;
|
|
234
|
+
const hints = [];
|
|
235
|
+
const owners = parseOwnerEmails(deps.env);
|
|
236
|
+
if (owners.length === 0) {
|
|
237
|
+
verdict = 'fail';
|
|
238
|
+
slug = 'E_OWNER_EMAILS_REQUIRED';
|
|
239
|
+
lines.push('MCP_OWNER_EMAILS is empty — nobody can pass the owner gate.');
|
|
240
|
+
hints.push('Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.');
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const known = new Set(aliases.map((a) => deps.accountHealth(a).email.toLowerCase()));
|
|
244
|
+
const strangers = known.size > 0 ? owners.filter((o) => !known.has(o)) : [];
|
|
245
|
+
lines.push(`owner gate: ${owners.length} email(s)${strangers.length ? `, ${strangers.length} matching no configured account` : ''}`);
|
|
246
|
+
if (strangers.length > 0) {
|
|
247
|
+
verdict = 'warn';
|
|
248
|
+
slug = 'W_OWNER_EMAIL_UNKNOWN';
|
|
249
|
+
hints.push(`Owner entry ${strangers.join(', ')} is not a configured account email. ` +
|
|
250
|
+
'If that is a misspelling of your account email, sign-in will be refused — fix MCP_OWNER_EMAILS.');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (verdict !== 'fail' && deps.probeHttp) {
|
|
254
|
+
const probe = await deps.probeHttp(cfg);
|
|
255
|
+
if (probe.ok) {
|
|
256
|
+
lines.push('live: PRM + AS metadata verified at the public URL');
|
|
257
|
+
}
|
|
258
|
+
else if (probe.unreachable) {
|
|
259
|
+
if (verdict === 'ok')
|
|
260
|
+
verdict = 'unknown';
|
|
261
|
+
lines.push(`live: ${cfg.publicUrl} not reachable (server not running?)`);
|
|
262
|
+
hints.push('Start the server (MCP_TRANSPORT=http) and re-run doctor for the live endpoint checks.');
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
verdict = 'fail';
|
|
266
|
+
slug = 'E_HTTP_METADATA_MISMATCH';
|
|
267
|
+
lines.push(`live: ${probe.problem}`);
|
|
268
|
+
hints.push('The advertised OAuth metadata must derive from MCP_PUBLIC_URL exactly; restart the server after changing it.');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { id: 7, title: 'HTTP', verdict, ...(slug ? { slug } : {}), lines, ...(hints.length ? { hint: hints.join('\n') } : {}) };
|
|
194
272
|
}
|
|
195
273
|
const RANK = { ok: 0, unknown: 0, warn: 1, fail: 2 };
|
|
196
274
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
@@ -214,7 +292,7 @@ export async function runDiagnostics(deps = DEFAULT_DEPS) {
|
|
|
214
292
|
sections.push(tokens, scopes);
|
|
215
293
|
sections.push(await sectionApiEnablement(deps, aliases));
|
|
216
294
|
}
|
|
217
|
-
const http =
|
|
295
|
+
const http = await sectionHttp(deps, aliases);
|
|
218
296
|
if (http)
|
|
219
297
|
sections.push(http);
|
|
220
298
|
return { verdict: overallVerdict(sections), sections };
|
package/dist/tools/drive.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
2
|
export declare function isTextualMime(mimeType: string): boolean;
|
|
3
|
+
export declare function resolveConvertTarget(convertTo: string | undefined): string | undefined;
|
|
3
4
|
export declare const DRIVE_QUERY_HINT: string;
|
|
4
5
|
export declare function normalizeDriveQuery(raw: string): string;
|
|
5
6
|
export declare function isDriveInvalidQuery(error: any): boolean;
|
package/dist/tools/drive.js
CHANGED
|
@@ -48,6 +48,29 @@ export function isTextualMime(mimeType) {
|
|
|
48
48
|
return TEXTUAL_EXACT.has(bare);
|
|
49
49
|
}
|
|
50
50
|
const BINARY_READ_HINT = 'Binary content cannot be inlined. Use drive_download to save the file to disk, or drive_export for Google Workspace files.';
|
|
51
|
+
// Accepted alongside the full application/vnd.google-apps.* ids so the obvious
|
|
52
|
+
// short spelling ("document") works; the enum advertises both.
|
|
53
|
+
const CONVERT_SHORTHANDS = {
|
|
54
|
+
document: 'application/vnd.google-apps.document',
|
|
55
|
+
spreadsheet: 'application/vnd.google-apps.spreadsheet',
|
|
56
|
+
presentation: 'application/vnd.google-apps.presentation',
|
|
57
|
+
drawing: 'application/vnd.google-apps.drawing',
|
|
58
|
+
};
|
|
59
|
+
export function resolveConvertTarget(convertTo) {
|
|
60
|
+
if (!convertTo)
|
|
61
|
+
return undefined;
|
|
62
|
+
return CONVERT_SHORTHANDS[convertTo] ?? convertTo;
|
|
63
|
+
}
|
|
64
|
+
const CONVERT_TO_VALUES = [
|
|
65
|
+
'document',
|
|
66
|
+
'spreadsheet',
|
|
67
|
+
'presentation',
|
|
68
|
+
'drawing',
|
|
69
|
+
'application/vnd.google-apps.document',
|
|
70
|
+
'application/vnd.google-apps.spreadsheet',
|
|
71
|
+
'application/vnd.google-apps.presentation',
|
|
72
|
+
'application/vnd.google-apps.drawing',
|
|
73
|
+
];
|
|
51
74
|
// Comment/Reply fields list — Drive API requires explicit `fields` on every call.
|
|
52
75
|
const COMMENT_BASE_FIELDS = 'id,kind,content,htmlContent,createdTime,modifiedTime,resolved,anchor,author,deleted,quotedFileContent';
|
|
53
76
|
const REPLY_SUBFIELDS = 'id,content,action,createdTime,modifiedTime,author,deleted';
|
|
@@ -279,15 +302,10 @@ export function registerDriveTools(server) {
|
|
|
279
302
|
description: 'Upload a local file to Google Drive. Pass `convertTo` to import it as a native, editable Google Doc/Sheet/Slides/Drawing instead of storing the raw bytes.',
|
|
280
303
|
inputSchema: {
|
|
281
304
|
account: accountEnum.describe('Google account alias'),
|
|
282
|
-
localPath: z.string().describe('Absolute path
|
|
305
|
+
localPath: z.string().describe('Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath)'),
|
|
283
306
|
filename: z.string().describe('Name as it appears in Drive'),
|
|
284
307
|
mimeType: z.string().optional().describe('Source MIME type of the local file (inferred from extension if omitted). With `convertTo`, this is the format Drive imports from.'),
|
|
285
|
-
convertTo: z.enum(
|
|
286
|
-
'application/vnd.google-apps.document',
|
|
287
|
-
'application/vnd.google-apps.spreadsheet',
|
|
288
|
-
'application/vnd.google-apps.presentation',
|
|
289
|
-
'application/vnd.google-apps.drawing',
|
|
290
|
-
]).optional().describe('Convert the upload into this native Google Workspace type on import (e.g. upload .md/.html/.docx/.txt with convertTo=...google-apps.document to get a real Google Doc). Source must be an importable format. Omit to store the file as-is.'),
|
|
308
|
+
convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('Convert the upload into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted). E.g. upload .md/.html/.docx/.txt with convertTo=document to get a real Google Doc. Source must be an importable format. Omit to store the file as-is.'),
|
|
291
309
|
parentFolderId: z.string().optional().describe('Parent folder ID (defaults to My Drive root)'),
|
|
292
310
|
},
|
|
293
311
|
}, async ({ account, localPath, filename, mimeType: mimeTypeArg, convertTo, parentFolderId }) => {
|
|
@@ -301,7 +319,7 @@ export function registerDriveTools(server) {
|
|
|
301
319
|
name: filename,
|
|
302
320
|
parents: parentFolderId ? [parentFolderId] : undefined,
|
|
303
321
|
// Setting a google-apps target type makes Drive convert the media on import.
|
|
304
|
-
...(convertTo ? { mimeType: convertTo } : {}),
|
|
322
|
+
...(convertTo ? { mimeType: resolveConvertTarget(convertTo) } : {}),
|
|
305
323
|
},
|
|
306
324
|
media: {
|
|
307
325
|
mimeType: resolvedMime,
|
|
@@ -323,14 +341,17 @@ export function registerDriveTools(server) {
|
|
|
323
341
|
inputSchema: {
|
|
324
342
|
account: accountEnum.describe('Google account alias'),
|
|
325
343
|
fileId: z.string().describe('Google Drive file ID'),
|
|
326
|
-
savePath: z.string().describe('Absolute
|
|
327
|
-
filename: z.string().describe('Filename to save as'),
|
|
344
|
+
savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename`'),
|
|
345
|
+
filename: z.string().optional().describe('Filename to save as (defaults to the file name in Drive)'),
|
|
328
346
|
},
|
|
329
347
|
}, async ({ account, fileId, savePath, filename }) => {
|
|
330
348
|
try {
|
|
331
349
|
const auth = await getClient(account);
|
|
332
350
|
const drive = driveClient({ version: 'v3', auth });
|
|
333
|
-
const
|
|
351
|
+
const name = filename
|
|
352
|
+
?? (await drive.files.get({ fileId, fields: 'name', supportsAllDrives: true })).data.name
|
|
353
|
+
?? fileId;
|
|
354
|
+
const dest = prepareLocalDest(savePath, name);
|
|
334
355
|
const res = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'stream' });
|
|
335
356
|
// pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
|
|
336
357
|
await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
|
|
@@ -349,14 +370,20 @@ export function registerDriveTools(server) {
|
|
|
349
370
|
account: accountEnum.describe('Google account alias'),
|
|
350
371
|
fileId: z.string().describe('Google Drive file ID'),
|
|
351
372
|
mimeType: z.string().describe('Target export MIME type (e.g. "application/pdf", "text/markdown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")'),
|
|
352
|
-
savePath: z.string().describe('Absolute
|
|
353
|
-
filename: z.string().describe('Filename to save as'),
|
|
373
|
+
savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename`'),
|
|
374
|
+
filename: z.string().optional().describe('Filename to save as (defaults to the Drive name plus the extension implied by mimeType)'),
|
|
354
375
|
},
|
|
355
376
|
}, async ({ account, fileId, mimeType: exportMime, savePath, filename }) => {
|
|
356
377
|
try {
|
|
357
378
|
const auth = await getClient(account);
|
|
358
379
|
const drive = driveClient({ version: 'v3', auth });
|
|
359
|
-
|
|
380
|
+
let name = filename;
|
|
381
|
+
if (!name) {
|
|
382
|
+
const meta = await drive.files.get({ fileId, fields: 'name', supportsAllDrives: true });
|
|
383
|
+
const ext = mime.extension(exportMime);
|
|
384
|
+
name = `${meta.data.name ?? fileId}${ext ? `.${ext}` : ''}`;
|
|
385
|
+
}
|
|
386
|
+
const dest = prepareLocalDest(savePath, name);
|
|
360
387
|
const res = await drive.files.export({ fileId, mimeType: exportMime }, { responseType: 'stream' });
|
|
361
388
|
// pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
|
|
362
389
|
await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
|
|
@@ -404,14 +431,9 @@ export function registerDriveTools(server) {
|
|
|
404
431
|
fileId: z.string().describe('Google Drive file ID'),
|
|
405
432
|
newName: z.string().optional().describe('New filename'),
|
|
406
433
|
newParentFolderId: z.string().optional().describe('Move to this folder'),
|
|
407
|
-
localPath: z.string().optional().describe('Replace file content with this local file'),
|
|
434
|
+
localPath: z.string().optional().describe('Replace file content with this local file (path on the machine running the server)'),
|
|
408
435
|
mimeType: z.string().optional().describe('MIME type of the replacement file (required if localPath is provided)'),
|
|
409
|
-
convertTo: z.enum(
|
|
410
|
-
'application/vnd.google-apps.document',
|
|
411
|
-
'application/vnd.google-apps.spreadsheet',
|
|
412
|
-
'application/vnd.google-apps.presentation',
|
|
413
|
-
'application/vnd.google-apps.drawing',
|
|
414
|
-
]).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import (e.g. replace a Google Doc body from a local .docx). Source must be an importable format.'),
|
|
436
|
+
convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted).'),
|
|
415
437
|
},
|
|
416
438
|
}, async ({ account, fileId, newName, newParentFolderId, localPath: localPathArg, mimeType: mimeTypeArg, convertTo }) => {
|
|
417
439
|
try {
|
|
@@ -437,7 +459,7 @@ export function registerDriveTools(server) {
|
|
|
437
459
|
body: await openLocalReadStream(localPathArg),
|
|
438
460
|
};
|
|
439
461
|
if (convertTo)
|
|
440
|
-
requestBody.mimeType = convertTo;
|
|
462
|
+
requestBody.mimeType = resolveConvertTarget(convertTo);
|
|
441
463
|
}
|
|
442
464
|
const res = await drive.files.update(params);
|
|
443
465
|
return {
|
package/dist/tools/gmail.js
CHANGED
|
@@ -665,7 +665,7 @@ export function registerGmailTools(server) {
|
|
|
665
665
|
messageId: z.string().describe('The Gmail message ID'),
|
|
666
666
|
attachmentId: z.string().describe('The attachment ID from gmail_read response'),
|
|
667
667
|
filename: z.string().describe('Filename to save as (e.g. report.xlsx)'),
|
|
668
|
-
savePath: z.string().describe('Absolute
|
|
668
|
+
savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server), e.g. /home/user/Downloads; the file name comes from `filename`'),
|
|
669
669
|
},
|
|
670
670
|
}, async ({ account, messageId, attachmentId, filename, savePath }) => {
|
|
671
671
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.7",
|
|
4
4
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|