mcp-google-multi 6.0.0-alpha.3 → 6.0.0-alpha.5
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/scope-catalog.d.ts +1 -0
- package/dist/scope-catalog.js +1 -1
- package/dist/tools/_errors.js +16 -0
- package/dist/tools/_local-files.d.ts +3 -0
- package/dist/tools/_local-files.js +29 -0
- package/dist/tools/drive.d.ts +1 -1
- package/dist/tools/drive.js +31 -11
- package/dist/tools/gmail.js +2 -2
- package/dist/tools/google-api.d.ts +4 -1
- package/dist/tools/google-api.js +27 -2
- package/package.json +1 -1
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -17,3 +17,4 @@ export declare const BUNDLE_ALIASES: Record<string, string>;
|
|
|
17
17
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
18
18
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
19
19
|
export declare function closestBundle(name: string): string | undefined;
|
|
20
|
+
export declare function editDistance(a: string, b: string): number;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -169,7 +169,7 @@ export function closestBundle(name) {
|
|
|
169
169
|
}
|
|
170
170
|
return best;
|
|
171
171
|
}
|
|
172
|
-
function editDistance(a, b) {
|
|
172
|
+
export function editDistance(a, b) {
|
|
173
173
|
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
174
174
|
for (let j = 1; j <= b.length; j++)
|
|
175
175
|
dp[0][j] = j;
|
package/dist/tools/_errors.js
CHANGED
|
@@ -20,6 +20,10 @@ const RETRIABLE_NET_CODES = new Set([
|
|
|
20
20
|
'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
|
|
21
21
|
]);
|
|
22
22
|
const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
|
|
23
|
+
// Local-filesystem syscall codes from caller-supplied paths (localPath/savePath).
|
|
24
|
+
// String codes, so they never collide with Google's numeric statuses; the
|
|
25
|
+
// network codes above are deliberately excluded.
|
|
26
|
+
const LOCAL_FS_CODES = new Set(['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR', 'EPERM', 'ELOOP', 'ENAMETOOLONG', 'ENOSPC']);
|
|
23
27
|
/** First known network code on the error or its cause chain (GaxiosError.cause
|
|
24
28
|
* -> FetchError; undici TypeError.cause -> AggregateError.errors). */
|
|
25
29
|
function netCodeOf(error) {
|
|
@@ -137,6 +141,18 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
|
|
|
137
141
|
return { error: 'upstream_error', message, retriable: true, account };
|
|
138
142
|
}
|
|
139
143
|
if (status === undefined) {
|
|
144
|
+
const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
|
|
145
|
+
if (fsCode) {
|
|
146
|
+
const p = typeof error?.path === 'string' ? ` "${error.path}"` : '';
|
|
147
|
+
return {
|
|
148
|
+
error: 'invalid_params',
|
|
149
|
+
message: `Cannot access local path${p}: ${fsCode}`,
|
|
150
|
+
hint: 'The path must exist on the machine running this server and be accessible to it. ' +
|
|
151
|
+
'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.',
|
|
152
|
+
retriable: false,
|
|
153
|
+
account,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
140
156
|
const netCode = netCodeOf(error);
|
|
141
157
|
if (netCode) {
|
|
142
158
|
return {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
// path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
|
|
4
|
+
export function prepareLocalDest(savePath, filename) {
|
|
5
|
+
const dest = path.join(savePath, path.basename(filename));
|
|
6
|
+
fs.mkdirSync(savePath, { recursive: true });
|
|
7
|
+
return dest;
|
|
8
|
+
}
|
|
9
|
+
// fs.createReadStream() reports an unopenable path as an async 'error' EVENT;
|
|
10
|
+
// with no listener attached, that single event kills the whole process — fatal
|
|
11
|
+
// for the shared HTTP transport. Opening the fd first turns the open-failure
|
|
12
|
+
// class (ENOENT/EACCES/...) into a normal rejection the caller's try/catch can
|
|
13
|
+
// map to an error envelope.
|
|
14
|
+
export async function openLocalReadStream(localPath) {
|
|
15
|
+
const handle = await fs.promises.open(localPath, 'r');
|
|
16
|
+
// open() succeeds on a directory; fail it here rather than as an async read error.
|
|
17
|
+
if ((await handle.stat()).isDirectory()) {
|
|
18
|
+
await handle.close();
|
|
19
|
+
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${localPath}'`), {
|
|
20
|
+
code: 'EISDIR',
|
|
21
|
+
path: localPath,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const stream = handle.createReadStream();
|
|
25
|
+
// Mid-read errors still reach the consumer through its own listeners; this
|
|
26
|
+
// one only closes the unhandled-'error' crash path.
|
|
27
|
+
stream.on('error', () => { });
|
|
28
|
+
return stream;
|
|
29
|
+
}
|
package/dist/tools/drive.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
-
export declare function
|
|
2
|
+
export declare function isTextualMime(mimeType: string): boolean;
|
|
3
3
|
export declare const DRIVE_QUERY_HINT: string;
|
|
4
4
|
export declare function normalizeDriveQuery(raw: string): string;
|
|
5
5
|
export declare function isDriveInvalidQuery(error: any): boolean;
|
package/dist/tools/drive.js
CHANGED
|
@@ -4,6 +4,7 @@ import { drive as driveClient } from '@googleapis/drive';
|
|
|
4
4
|
import { accountAliasSchema, getAccountSet } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError } from './_errors.js';
|
|
7
|
+
import { openLocalReadStream, prepareLocalDest } from './_local-files.js';
|
|
7
8
|
import { isAllowed, writeDisabledResult } from '../write-control.js';
|
|
8
9
|
import { capText } from '../trim.js';
|
|
9
10
|
import * as fs from 'fs';
|
|
@@ -24,6 +25,29 @@ const GOOGLE_WORKSPACE_TYPES = new Set([
|
|
|
24
25
|
'application/vnd.google-apps.presentation',
|
|
25
26
|
'application/vnd.google-apps.drawing',
|
|
26
27
|
]);
|
|
28
|
+
// drive_read inlines only textual content. Beyond text/*, RFC 6839 structured-
|
|
29
|
+
// syntax suffixes (+json/+xml/...) and a few bare application/* types are text
|
|
30
|
+
// in practice — image/svg+xml was the motivating false "binary" refusal.
|
|
31
|
+
const TEXTUAL_EXACT = new Set([
|
|
32
|
+
'application/json',
|
|
33
|
+
'application/xml',
|
|
34
|
+
'application/javascript',
|
|
35
|
+
'application/x-ndjson',
|
|
36
|
+
'application/yaml',
|
|
37
|
+
'application/x-yaml',
|
|
38
|
+
'application/sql',
|
|
39
|
+
'application/x-sh',
|
|
40
|
+
'application/csv',
|
|
41
|
+
]);
|
|
42
|
+
export function isTextualMime(mimeType) {
|
|
43
|
+
const bare = mimeType.split(';')[0].trim().toLowerCase();
|
|
44
|
+
if (bare.startsWith('text/'))
|
|
45
|
+
return true;
|
|
46
|
+
if (/\+(json|xml|yaml|toml|csv)$/.test(bare))
|
|
47
|
+
return true;
|
|
48
|
+
return TEXTUAL_EXACT.has(bare);
|
|
49
|
+
}
|
|
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.';
|
|
27
51
|
// Comment/Reply fields list — Drive API requires explicit `fields` on every call.
|
|
28
52
|
const COMMENT_BASE_FIELDS = 'id,kind,content,htmlContent,createdTime,modifiedTime,resolved,anchor,author,deleted,quotedFileContent';
|
|
29
53
|
const REPLY_SUBFIELDS = 'id,content,action,createdTime,modifiedTime,author,deleted';
|
|
@@ -31,12 +55,6 @@ const COMMENT_FIELDS = `${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS})`;
|
|
|
31
55
|
const COMMENT_LIST_FIELDS = `nextPageToken,comments(${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS}))`;
|
|
32
56
|
const REPLY_FIELDS = `kind,htmlContent,${REPLY_SUBFIELDS}`;
|
|
33
57
|
const REPLY_LIST_FIELDS = `nextPageToken,replies(${REPLY_FIELDS})`;
|
|
34
|
-
// path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
|
|
35
|
-
export function prepareLocalDest(savePath, filename) {
|
|
36
|
-
const dest = path.join(savePath, path.basename(filename));
|
|
37
|
-
fs.mkdirSync(savePath, { recursive: true });
|
|
38
|
-
return dest;
|
|
39
|
-
}
|
|
40
58
|
export const DRIVE_QUERY_HINT = "Drive search syntax: a plain keyword is treated as a full-text search, but a " +
|
|
41
59
|
"structured query needs an operator, e.g. \"name contains 'report'\", " +
|
|
42
60
|
"\"mimeType = 'application/pdf'\", or \"'me' in owners\". " +
|
|
@@ -131,7 +149,7 @@ export function registerDriveTools(server) {
|
|
|
131
149
|
});
|
|
132
150
|
server.registerTool('drive_read', {
|
|
133
151
|
_meta: { 'anthropic/maxResultSizeChars': 100_000 },
|
|
134
|
-
description: 'Read the content of a Google Drive file (returns up to maxChars characters per call; non-Google-native files over 2MB return too_large)',
|
|
152
|
+
description: 'Read the content of a Google Drive file: Workspace docs and textual types (text/*, JSON/XML/SVG and similar) inline; other binaries return error:binary (returns up to maxChars characters per call; non-Google-native files over 2MB return too_large)',
|
|
135
153
|
inputSchema: {
|
|
136
154
|
account: accountEnum.describe('Google account alias'),
|
|
137
155
|
fileId: z.string().describe('Google Drive file ID'),
|
|
@@ -184,6 +202,7 @@ export function registerDriveTools(server) {
|
|
|
184
202
|
name,
|
|
185
203
|
mimeType,
|
|
186
204
|
error: 'binary',
|
|
205
|
+
hint: BINARY_READ_HINT,
|
|
187
206
|
webViewLink,
|
|
188
207
|
}, null, 2),
|
|
189
208
|
}],
|
|
@@ -204,7 +223,7 @@ export function registerDriveTools(server) {
|
|
|
204
223
|
}],
|
|
205
224
|
};
|
|
206
225
|
}
|
|
207
|
-
if (mimeType
|
|
226
|
+
if (mimeType && isTextualMime(mimeType)) {
|
|
208
227
|
const downloaded = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'text' });
|
|
209
228
|
return respond(String(downloaded.data));
|
|
210
229
|
}
|
|
@@ -216,6 +235,7 @@ export function registerDriveTools(server) {
|
|
|
216
235
|
name,
|
|
217
236
|
mimeType,
|
|
218
237
|
error: 'binary',
|
|
238
|
+
hint: BINARY_READ_HINT,
|
|
219
239
|
webViewLink,
|
|
220
240
|
}, null, 2),
|
|
221
241
|
}],
|
|
@@ -275,7 +295,7 @@ export function registerDriveTools(server) {
|
|
|
275
295
|
const auth = await getClient(account);
|
|
276
296
|
const drive = driveClient({ version: 'v3', auth });
|
|
277
297
|
const resolvedMime = mimeTypeArg ?? (mime.lookup(localPath) || 'application/octet-stream');
|
|
278
|
-
const fileStream =
|
|
298
|
+
const fileStream = await openLocalReadStream(localPath);
|
|
279
299
|
const res = await drive.files.create({
|
|
280
300
|
requestBody: {
|
|
281
301
|
name: filename,
|
|
@@ -414,7 +434,7 @@ export function registerDriveTools(server) {
|
|
|
414
434
|
if (localPathArg) {
|
|
415
435
|
params.media = {
|
|
416
436
|
mimeType: mimeTypeArg ?? (mime.lookup(localPathArg) || 'application/octet-stream'),
|
|
417
|
-
body:
|
|
437
|
+
body: await openLocalReadStream(localPathArg),
|
|
418
438
|
};
|
|
419
439
|
if (convertTo)
|
|
420
440
|
requestBody.mimeType = convertTo;
|
|
@@ -1440,7 +1460,7 @@ async function downloadAndUpload(sourceDrive, targetDrive, fileId, sourceMime, p
|
|
|
1440
1460
|
},
|
|
1441
1461
|
media: {
|
|
1442
1462
|
mimeType: plan.kind === 'native' ? plan.exportMime : (sourceMime ?? 'application/octet-stream'),
|
|
1443
|
-
body:
|
|
1463
|
+
body: await openLocalReadStream(tmp),
|
|
1444
1464
|
},
|
|
1445
1465
|
supportsAllDrives: true,
|
|
1446
1466
|
fields: 'id,name,mimeType,webViewLink',
|
package/dist/tools/gmail.js
CHANGED
|
@@ -5,6 +5,7 @@ import { accountAliasSchema } from '../accounts.js';
|
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError, mapGoogleError } from './_errors.js';
|
|
7
7
|
import { buildReplyHeaders, composeRaw, renderMarkdown, htmlToMarkdown, HeaderInjectionError } from './gmail-mime.js';
|
|
8
|
+
import { prepareLocalDest } from './_local-files.js';
|
|
8
9
|
import addressparser from 'nodemailer/lib/addressparser/index.js';
|
|
9
10
|
import { lookup as lookupMime } from 'mime-types';
|
|
10
11
|
import { configDir } from '../config-file.js';
|
|
@@ -679,8 +680,7 @@ export function registerGmailTools(server) {
|
|
|
679
680
|
if (!data)
|
|
680
681
|
throw new Error('No attachment data returned');
|
|
681
682
|
const buffer = Buffer.from(data, 'base64url');
|
|
682
|
-
|
|
683
|
-
const fullPath = path.join(savePath, path.basename(filename));
|
|
683
|
+
const fullPath = prepareLocalDest(savePath, filename);
|
|
684
684
|
await fs.promises.writeFile(fullPath, buffer, { mode: 0o600 });
|
|
685
685
|
return {
|
|
686
686
|
content: [{ type: 'text', text: `Saved to ${fullPath} (${buffer.length} bytes)` }],
|
|
@@ -2,9 +2,12 @@ import type { ToolRegistry } from '../registry.js';
|
|
|
2
2
|
import { type Policy } from '../write-control.js';
|
|
3
3
|
import { getClient } from '../client.js';
|
|
4
4
|
import { type Toolsets } from '../toolsets.js';
|
|
5
|
-
import { type DiscoveryDeps } from '../discovery-client.js';
|
|
5
|
+
import { type DiscoveryDeps, type DiscoveryMethod } from '../discovery-client.js';
|
|
6
6
|
export interface EscapeDeps extends DiscoveryDeps {
|
|
7
7
|
getClientFn?: typeof getClient;
|
|
8
8
|
toolsets?: Toolsets;
|
|
9
9
|
}
|
|
10
|
+
/** Up to three closest known ids for the unknown_method did-you-mean hint;
|
|
11
|
+
* bounded distance so unrelated ids never masquerade as suggestions. */
|
|
12
|
+
export declare function nearestMethodIds(methodId: string, index: DiscoveryMethod[]): string[];
|
|
10
13
|
export declare function registerEscapeTools(registry: ToolRegistry, policy: Policy, deps?: EscapeDeps): void;
|
package/dist/tools/google-api.js
CHANGED
|
@@ -4,6 +4,7 @@ import { accountAliasSchema } from '../accounts.js';
|
|
|
4
4
|
import { getClient } from '../client.js';
|
|
5
5
|
import { coerceJson } from './_coerce.js';
|
|
6
6
|
import { getToolsets, toolsetEnabled } from '../toolsets.js';
|
|
7
|
+
import { editDistance } from '../scope-catalog.js';
|
|
7
8
|
import { executeApiMethod, jsonResult } from '../executor.js';
|
|
8
9
|
import { WORKSPACE_APIS, cudFromMethod, loadMethodIndex, searchMethods, } from '../discovery-client.js';
|
|
9
10
|
const accountEnum = accountAliasSchema.optional();
|
|
@@ -29,6 +30,17 @@ const SERVICE_FOR_ALIAS = {
|
|
|
29
30
|
admin_datatransfer: 'admin',
|
|
30
31
|
groupssettings: 'groupssettings',
|
|
31
32
|
};
|
|
33
|
+
/** Up to three closest known ids for the unknown_method did-you-mean hint;
|
|
34
|
+
* bounded distance so unrelated ids never masquerade as suggestions. */
|
|
35
|
+
export function nearestMethodIds(methodId, index) {
|
|
36
|
+
const maxDist = Math.max(3, Math.floor(methodId.length / 3));
|
|
37
|
+
return index
|
|
38
|
+
.map((m) => ({ id: m.id, d: editDistance(methodId.toLowerCase(), m.id.toLowerCase()) }))
|
|
39
|
+
.filter((x) => x.d <= maxDist)
|
|
40
|
+
.sort((a, b) => a.d - b.d)
|
|
41
|
+
.slice(0, 3)
|
|
42
|
+
.map((x) => x.id);
|
|
43
|
+
}
|
|
32
44
|
function describeMethod(m) {
|
|
33
45
|
return {
|
|
34
46
|
api: m.api,
|
|
@@ -127,12 +139,25 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
127
139
|
catch (err) {
|
|
128
140
|
return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true, account }, true);
|
|
129
141
|
}
|
|
130
|
-
|
|
142
|
+
let method = index.find((m) => m.id === methodId);
|
|
143
|
+
if (!method) {
|
|
144
|
+
// Some discovery docs keep a legacy id prefix (the searchconsole doc's
|
|
145
|
+
// methods are webmasters.*): when the caller prefixed with our api
|
|
146
|
+
// alias, retry under the doc's own prefix before failing.
|
|
147
|
+
const docPrefix = index[0]?.id.split('.')[0];
|
|
148
|
+
const [head, ...rest] = String(methodId).split('.');
|
|
149
|
+
if (docPrefix && head === api && head !== docPrefix && rest.length > 0) {
|
|
150
|
+
const swapped = [docPrefix, ...rest].join('.');
|
|
151
|
+
method = index.find((m) => m.id === swapped);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
131
154
|
if (!method) {
|
|
155
|
+
const near = nearestMethodIds(String(methodId), index);
|
|
132
156
|
return jsonResult({
|
|
133
157
|
error: 'unknown_method',
|
|
134
158
|
message: `No method "${methodId}" in ${api}.`,
|
|
135
|
-
hint: `
|
|
159
|
+
hint: `${near.length ? `Did you mean: ${near.join(', ')}? ` : ''}` +
|
|
160
|
+
`Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
|
|
136
161
|
retriable: false,
|
|
137
162
|
account,
|
|
138
163
|
}, true);
|
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.5",
|
|
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",
|