mcp-google-gdrive 0.1.0 → 2.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/README.md +172 -41
- package/SPEC.md +8 -6
- package/package.json +8 -3
- package/src/index.js +1040 -327
package/src/index.js
CHANGED
|
@@ -5,20 +5,27 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { google } from "googleapis";
|
|
7
7
|
import TurndownService from "turndown";
|
|
8
|
+
import yaml from "js-yaml";
|
|
8
9
|
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
+
import { join, resolve, dirname } from "node:path";
|
|
10
11
|
import { homedir } from "node:os";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
11
13
|
import { createInterface } from "node:readline";
|
|
12
14
|
|
|
13
15
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
14
16
|
|
|
15
|
-
const SCOPES = [
|
|
17
|
+
const SCOPES = [
|
|
18
|
+
"https://www.googleapis.com/auth/drive",
|
|
19
|
+
"https://www.googleapis.com/auth/documents",
|
|
20
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
21
|
+
"https://www.googleapis.com/auth/presentations",
|
|
22
|
+
];
|
|
23
|
+
|
|
16
24
|
const CONFIG_DIR = join(homedir(), ".config", "mcp-google-gdrive");
|
|
25
|
+
const TOKENS_DIR = join(CONFIG_DIR, "tokens");
|
|
17
26
|
const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
|
|
18
|
-
const
|
|
19
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
20
|
-
|
|
21
|
-
const DRIVE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
27
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
28
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
22
29
|
|
|
23
30
|
const turndown = new TurndownService({
|
|
24
31
|
headingStyle: "atx",
|
|
@@ -28,28 +35,40 @@ const turndown = new TurndownService({
|
|
|
28
35
|
strongDelimiter: "**",
|
|
29
36
|
});
|
|
30
37
|
|
|
31
|
-
// ─── Auth
|
|
38
|
+
// ─── Multi-Account Auth ──────────────────────────────────────────────────────
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
const authClients = new Map();
|
|
41
|
+
const apiClients = new Map();
|
|
35
42
|
|
|
36
|
-
async function
|
|
43
|
+
async function ensureConfigDirs() {
|
|
37
44
|
await mkdir(CONFIG_DIR, { recursive: true });
|
|
45
|
+
await mkdir(TOKENS_DIR, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadConfig() {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(await readFile(CONFIG_PATH, "utf-8"));
|
|
51
|
+
} catch {
|
|
52
|
+
return { active: "primary", accounts: {} };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function saveConfig(config) {
|
|
57
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
async function loadCredentials() {
|
|
41
61
|
try {
|
|
42
|
-
|
|
43
|
-
return JSON.parse(content);
|
|
62
|
+
return JSON.parse(await readFile(CREDENTIALS_PATH, "utf-8"));
|
|
44
63
|
} catch (err) {
|
|
45
64
|
if (err.code === "ENOENT") {
|
|
46
65
|
throw new Error(
|
|
47
66
|
`credentials.json not found at ${CREDENTIALS_PATH}\n` +
|
|
48
|
-
"Setup
|
|
67
|
+
"Setup:\n" +
|
|
49
68
|
"1. Go to https://console.cloud.google.com/\n" +
|
|
50
|
-
"2. Create a project and enable
|
|
69
|
+
"2. Create a project and enable Drive, Docs, Sheets, Slides APIs\n" +
|
|
51
70
|
"3. Create OAuth2 desktop credentials\n" +
|
|
52
|
-
"4. Download
|
|
71
|
+
"4. Download JSON and save to:\n" +
|
|
53
72
|
` ${CREDENTIALS_PATH}`
|
|
54
73
|
);
|
|
55
74
|
}
|
|
@@ -57,72 +76,84 @@ async function loadCredentials() {
|
|
|
57
76
|
}
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
|
|
79
|
+
function tokenPath(label) {
|
|
80
|
+
return join(TOKENS_DIR, `${validateLabel(label)}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadToken(label) {
|
|
61
84
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (err.code === "ENOENT") return null;
|
|
66
|
-
throw err;
|
|
85
|
+
return JSON.parse(await readFile(tokenPath(label), "utf-8"));
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
67
88
|
}
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
async function
|
|
71
|
-
await writeFile(
|
|
72
|
-
await chmod(
|
|
91
|
+
async function saveTokenFile(label, token) {
|
|
92
|
+
await writeFile(tokenPath(label), JSON.stringify(token, null, 2));
|
|
93
|
+
await chmod(tokenPath(label), 0o600);
|
|
73
94
|
}
|
|
74
95
|
|
|
75
|
-
async function
|
|
76
|
-
await ensureConfigDir();
|
|
96
|
+
async function createOAuth2Client() {
|
|
77
97
|
const credentials = await loadCredentials();
|
|
78
|
-
|
|
79
98
|
const { client_id, client_secret, redirect_uris } =
|
|
80
99
|
credentials.installed || credentials.web;
|
|
81
|
-
|
|
82
|
-
const oauth2Client = new google.auth.OAuth2(
|
|
100
|
+
return new google.auth.OAuth2(
|
|
83
101
|
client_id,
|
|
84
102
|
client_secret,
|
|
85
103
|
redirect_uris?.[0] || "urn:ietf:wg:oauth:2.0:oob"
|
|
86
104
|
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function authorizeAccount(label) {
|
|
108
|
+
if (authClients.has(label)) return authClients.get(label);
|
|
109
|
+
|
|
110
|
+
const oauth2Client = await createOAuth2Client();
|
|
111
|
+
const token = await loadToken(label);
|
|
87
112
|
|
|
88
|
-
// Try loading existing token
|
|
89
|
-
const token = await loadToken();
|
|
90
113
|
if (!token) {
|
|
91
114
|
throw new Error(
|
|
92
|
-
`No token
|
|
93
|
-
` node src/index.js --auth\n
|
|
94
|
-
`This must be done before starting the MCP server.`
|
|
115
|
+
`No token found for account "${label}". Run:\n` +
|
|
116
|
+
` node src/index.js --auth ${label}\n`
|
|
95
117
|
);
|
|
96
118
|
}
|
|
97
119
|
|
|
98
120
|
oauth2Client.setCredentials(token);
|
|
99
121
|
|
|
100
|
-
// Auto-save on token refresh
|
|
101
122
|
oauth2Client.on("tokens", async (newTokens) => {
|
|
102
123
|
try {
|
|
103
|
-
const existing = await loadToken();
|
|
104
|
-
|
|
105
|
-
await saveToken(merged);
|
|
124
|
+
const existing = await loadToken(label) || {};
|
|
125
|
+
await saveTokenFile(label, { ...existing, ...newTokens });
|
|
106
126
|
} catch (err) {
|
|
107
|
-
process.stderr.write(`Warning: Failed to save refreshed token: ${err.message}\n`);
|
|
127
|
+
process.stderr.write(`Warning: Failed to save refreshed token for ${label}: ${err.message}\n`);
|
|
108
128
|
}
|
|
109
129
|
});
|
|
110
130
|
|
|
131
|
+
authClients.set(label, oauth2Client);
|
|
111
132
|
return oauth2Client;
|
|
112
133
|
}
|
|
113
134
|
|
|
114
|
-
async function
|
|
115
|
-
await
|
|
116
|
-
const
|
|
135
|
+
async function getClients(accountLabel) {
|
|
136
|
+
const config = await loadConfig();
|
|
137
|
+
const label = accountLabel || config.active || "primary";
|
|
117
138
|
|
|
118
|
-
|
|
119
|
-
credentials.installed || credentials.web;
|
|
139
|
+
if (apiClients.has(label)) return apiClients.get(label);
|
|
120
140
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
141
|
+
const auth = await authorizeAccount(label);
|
|
142
|
+
const clients = {
|
|
143
|
+
drive: google.drive({ version: "v3", auth }),
|
|
144
|
+
docs: google.docs({ version: "v1", auth }),
|
|
145
|
+
sheets: google.sheets({ version: "v4", auth }),
|
|
146
|
+
slides: google.slides({ version: "v1", auth }),
|
|
147
|
+
label,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
apiClients.set(label, clients);
|
|
151
|
+
return clients;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function interactiveAuth(label) {
|
|
155
|
+
await ensureConfigDirs();
|
|
156
|
+
const oauth2Client = await createOAuth2Client();
|
|
126
157
|
|
|
127
158
|
const authUrl = oauth2Client.generateAuthUrl({
|
|
128
159
|
access_type: "offline",
|
|
@@ -130,16 +161,12 @@ async function interactiveAuth() {
|
|
|
130
161
|
prompt: "consent",
|
|
131
162
|
});
|
|
132
163
|
|
|
133
|
-
process.stderr.write(
|
|
164
|
+
process.stderr.write(`\n═══ Google Drive MCP — Authorization for "${label}" ═══\n\n`);
|
|
134
165
|
process.stderr.write("Open this URL in your browser:\n\n");
|
|
135
166
|
process.stderr.write(` ${authUrl}\n\n`);
|
|
136
167
|
process.stderr.write("After granting access, paste the authorization code below.\n\n");
|
|
137
168
|
|
|
138
|
-
const rl = createInterface({
|
|
139
|
-
input: process.stdin,
|
|
140
|
-
output: process.stderr,
|
|
141
|
-
});
|
|
142
|
-
|
|
169
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
143
170
|
const code = await new Promise((resolve) => {
|
|
144
171
|
rl.question("Authorization code: ", (answer) => {
|
|
145
172
|
rl.close();
|
|
@@ -148,15 +175,25 @@ async function interactiveAuth() {
|
|
|
148
175
|
});
|
|
149
176
|
|
|
150
177
|
const { tokens } = await oauth2Client.getToken(code);
|
|
151
|
-
await
|
|
178
|
+
await saveTokenFile(label, tokens);
|
|
152
179
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
180
|
+
oauth2Client.setCredentials(tokens);
|
|
181
|
+
const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
|
|
182
|
+
let email = label;
|
|
183
|
+
try {
|
|
184
|
+
const userInfo = await oauth2.userinfo.get();
|
|
185
|
+
email = userInfo.data.email || label;
|
|
186
|
+
} catch { /* non-critical */ }
|
|
187
|
+
|
|
188
|
+
const config = await loadConfig();
|
|
189
|
+
config.accounts[label] = { email };
|
|
190
|
+
if (!config.active || Object.keys(config.accounts).length === 1) {
|
|
191
|
+
config.active = label;
|
|
192
|
+
}
|
|
193
|
+
await saveConfig(config);
|
|
156
194
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return driveClient;
|
|
195
|
+
process.stderr.write(`\nAuthorization successful! Account "${label}" (${email}) saved.\n`);
|
|
196
|
+
process.stderr.write(`Token stored at: ${tokenPath(label)}\n\n`);
|
|
160
197
|
}
|
|
161
198
|
|
|
162
199
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -173,305 +210,981 @@ function errorResponse(message) {
|
|
|
173
210
|
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
174
211
|
}
|
|
175
212
|
|
|
176
|
-
|
|
213
|
+
function binaryResponse(data, label) {
|
|
214
|
+
const buf = Buffer.from(data);
|
|
215
|
+
return textResponse(`[${label}, ${buf.length} bytes]\nBase64:\n${buf.toString("base64")}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateLabel(label) {
|
|
219
|
+
if (/[\/\\.]/.test(label) || label.includes("..")) {
|
|
220
|
+
throw new Error(`Invalid account label: "${label}". Labels must not contain /, \\, or ..`);
|
|
221
|
+
}
|
|
222
|
+
return label;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sanitizeDriveId(id) {
|
|
226
|
+
if (id && !/^[a-zA-Z0-9_-]+$/.test(id)) {
|
|
227
|
+
throw new Error(`Invalid Drive ID format: "${id}"`);
|
|
228
|
+
}
|
|
229
|
+
return id;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function validateSavePath(savePath) {
|
|
233
|
+
const resolved = resolve(savePath);
|
|
234
|
+
if (resolved.includes("\0")) {
|
|
235
|
+
throw new Error("Invalid save path: null bytes not allowed");
|
|
236
|
+
}
|
|
237
|
+
return resolved;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function saveToDisk(data, savePath) {
|
|
241
|
+
const resolved = validateSavePath(savePath);
|
|
242
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
243
|
+
const buf = Buffer.from(data);
|
|
244
|
+
await writeFile(resolved, buf);
|
|
245
|
+
return { path: resolved, size: buf.length };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function handleBinaryExport(data, format, savePath) {
|
|
249
|
+
if (savePath) {
|
|
250
|
+
const result = await saveToDisk(data, savePath);
|
|
251
|
+
return jsonResponse({ exported: format.toUpperCase(), path: result.path, size: result.size });
|
|
252
|
+
}
|
|
253
|
+
return binaryResponse(data, format.toUpperCase());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function markdownToHtml(md) {
|
|
257
|
+
return md
|
|
258
|
+
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
|
|
259
|
+
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
|
260
|
+
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
|
|
261
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
262
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
263
|
+
.replace(/\n\n/g, "</p><p>")
|
|
264
|
+
.replace(/^/, "<p>").replace(/$/, "</p>");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Output Format ──────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const OUTPUT_FORMAT_SCHEMA = z.enum(["json", "yaml", "text"]).optional().default("json").describe("Output format: json (default), yaml, or text (compact human-readable)");
|
|
270
|
+
|
|
271
|
+
function dataToText(data) {
|
|
272
|
+
if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object") {
|
|
273
|
+
const keys = Object.keys(data[0]);
|
|
274
|
+
const widths = keys.map(k => k.length);
|
|
275
|
+
for (const row of data) { keys.forEach((k, i) => { const len = String(row[k] ?? "").length; if (len > widths[i]) widths[i] = len; }); }
|
|
276
|
+
const pad = (val, i) => String(val ?? "").padEnd(widths[i]);
|
|
277
|
+
const header = keys.map((k, i) => pad(k, i)).join(" ");
|
|
278
|
+
const sep = widths.map(w => "-".repeat(w)).join(" ");
|
|
279
|
+
const rows = data.map(row => keys.map((k, i) => pad(row[k], i)).join(" "));
|
|
280
|
+
return [header, sep, ...rows].join("\n");
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {
|
|
283
|
+
return data.map(row => (row || []).map(c => String(c ?? "")).join("\t")).join("\n");
|
|
284
|
+
}
|
|
285
|
+
if (typeof data === "object" && data !== null) {
|
|
286
|
+
return Object.entries(data).map(([k, v]) => {
|
|
287
|
+
if (Array.isArray(v) && v.length > 0 && typeof v[0] === "object") return `${k}:\n${dataToText(v).split("\n").map(l => " " + l).join("\n")}`;
|
|
288
|
+
if (Array.isArray(v)) return `${k}: ${v.join(", ")}`;
|
|
289
|
+
if (typeof v === "object" && v !== null) return `${k}: ${JSON.stringify(v)}`;
|
|
290
|
+
return `${k}: ${v}`;
|
|
291
|
+
}).join("\n");
|
|
292
|
+
}
|
|
293
|
+
return String(data);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function formatOutput(data, format) {
|
|
297
|
+
if (format === "yaml") return textResponse(yaml.dump(data, { lineWidth: 120, noRefs: true }));
|
|
298
|
+
if (format === "text") return textResponse(dataToText(data));
|
|
299
|
+
return jsonResponse(data);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const FILE_FIELDS = "id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink,owners,shared,description,trashed,starred";
|
|
177
303
|
const LIST_FIELDS = "id,name,mimeType,modifiedTime,size,parents";
|
|
178
304
|
|
|
179
305
|
// ─── Server ──────────────────────────────────────────────────────────────────
|
|
180
306
|
|
|
181
307
|
const server = new McpServer({
|
|
182
308
|
name: "mcp-google-gdrive",
|
|
183
|
-
version: "0.
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
server.registerTool(
|
|
189
|
-
"
|
|
190
|
-
{
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const drive = await getDrive();
|
|
212
|
-
|
|
213
|
-
let q = "trashed = false";
|
|
214
|
-
if (folderId) q += ` and '${folderId}' in parents`;
|
|
215
|
-
if (query) q += ` and ${query}`;
|
|
216
|
-
|
|
217
|
-
const res = await drive.files.list({
|
|
218
|
-
q,
|
|
219
|
-
pageSize: Math.min(pageSize || 20, 100),
|
|
220
|
-
pageToken: pageToken || undefined,
|
|
221
|
-
orderBy: orderBy || "modifiedTime desc",
|
|
222
|
-
fields: `nextPageToken,files(${LIST_FIELDS})`,
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
return jsonResponse({
|
|
226
|
-
files: res.data.files || [],
|
|
227
|
-
nextPageToken: res.data.nextPageToken || null,
|
|
228
|
-
resultCount: (res.data.files || []).length,
|
|
229
|
-
});
|
|
230
|
-
} catch (err) {
|
|
231
|
-
return errorResponse(err.message);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
// ─── Tool: gdrive_get_metadata ───────────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
server.registerTool(
|
|
239
|
-
"gdrive_get_metadata",
|
|
240
|
-
{
|
|
241
|
-
description:
|
|
242
|
-
"Get detailed metadata for a specific file in Google Drive by its file ID. " +
|
|
243
|
-
"Returns name, type, size, dates, parents, web link, and sharing status.",
|
|
244
|
-
inputSchema: {
|
|
245
|
-
fileId: z.string().describe("Google Drive file ID"),
|
|
246
|
-
},
|
|
247
|
-
annotations: {
|
|
248
|
-
readOnlyHint: true,
|
|
249
|
-
destructiveHint: false,
|
|
250
|
-
idempotentHint: true,
|
|
251
|
-
openWorldHint: true,
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
async ({ fileId }) => {
|
|
255
|
-
try {
|
|
256
|
-
const drive = await getDrive();
|
|
257
|
-
const res = await drive.files.get({
|
|
258
|
-
fileId,
|
|
259
|
-
fields: FILE_FIELDS,
|
|
260
|
-
});
|
|
261
|
-
return jsonResponse(res.data);
|
|
262
|
-
} catch (err) {
|
|
263
|
-
if (err.code === 404) {
|
|
264
|
-
return errorResponse(`File not found: ${fileId}. Verify the file ID exists and you have access.`);
|
|
309
|
+
version: "2.0.0",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ═══ ACCOUNT TOOLS ═══════════════════════════════════════════════════════════
|
|
313
|
+
|
|
314
|
+
server.registerTool("gdrive_list_accounts", {
|
|
315
|
+
description: "List all configured Google accounts with their labels and email addresses.",
|
|
316
|
+
inputSchema: {
|
|
317
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
318
|
+
},
|
|
319
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
320
|
+
}, async ({ outputFormat }) => {
|
|
321
|
+
try {
|
|
322
|
+
const config = await loadConfig();
|
|
323
|
+
const unresolved = Object.entries(config.accounts)
|
|
324
|
+
.filter(([, info]) => info.email === "migrated" || !info.email);
|
|
325
|
+
if (unresolved.length > 0) {
|
|
326
|
+
const results = await Promise.all(unresolved.map(async ([label]) => {
|
|
327
|
+
try {
|
|
328
|
+
const auth = await authorizeAccount(label);
|
|
329
|
+
const oauth2 = google.oauth2({ version: "v2", auth });
|
|
330
|
+
const userInfo = await oauth2.userinfo.get();
|
|
331
|
+
return { label, email: userInfo.data.email };
|
|
332
|
+
} catch { return null; }
|
|
333
|
+
}));
|
|
334
|
+
let configDirty = false;
|
|
335
|
+
for (const r of results) {
|
|
336
|
+
if (r?.email) { config.accounts[r.label].email = r.email; configDirty = true; }
|
|
265
337
|
}
|
|
266
|
-
|
|
338
|
+
if (configDirty) await saveConfig(config);
|
|
267
339
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"gdrive_read_file",
|
|
275
|
-
{
|
|
276
|
-
description:
|
|
277
|
-
"Read the content of a file from Google Drive. Automatically detects Google Workspace types: " +
|
|
278
|
-
"Docs are exported as Markdown, Sheets as CSV, Slides as plain text. " +
|
|
279
|
-
"Binary files under 10MB are returned as base64. Files over 10MB return metadata only.",
|
|
280
|
-
inputSchema: {
|
|
281
|
-
fileId: z.string().describe("Google Drive file ID"),
|
|
282
|
-
},
|
|
283
|
-
annotations: {
|
|
284
|
-
readOnlyHint: true,
|
|
285
|
-
destructiveHint: false,
|
|
286
|
-
idempotentHint: true,
|
|
287
|
-
openWorldHint: true,
|
|
288
|
-
},
|
|
289
|
-
},
|
|
290
|
-
async ({ fileId }) => {
|
|
291
|
-
try {
|
|
292
|
-
const drive = await getDrive();
|
|
293
|
-
|
|
294
|
-
// Get file metadata first
|
|
295
|
-
const meta = await drive.files.get({
|
|
296
|
-
fileId,
|
|
297
|
-
fields: "id,name,mimeType,size,webViewLink",
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
const { mimeType, name, size } = meta.data;
|
|
301
|
-
|
|
302
|
-
// Google Workspace types — export
|
|
303
|
-
if (mimeType === "application/vnd.google-apps.document") {
|
|
304
|
-
const res = await drive.files.export({
|
|
305
|
-
fileId,
|
|
306
|
-
mimeType: "text/html",
|
|
307
|
-
}, { responseType: "text" });
|
|
308
|
-
const markdown = turndown.turndown(res.data);
|
|
309
|
-
return textResponse(markdown);
|
|
310
|
-
}
|
|
340
|
+
const accounts = Object.entries(config.accounts).map(([label, info]) => ({
|
|
341
|
+
label, email: info.email, active: label === config.active,
|
|
342
|
+
}));
|
|
343
|
+
return formatOutput({ accounts, activeAccount: config.active }, outputFormat);
|
|
344
|
+
} catch (err) { return errorResponse(err.message); }
|
|
345
|
+
});
|
|
311
346
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
347
|
+
server.registerTool("gdrive_switch_account", {
|
|
348
|
+
description: "Set the active Google account for subsequent tool calls.",
|
|
349
|
+
inputSchema: {
|
|
350
|
+
account: z.string().describe("Account label to make active (from gdrive_list_accounts)"),
|
|
351
|
+
},
|
|
352
|
+
annotations: { readOnlyHint: false, openWorldHint: false },
|
|
353
|
+
}, async ({ account }) => {
|
|
354
|
+
try {
|
|
355
|
+
const config = await loadConfig();
|
|
356
|
+
if (!config.accounts[account]) {
|
|
357
|
+
return errorResponse(`Account "${account}" not found. Available: ${Object.keys(config.accounts).join(", ") || "none"}`);
|
|
358
|
+
}
|
|
359
|
+
config.active = account;
|
|
360
|
+
await saveConfig(config);
|
|
361
|
+
return jsonResponse({ active: account, email: config.accounts[account].email });
|
|
362
|
+
} catch (err) { return errorResponse(err.message); }
|
|
363
|
+
});
|
|
319
364
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
365
|
+
server.registerTool("gdrive_add_account", {
|
|
366
|
+
description: "Generate an authorization URL to add a new Google account.",
|
|
367
|
+
inputSchema: {
|
|
368
|
+
label: z.string().describe("Short label for this account (e.g., 'work', 'personal')"),
|
|
369
|
+
},
|
|
370
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
371
|
+
}, async ({ label }) => {
|
|
372
|
+
try {
|
|
373
|
+
await ensureConfigDirs();
|
|
374
|
+
const oauth2Client = await createOAuth2Client();
|
|
375
|
+
const authUrl = oauth2Client.generateAuthUrl({ access_type: "offline", scope: SCOPES, prompt: "consent" });
|
|
376
|
+
return textResponse(
|
|
377
|
+
`To add account "${label}", open this URL in your browser:\n\n${authUrl}\n\n` +
|
|
378
|
+
`After granting access, run:\n node src/index.js --auth ${label}\nand paste the authorization code.`
|
|
379
|
+
);
|
|
380
|
+
} catch (err) { return errorResponse(err.message); }
|
|
381
|
+
});
|
|
327
382
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
383
|
+
// ═══ FILE TOOLS ══════════════════════════════════════════════════════════════
|
|
384
|
+
|
|
385
|
+
server.registerTool("gdrive_list_files", {
|
|
386
|
+
description: "List files in Google Drive with optional search query and folder filtering.",
|
|
387
|
+
inputSchema: {
|
|
388
|
+
query: z.string().optional().describe("Drive search query (e.g., \"name contains 'report'\")"),
|
|
389
|
+
folderId: z.string().optional().describe("List files in this folder only"),
|
|
390
|
+
pageSize: z.number().optional().default(20).describe("Results per page (max 100, default: 20)"),
|
|
391
|
+
pageToken: z.string().optional().describe("Token for next page of results"),
|
|
392
|
+
orderBy: z.string().optional().default("modifiedTime desc").describe("Sort order"),
|
|
393
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
394
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
395
|
+
},
|
|
396
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
397
|
+
}, async ({ query, folderId, pageSize, pageToken, orderBy, outputFormat, account }) => {
|
|
398
|
+
try {
|
|
399
|
+
const { drive } = await getClients(account);
|
|
400
|
+
let q = "trashed = false";
|
|
401
|
+
if (folderId) q += ` and '${sanitizeDriveId(folderId)}' in parents`;
|
|
402
|
+
if (query) q += ` and ${query}`;
|
|
403
|
+
const res = await drive.files.list({
|
|
404
|
+
q, pageSize: Math.min(pageSize || 20, 100), pageToken: pageToken || undefined,
|
|
405
|
+
orderBy: orderBy || "modifiedTime desc", fields: `nextPageToken,files(${LIST_FIELDS})`,
|
|
406
|
+
supportsAllDrives: true, includeItemsFromAllDrives: true,
|
|
407
|
+
});
|
|
408
|
+
return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null, resultCount: (res.data.files || []).length }, outputFormat);
|
|
409
|
+
} catch (err) { return errorResponse(err.message); }
|
|
410
|
+
});
|
|
331
411
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
412
|
+
server.registerTool("gdrive_get_metadata", {
|
|
413
|
+
description: "Get detailed metadata for a specific file in Google Drive.",
|
|
414
|
+
inputSchema: {
|
|
415
|
+
fileId: z.string().describe("Google Drive file ID"),
|
|
416
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
417
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
418
|
+
},
|
|
419
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
420
|
+
}, async ({ fileId, outputFormat, account }) => {
|
|
421
|
+
try {
|
|
422
|
+
const { drive } = await getClients(account);
|
|
423
|
+
const res = await drive.files.get({ fileId, fields: FILE_FIELDS, supportsAllDrives: true });
|
|
424
|
+
return formatOutput(res.data, outputFormat);
|
|
425
|
+
} catch (err) { return errorResponse(err.code === 404 ? `File not found: ${fileId}` : err.message); }
|
|
426
|
+
});
|
|
335
427
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
428
|
+
server.registerTool("gdrive_read_file", {
|
|
429
|
+
description: "Read file content. Auto-detects Workspace types: Docs→Markdown, Sheets→CSV, Slides→text. Binary under 10MB as base64.",
|
|
430
|
+
inputSchema: {
|
|
431
|
+
fileId: z.string().describe("Google Drive file ID"),
|
|
432
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
433
|
+
},
|
|
434
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
435
|
+
}, async ({ fileId, account }) => {
|
|
436
|
+
try {
|
|
437
|
+
const { drive } = await getClients(account);
|
|
438
|
+
const meta = await drive.files.get({ fileId, fields: "id,name,mimeType,size,webViewLink", supportsAllDrives: true });
|
|
439
|
+
const { mimeType, name, size } = meta.data;
|
|
348
440
|
|
|
349
|
-
|
|
350
|
-
const res = await drive.files.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
441
|
+
if (mimeType === "application/vnd.google-apps.document") {
|
|
442
|
+
const res = await drive.files.export({ fileId, mimeType: "text/html" }, { responseType: "text" });
|
|
443
|
+
return textResponse(turndown.turndown(res.data));
|
|
444
|
+
}
|
|
445
|
+
if (mimeType === "application/vnd.google-apps.spreadsheet") {
|
|
446
|
+
const res = await drive.files.export({ fileId, mimeType: "text/csv" }, { responseType: "text" });
|
|
447
|
+
return textResponse(res.data);
|
|
448
|
+
}
|
|
449
|
+
if (mimeType === "application/vnd.google-apps.presentation") {
|
|
450
|
+
const res = await drive.files.export({ fileId, mimeType: "text/plain" }, { responseType: "text" });
|
|
451
|
+
return textResponse(res.data);
|
|
452
|
+
}
|
|
453
|
+
if (mimeType?.startsWith("application/vnd.google-apps.")) {
|
|
454
|
+
return errorResponse(`Unsupported Workspace type: ${mimeType}. File: ${name}`);
|
|
455
|
+
}
|
|
354
456
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
457
|
+
const fileSize = parseInt(size, 10) || 0;
|
|
458
|
+
if (fileSize > MAX_FILE_SIZE) {
|
|
459
|
+
return jsonResponse({ warning: "File exceeds 10MB. Metadata only.", name, mimeType, size: fileSize, webViewLink: meta.data.webViewLink });
|
|
460
|
+
}
|
|
358
461
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
462
|
+
const res = await drive.files.get({ fileId, alt: "media" }, { responseType: mimeType?.startsWith("text/") ? "text" : "arraybuffer" });
|
|
463
|
+
if (mimeType?.startsWith("text/")) return textResponse(res.data);
|
|
464
|
+
return binaryResponse(res.data, `Binary: ${name} (${mimeType})`);
|
|
465
|
+
} catch (err) { return errorResponse(err.code === 404 ? `File not found: ${fileId}` : err.message); }
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
server.registerTool("gdrive_download_file", {
|
|
469
|
+
description: "Download a file from Google Drive to a local disk path. For Workspace files (Docs/Sheets/Slides), exports to the specified format first.",
|
|
470
|
+
inputSchema: {
|
|
471
|
+
fileId: z.string().describe("Google Drive file ID"),
|
|
472
|
+
savePath: z.string().describe("Local file path to save to (absolute or relative)"),
|
|
473
|
+
exportFormat: z.enum(["pdf", "docx", "xlsx", "pptx", "csv", "txt", "html", "markdown"]).optional().describe("Export format for Workspace files (default: pdf for Docs/Slides, csv for Sheets)"),
|
|
474
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
475
|
+
},
|
|
476
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
477
|
+
}, async ({ fileId, savePath, exportFormat, account }) => {
|
|
478
|
+
try {
|
|
479
|
+
const { drive } = await getClients(account);
|
|
480
|
+
const meta = await drive.files.get({ fileId, fields: "id,name,mimeType,size", supportsAllDrives: true });
|
|
481
|
+
const { name, mimeType, size } = meta.data;
|
|
482
|
+
|
|
483
|
+
const workspaceExportMap = {
|
|
484
|
+
"application/vnd.google-apps.document": { default: "pdf", mimeTypes: { pdf: "application/pdf", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", txt: "text/plain", html: "text/html", markdown: "text/html" } },
|
|
485
|
+
"application/vnd.google-apps.spreadsheet": { default: "csv", mimeTypes: { csv: "text/csv", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", pdf: "application/pdf" } },
|
|
486
|
+
"application/vnd.google-apps.presentation": { default: "pdf", mimeTypes: { pdf: "application/pdf", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", txt: "text/plain" } },
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const wsConfig = workspaceExportMap[mimeType];
|
|
490
|
+
let data;
|
|
491
|
+
|
|
492
|
+
if (wsConfig) {
|
|
493
|
+
const fmt = exportFormat || wsConfig.default;
|
|
494
|
+
const exportMime = wsConfig.mimeTypes[fmt];
|
|
495
|
+
if (!exportMime) return errorResponse(`Unsupported export format "${fmt}" for ${mimeType}. Available: ${Object.keys(wsConfig.mimeTypes).join(", ")}`);
|
|
496
|
+
const res = await drive.files.export({ fileId, mimeType: exportMime }, { responseType: "arraybuffer" });
|
|
497
|
+
data = res.data;
|
|
498
|
+
} else {
|
|
499
|
+
const res = await drive.files.get({ fileId, alt: "media", supportsAllDrives: true }, { responseType: "arraybuffer" });
|
|
500
|
+
data = res.data;
|
|
367
501
|
}
|
|
368
|
-
}
|
|
369
|
-
);
|
|
370
|
-
|
|
371
|
-
// ─── Tool: gdrive_create_file ────────────────────────────────────────────────
|
|
372
|
-
|
|
373
|
-
server.registerTool(
|
|
374
|
-
"gdrive_create_file",
|
|
375
|
-
{
|
|
376
|
-
description:
|
|
377
|
-
"Create a new file in Google Drive with the specified name and content. " +
|
|
378
|
-
"Optionally specify a parent folder and MIME type.",
|
|
379
|
-
inputSchema: {
|
|
380
|
-
name: z.string().describe("File name including extension (e.g., 'notes.txt')"),
|
|
381
|
-
content: z.string().describe("File content (text)"),
|
|
382
|
-
mimeType: z.string().optional().default("text/plain").describe("MIME type of content"),
|
|
383
|
-
parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
|
|
384
|
-
},
|
|
385
|
-
annotations: {
|
|
386
|
-
readOnlyHint: false,
|
|
387
|
-
destructiveHint: false,
|
|
388
|
-
idempotentHint: false,
|
|
389
|
-
openWorldHint: true,
|
|
390
|
-
},
|
|
391
|
-
},
|
|
392
|
-
async ({ name, content, mimeType, parentFolderId }) => {
|
|
393
|
-
try {
|
|
394
|
-
const drive = await getDrive();
|
|
395
502
|
|
|
396
|
-
|
|
397
|
-
|
|
503
|
+
const result = await saveToDisk(data, savePath);
|
|
504
|
+
return jsonResponse({ downloaded: name, format: exportFormat || (wsConfig ? wsConfig.default : mimeType), path: result.path, size: result.size });
|
|
505
|
+
} catch (err) { return errorResponse(err.message); }
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
server.registerTool("gdrive_create_file", {
|
|
509
|
+
description: "Create a new file in Google Drive. Set ocrLanguage to trigger OCR on image/PDF uploads.",
|
|
510
|
+
inputSchema: {
|
|
511
|
+
name: z.string().describe("File name including extension"),
|
|
512
|
+
content: z.string().describe("File content (text)"),
|
|
513
|
+
mimeType: z.string().optional().default("text/plain").describe("MIME type of content"),
|
|
514
|
+
parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
|
|
515
|
+
ocrLanguage: z.string().optional().describe("OCR language hint (e.g., 'en'). Triggers OCR on image/PDF uploads"),
|
|
516
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
517
|
+
},
|
|
518
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
519
|
+
}, async ({ name, content, mimeType, parentFolderId, ocrLanguage, account }) => {
|
|
520
|
+
try {
|
|
521
|
+
const { drive } = await getClients(account);
|
|
522
|
+
const metadata = { name };
|
|
523
|
+
if (parentFolderId) metadata.parents = [parentFolderId];
|
|
524
|
+
const params = { requestBody: metadata, media: { mimeType: mimeType || "text/plain", body: content }, fields: "id,name,mimeType,webViewLink", supportsAllDrives: true };
|
|
525
|
+
if (ocrLanguage) params.ocrLanguage = ocrLanguage;
|
|
526
|
+
const res = await drive.files.create(params);
|
|
527
|
+
return jsonResponse(res.data);
|
|
528
|
+
} catch (err) { return errorResponse(err.message); }
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
server.registerTool("gdrive_update_file", {
|
|
532
|
+
description: "Update an existing file's content, name, or description.",
|
|
533
|
+
inputSchema: {
|
|
534
|
+
fileId: z.string().describe("File ID to update"),
|
|
535
|
+
content: z.string().optional().describe("New file content"),
|
|
536
|
+
name: z.string().optional().describe("New file name"),
|
|
537
|
+
description: z.string().optional().describe("New file description"),
|
|
538
|
+
mimeType: z.string().optional().describe("MIME type of new content"),
|
|
539
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
540
|
+
},
|
|
541
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
542
|
+
}, async ({ fileId, content, name, description, mimeType, account }) => {
|
|
543
|
+
try {
|
|
544
|
+
const { drive } = await getClients(account);
|
|
545
|
+
const params = { fileId, supportsAllDrives: true, fields: "id,name,mimeType,modifiedTime,webViewLink" };
|
|
546
|
+
const metadata = {};
|
|
547
|
+
if (name) metadata.name = name;
|
|
548
|
+
if (description !== undefined) metadata.description = description;
|
|
549
|
+
if (Object.keys(metadata).length) params.requestBody = metadata;
|
|
550
|
+
if (content) params.media = { mimeType: mimeType || "text/plain", body: content };
|
|
551
|
+
const res = await drive.files.update(params);
|
|
552
|
+
return jsonResponse(res.data);
|
|
553
|
+
} catch (err) { return errorResponse(err.message); }
|
|
554
|
+
});
|
|
398
555
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
556
|
+
server.registerTool("gdrive_copy_file", {
|
|
557
|
+
description: "Copy a file, optionally to a different folder with a new name.",
|
|
558
|
+
inputSchema: {
|
|
559
|
+
fileId: z.string().describe("Source file ID"),
|
|
560
|
+
name: z.string().optional().describe("Name for the copy (default: 'Copy of {original}')"),
|
|
561
|
+
parentFolderId: z.string().optional().describe("Destination folder ID"),
|
|
562
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
563
|
+
},
|
|
564
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
565
|
+
}, async ({ fileId, name, parentFolderId, account }) => {
|
|
566
|
+
try {
|
|
567
|
+
const { drive } = await getClients(account);
|
|
568
|
+
const body = {};
|
|
569
|
+
if (name) body.name = name;
|
|
570
|
+
if (parentFolderId) body.parents = [parentFolderId];
|
|
571
|
+
const res = await drive.files.copy({ fileId, requestBody: body, fields: "id,name,mimeType,webViewLink", supportsAllDrives: true });
|
|
572
|
+
return jsonResponse(res.data);
|
|
573
|
+
} catch (err) { return errorResponse(err.message); }
|
|
574
|
+
});
|
|
403
575
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
576
|
+
server.registerTool("gdrive_move_file", {
|
|
577
|
+
description: "Move a file to a different folder.",
|
|
578
|
+
inputSchema: {
|
|
579
|
+
fileId: z.string().describe("File ID to move"),
|
|
580
|
+
destinationFolderId: z.string().describe("Target folder ID"),
|
|
581
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
582
|
+
},
|
|
583
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
584
|
+
}, async ({ fileId, destinationFolderId, account }) => {
|
|
585
|
+
try {
|
|
586
|
+
const { drive } = await getClients(account);
|
|
587
|
+
const file = await drive.files.get({ fileId, fields: "parents", supportsAllDrives: true });
|
|
588
|
+
const res = await drive.files.update({
|
|
589
|
+
fileId, addParents: destinationFolderId, removeParents: (file.data.parents || []).join(","),
|
|
590
|
+
fields: "id,name,parents,webViewLink", supportsAllDrives: true,
|
|
591
|
+
});
|
|
592
|
+
return jsonResponse(res.data);
|
|
593
|
+
} catch (err) { return errorResponse(err.message); }
|
|
594
|
+
});
|
|
409
595
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
596
|
+
server.registerTool("gdrive_search_files", {
|
|
597
|
+
description: "Full-text search across file names and content in Google Drive.",
|
|
598
|
+
inputSchema: {
|
|
599
|
+
text: z.string().describe("Search text (searches file names and content)"),
|
|
600
|
+
mimeType: z.string().optional().describe("Filter by MIME type (e.g., 'application/pdf')"),
|
|
601
|
+
modifiedAfter: z.string().optional().describe("ISO date — only files modified after this date"),
|
|
602
|
+
trashed: z.boolean().optional().default(false).describe("Include trashed files (default: false)"),
|
|
603
|
+
maxResults: z.number().optional().default(20).describe("Maximum results (default: 20)"),
|
|
604
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
605
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
606
|
+
},
|
|
607
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
608
|
+
}, async ({ text, mimeType, modifiedAfter, trashed, maxResults, outputFormat, account }) => {
|
|
609
|
+
try {
|
|
610
|
+
const { drive } = await getClients(account);
|
|
611
|
+
const parts = [`fullText contains '${text.replace(/'/g, "\\'")}'`];
|
|
612
|
+
if (!trashed) parts.push("trashed = false");
|
|
613
|
+
if (mimeType) parts.push(`mimeType = '${mimeType}'`);
|
|
614
|
+
if (modifiedAfter) parts.push(`modifiedTime > '${modifiedAfter}'`);
|
|
615
|
+
const res = await drive.files.list({
|
|
616
|
+
q: parts.join(" and "), pageSize: Math.min(maxResults || 20, 100),
|
|
617
|
+
fields: `files(${LIST_FIELDS},description)`,
|
|
618
|
+
supportsAllDrives: true, includeItemsFromAllDrives: true,
|
|
619
|
+
});
|
|
620
|
+
return formatOutput({ files: res.data.files || [], resultCount: (res.data.files || []).length }, outputFormat);
|
|
621
|
+
} catch (err) { return errorResponse(err.message); }
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ═══ FOLDER TOOLS ════════════════════════════════════════════════════════════
|
|
625
|
+
|
|
626
|
+
server.registerTool("gdrive_create_folder", {
|
|
627
|
+
description: "Create a new folder in Google Drive.",
|
|
628
|
+
inputSchema: {
|
|
629
|
+
name: z.string().describe("Folder name"),
|
|
630
|
+
parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
|
|
631
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
632
|
+
},
|
|
633
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
634
|
+
}, async ({ name, parentFolderId, account }) => {
|
|
635
|
+
try {
|
|
636
|
+
const { drive } = await getClients(account);
|
|
637
|
+
const metadata = { name, mimeType: "application/vnd.google-apps.folder" };
|
|
638
|
+
if (parentFolderId) metadata.parents = [parentFolderId];
|
|
639
|
+
const res = await drive.files.create({ requestBody: metadata, fields: "id,name,mimeType,webViewLink", supportsAllDrives: true });
|
|
640
|
+
return jsonResponse(res.data);
|
|
641
|
+
} catch (err) { return errorResponse(err.message); }
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
server.registerTool("gdrive_list_folder", {
|
|
645
|
+
description: "List contents of a specific folder.",
|
|
646
|
+
inputSchema: {
|
|
647
|
+
folderId: z.string().describe("Folder ID to list contents of"),
|
|
648
|
+
pageSize: z.number().optional().default(50).describe("Results per page (max 100, default: 50)"),
|
|
649
|
+
pageToken: z.string().optional().describe("Token for next page"),
|
|
650
|
+
orderBy: z.string().optional().default("folder,name").describe("Sort order (default: folders first, then by name)"),
|
|
651
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
652
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
653
|
+
},
|
|
654
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
655
|
+
}, async ({ folderId, pageSize, pageToken, orderBy, outputFormat, account }) => {
|
|
656
|
+
try {
|
|
657
|
+
const { drive } = await getClients(account);
|
|
658
|
+
const res = await drive.files.list({
|
|
659
|
+
q: `'${sanitizeDriveId(folderId)}' in parents and trashed = false`,
|
|
660
|
+
pageSize: Math.min(pageSize || 50, 100), pageToken: pageToken || undefined,
|
|
661
|
+
orderBy: orderBy || "folder,name", fields: `nextPageToken,files(${LIST_FIELDS})`,
|
|
662
|
+
supportsAllDrives: true, includeItemsFromAllDrives: true,
|
|
663
|
+
});
|
|
664
|
+
return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null }, outputFormat);
|
|
665
|
+
} catch (err) { return errorResponse(err.message); }
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
server.registerTool("gdrive_delete_folder", {
|
|
669
|
+
description: "Delete a folder (move to trash or permanently delete).",
|
|
670
|
+
inputSchema: {
|
|
671
|
+
folderId: z.string().describe("Folder ID to delete"),
|
|
672
|
+
permanent: z.boolean().optional().default(false).describe("true = permanent delete, false = move to trash (default)"),
|
|
673
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
674
|
+
},
|
|
675
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
676
|
+
}, async ({ folderId, permanent, account }) => {
|
|
677
|
+
try {
|
|
678
|
+
const { drive } = await getClients(account);
|
|
679
|
+
if (permanent) {
|
|
680
|
+
await drive.files.delete({ fileId: folderId, supportsAllDrives: true });
|
|
681
|
+
return textResponse(`Permanently deleted folder ${folderId}`);
|
|
416
682
|
}
|
|
417
|
-
|
|
418
|
-
);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
server.registerTool(
|
|
423
|
-
"gdrive_create_folder",
|
|
424
|
-
{
|
|
425
|
-
description:
|
|
426
|
-
"Create a new folder in Google Drive. Optionally specify a parent folder.",
|
|
427
|
-
inputSchema: {
|
|
428
|
-
name: z.string().describe("Folder name"),
|
|
429
|
-
parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
|
|
430
|
-
},
|
|
431
|
-
annotations: {
|
|
432
|
-
readOnlyHint: false,
|
|
433
|
-
destructiveHint: false,
|
|
434
|
-
idempotentHint: false,
|
|
435
|
-
openWorldHint: true,
|
|
436
|
-
},
|
|
437
|
-
},
|
|
438
|
-
async ({ name, parentFolderId }) => {
|
|
439
|
-
try {
|
|
440
|
-
const drive = await getDrive();
|
|
683
|
+
await drive.files.update({ fileId: folderId, requestBody: { trashed: true }, supportsAllDrives: true });
|
|
684
|
+
return textResponse(`Moved folder ${folderId} to trash`);
|
|
685
|
+
} catch (err) { return errorResponse(err.message); }
|
|
686
|
+
});
|
|
441
687
|
|
|
442
|
-
|
|
443
|
-
name,
|
|
444
|
-
mimeType: "application/vnd.google-apps.folder",
|
|
445
|
-
};
|
|
446
|
-
if (parentFolderId) fileMetadata.parents = [parentFolderId];
|
|
688
|
+
// ═══ TRASH TOOLS ═════════════════════════════════════════════════════════════
|
|
447
689
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
690
|
+
server.registerTool("gdrive_trash_file", {
|
|
691
|
+
description: "Move a file or folder to trash.",
|
|
692
|
+
inputSchema: {
|
|
693
|
+
fileId: z.string().describe("File or folder ID to trash"),
|
|
694
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
695
|
+
},
|
|
696
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
697
|
+
}, async ({ fileId, account }) => {
|
|
698
|
+
try {
|
|
699
|
+
const { drive } = await getClients(account);
|
|
700
|
+
const res = await drive.files.update({ fileId, requestBody: { trashed: true }, fields: "id,name,trashed", supportsAllDrives: true });
|
|
701
|
+
return jsonResponse(res.data);
|
|
702
|
+
} catch (err) { return errorResponse(err.message); }
|
|
703
|
+
});
|
|
452
704
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
705
|
+
server.registerTool("gdrive_untrash_file", {
|
|
706
|
+
description: "Restore a file or folder from trash.",
|
|
707
|
+
inputSchema: {
|
|
708
|
+
fileId: z.string().describe("File or folder ID to restore"),
|
|
709
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
710
|
+
},
|
|
711
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
712
|
+
}, async ({ fileId, account }) => {
|
|
713
|
+
try {
|
|
714
|
+
const { drive } = await getClients(account);
|
|
715
|
+
const res = await drive.files.update({ fileId, requestBody: { trashed: false }, fields: "id,name,trashed", supportsAllDrives: true });
|
|
716
|
+
return jsonResponse(res.data);
|
|
717
|
+
} catch (err) { return errorResponse(err.message); }
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
server.registerTool("gdrive_empty_trash", {
|
|
721
|
+
description: "Permanently delete ALL files in trash. Irreversible. Note: Google returns success before files are fully purged (eventual consistency — files may still appear briefly in trash listings).",
|
|
722
|
+
inputSchema: {
|
|
723
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
724
|
+
},
|
|
725
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
726
|
+
}, async ({ account }) => {
|
|
727
|
+
try {
|
|
728
|
+
const { drive } = await getClients(account);
|
|
729
|
+
await drive.files.emptyTrash({ supportsAllDrives: true });
|
|
730
|
+
return textResponse("Trash emptied. All trashed files permanently deleted.");
|
|
731
|
+
} catch (err) { return errorResponse(err.message); }
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
server.registerTool("gdrive_list_trash", {
|
|
735
|
+
description: "List files currently in trash.",
|
|
736
|
+
inputSchema: {
|
|
737
|
+
pageSize: z.number().optional().default(20).describe("Results per page (max 100, default: 20)"),
|
|
738
|
+
pageToken: z.string().optional().describe("Token for next page"),
|
|
739
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
740
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
741
|
+
},
|
|
742
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
743
|
+
}, async ({ pageSize, pageToken, outputFormat, account }) => {
|
|
744
|
+
try {
|
|
745
|
+
const { drive } = await getClients(account);
|
|
746
|
+
const res = await drive.files.list({
|
|
747
|
+
q: "trashed = true", pageSize: Math.min(pageSize || 20, 100),
|
|
748
|
+
pageToken: pageToken || undefined, fields: `nextPageToken,files(${LIST_FIELDS},trashedTime)`, orderBy: "modifiedTime desc",
|
|
749
|
+
supportsAllDrives: true, includeItemsFromAllDrives: true,
|
|
750
|
+
});
|
|
751
|
+
return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null }, outputFormat);
|
|
752
|
+
} catch (err) { return errorResponse(err.message); }
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// ═══ PERMISSION TOOLS ════════════════════════════════════════════════════════
|
|
756
|
+
|
|
757
|
+
server.registerTool("gdrive_share_file", {
|
|
758
|
+
description: "Share a file or folder with a user, group, domain, or anyone.",
|
|
759
|
+
inputSchema: {
|
|
760
|
+
fileId: z.string().describe("File or folder ID to share"),
|
|
761
|
+
role: z.enum(["reader", "commenter", "writer", "organizer"]).describe("Permission role"),
|
|
762
|
+
type: z.enum(["user", "group", "domain", "anyone"]).optional().default("user").describe("Permission type (default: user)"),
|
|
763
|
+
email: z.string().optional().describe("Email address (required for type=user or type=group)"),
|
|
764
|
+
domain: z.string().optional().describe("Domain (required for type=domain)"),
|
|
765
|
+
sendNotification: z.boolean().optional().default(true).describe("Send email notification (default: true)"),
|
|
766
|
+
message: z.string().optional().describe("Custom message in the notification email"),
|
|
767
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
768
|
+
},
|
|
769
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
770
|
+
}, async ({ fileId, role, type, email, domain, sendNotification, message, account }) => {
|
|
771
|
+
try {
|
|
772
|
+
const { drive } = await getClients(account);
|
|
773
|
+
const permission = { role, type };
|
|
774
|
+
if (email) permission.emailAddress = email;
|
|
775
|
+
if (domain) permission.domain = domain;
|
|
776
|
+
const res = await drive.permissions.create({
|
|
777
|
+
fileId, requestBody: permission, sendNotificationEmail: sendNotification,
|
|
778
|
+
emailMessage: message || undefined, fields: "id,role,type,emailAddress,domain", supportsAllDrives: true,
|
|
779
|
+
});
|
|
780
|
+
return jsonResponse(res.data);
|
|
781
|
+
} catch (err) { return errorResponse(err.message); }
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
server.registerTool("gdrive_list_permissions", {
|
|
785
|
+
description: "List all permissions (sharing) on a file or folder.",
|
|
786
|
+
inputSchema: {
|
|
787
|
+
fileId: z.string().describe("File or folder ID"),
|
|
788
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
789
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
790
|
+
},
|
|
791
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
792
|
+
}, async ({ fileId, outputFormat, account }) => {
|
|
793
|
+
try {
|
|
794
|
+
const { drive } = await getClients(account);
|
|
795
|
+
const res = await drive.permissions.list({
|
|
796
|
+
fileId, fields: "permissions(id,role,type,emailAddress,domain,displayName)", supportsAllDrives: true,
|
|
797
|
+
});
|
|
798
|
+
return formatOutput({ permissions: res.data.permissions || [] }, outputFormat);
|
|
799
|
+
} catch (err) { return errorResponse(err.message); }
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
server.registerTool("gdrive_update_permission", {
|
|
803
|
+
description: "Update a permission's role on a file or folder.",
|
|
804
|
+
inputSchema: {
|
|
805
|
+
fileId: z.string().describe("File or folder ID"),
|
|
806
|
+
permissionId: z.string().describe("Permission ID (from gdrive_list_permissions)"),
|
|
807
|
+
role: z.enum(["reader", "commenter", "writer", "organizer"]).describe("New role"),
|
|
808
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
809
|
+
},
|
|
810
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
811
|
+
}, async ({ fileId, permissionId, role, account }) => {
|
|
812
|
+
try {
|
|
813
|
+
const { drive } = await getClients(account);
|
|
814
|
+
const res = await drive.permissions.update({
|
|
815
|
+
fileId, permissionId, requestBody: { role }, fields: "id,role,type,emailAddress", supportsAllDrives: true,
|
|
816
|
+
});
|
|
817
|
+
return jsonResponse(res.data);
|
|
818
|
+
} catch (err) { return errorResponse(err.message); }
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
server.registerTool("gdrive_remove_permission", {
|
|
822
|
+
description: "Remove a permission (unshare) from a file or folder.",
|
|
823
|
+
inputSchema: {
|
|
824
|
+
fileId: z.string().describe("File or folder ID"),
|
|
825
|
+
permissionId: z.string().describe("Permission ID to remove"),
|
|
826
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
827
|
+
},
|
|
828
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
829
|
+
}, async ({ fileId, permissionId, account }) => {
|
|
830
|
+
try {
|
|
831
|
+
const { drive } = await getClients(account);
|
|
832
|
+
await drive.permissions.delete({ fileId, permissionId, supportsAllDrives: true });
|
|
833
|
+
return textResponse(`Permission ${permissionId} removed from ${fileId}`);
|
|
834
|
+
} catch (err) { return errorResponse(err.message); }
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
server.registerTool("gdrive_transfer_ownership", {
|
|
838
|
+
description: "Transfer ownership of a file to another user. Note: cross-organization transfers (e.g., personal to Workspace) are blocked by Google.",
|
|
839
|
+
inputSchema: {
|
|
840
|
+
fileId: z.string().describe("File ID to transfer"),
|
|
841
|
+
email: z.string().describe("New owner's email address"),
|
|
842
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
843
|
+
},
|
|
844
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
845
|
+
}, async ({ fileId, email, account }) => {
|
|
846
|
+
try {
|
|
847
|
+
const { drive } = await getClients(account);
|
|
848
|
+
const res = await drive.permissions.create({
|
|
849
|
+
fileId, requestBody: { role: "owner", type: "user", emailAddress: email },
|
|
850
|
+
transferOwnership: true, fields: "id,role,type,emailAddress", supportsAllDrives: true,
|
|
851
|
+
});
|
|
852
|
+
return jsonResponse(res.data);
|
|
853
|
+
} catch (err) { return errorResponse(err.message); }
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// ═══ LABEL TOOLS ═════════════════════════════════════════════════════════════
|
|
857
|
+
|
|
858
|
+
server.registerTool("gdrive_list_labels", {
|
|
859
|
+
description: "List available Drive labels that can be applied to files.",
|
|
860
|
+
inputSchema: {
|
|
861
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
862
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
863
|
+
},
|
|
864
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
865
|
+
}, async ({ outputFormat, account }) => {
|
|
866
|
+
try {
|
|
867
|
+
const config = await loadConfig();
|
|
868
|
+
const label = account || config.active || "primary";
|
|
869
|
+
const auth = await authorizeAccount(label);
|
|
870
|
+
const labels = google.drivelabels({ version: "v2", auth });
|
|
871
|
+
const res = await labels.labels.list({ publishedOnly: true, view: "LABEL_VIEW_FULL" });
|
|
872
|
+
return formatOutput({ labels: res.data.labels || [] }, outputFormat);
|
|
873
|
+
} catch (err) { return errorResponse(err.message); }
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
server.registerTool("gdrive_list_file_labels", {
|
|
877
|
+
description: "List labels currently applied to a file.",
|
|
878
|
+
inputSchema: {
|
|
879
|
+
fileId: z.string().describe("File ID"),
|
|
880
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
881
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
882
|
+
},
|
|
883
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
884
|
+
}, async ({ fileId, outputFormat, account }) => {
|
|
885
|
+
try {
|
|
886
|
+
const { drive } = await getClients(account);
|
|
887
|
+
const res = await drive.files.listLabels({ fileId });
|
|
888
|
+
return formatOutput({ labels: res.data.labels || [] }, outputFormat);
|
|
889
|
+
} catch (err) { return errorResponse(err.message); }
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
server.registerTool("gdrive_set_file_labels", {
|
|
893
|
+
description: "Add, update, or remove a label on a file.",
|
|
894
|
+
inputSchema: {
|
|
895
|
+
fileId: z.string().describe("File ID"),
|
|
896
|
+
labelId: z.string().describe("Label ID (from gdrive_list_labels)"),
|
|
897
|
+
fields: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional().describe("Field values to set (key: fieldId, value: field value)"),
|
|
898
|
+
remove: z.boolean().optional().default(false).describe("true = remove this label from the file"),
|
|
899
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
900
|
+
},
|
|
901
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
902
|
+
}, async ({ fileId, labelId, fields, remove, account }) => {
|
|
903
|
+
try {
|
|
904
|
+
const { drive } = await getClients(account);
|
|
905
|
+
const mod = { labelId };
|
|
906
|
+
if (remove) {
|
|
907
|
+
mod.removeLabel = true;
|
|
908
|
+
} else if (fields) {
|
|
909
|
+
mod.fieldModifications = Object.entries(fields).map(([fieldId, value]) => ({
|
|
910
|
+
fieldId, setValues: value !== null ? [value] : undefined, unsetValues: value === null ? true : undefined,
|
|
911
|
+
}));
|
|
459
912
|
}
|
|
460
|
-
|
|
461
|
-
);
|
|
913
|
+
const res = await drive.files.modifyLabels({ fileId, requestBody: { labelModifications: [mod] } });
|
|
914
|
+
return jsonResponse(res.data);
|
|
915
|
+
} catch (err) { return errorResponse(err.message); }
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// ═══ DOCS TOOLS ══════════════════════════════════════════════════════════════
|
|
919
|
+
|
|
920
|
+
server.registerTool("gdrive_export_doc", {
|
|
921
|
+
description: "Export a Google Doc. Default: markdown. Also: html, text, docx, pdf. Use savePath to save binary formats to disk.",
|
|
922
|
+
inputSchema: {
|
|
923
|
+
fileId: z.string().describe("Google Doc file ID"),
|
|
924
|
+
format: z.enum(["markdown", "html", "text", "docx", "pdf"]).optional().default("markdown").describe("Export format (default: markdown)"),
|
|
925
|
+
savePath: z.string().optional().describe("Local file path to save binary exports (docx/pdf). If omitted, returns base64."),
|
|
926
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
927
|
+
},
|
|
928
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
929
|
+
}, async ({ fileId, format, savePath, account }) => {
|
|
930
|
+
try {
|
|
931
|
+
const { drive } = await getClients(account);
|
|
932
|
+
const mimeMap = { markdown: "text/html", html: "text/html", text: "text/plain", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", pdf: "application/pdf" };
|
|
933
|
+
const isBinary = ["docx", "pdf"].includes(format);
|
|
934
|
+
const res = await drive.files.export({ fileId, mimeType: mimeMap[format] }, { responseType: isBinary ? "arraybuffer" : "text" });
|
|
935
|
+
if (format === "markdown") return textResponse(turndown.turndown(res.data));
|
|
936
|
+
if (isBinary) return handleBinaryExport(res.data, format, savePath);
|
|
937
|
+
return textResponse(res.data);
|
|
938
|
+
} catch (err) { return errorResponse(err.message); }
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
server.registerTool("gdrive_create_doc", {
|
|
942
|
+
description: "Create a new Google Doc from Markdown content.",
|
|
943
|
+
inputSchema: {
|
|
944
|
+
name: z.string().describe("Document title"),
|
|
945
|
+
markdownContent: z.string().describe("Markdown content to convert to Google Doc"),
|
|
946
|
+
parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
|
|
947
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
948
|
+
},
|
|
949
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
950
|
+
}, async ({ name, markdownContent, parentFolderId, account }) => {
|
|
951
|
+
try {
|
|
952
|
+
const { drive } = await getClients(account);
|
|
953
|
+
const metadata = { name, mimeType: "application/vnd.google-apps.document" };
|
|
954
|
+
if (parentFolderId) metadata.parents = [parentFolderId];
|
|
955
|
+
const res = await drive.files.create({
|
|
956
|
+
requestBody: metadata, media: { mimeType: "text/html", body: markdownToHtml(markdownContent) },
|
|
957
|
+
fields: "id,name,mimeType,webViewLink", supportsAllDrives: true,
|
|
958
|
+
});
|
|
959
|
+
return jsonResponse(res.data);
|
|
960
|
+
} catch (err) { return errorResponse(err.message); }
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
server.registerTool("gdrive_update_doc", {
|
|
964
|
+
description: "Replace a Google Doc's content with new Markdown.",
|
|
965
|
+
inputSchema: {
|
|
966
|
+
fileId: z.string().describe("Google Doc file ID"),
|
|
967
|
+
markdownContent: z.string().describe("New Markdown content (replaces entire document)"),
|
|
968
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
969
|
+
},
|
|
970
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
971
|
+
}, async ({ fileId, markdownContent, account }) => {
|
|
972
|
+
try {
|
|
973
|
+
const { drive } = await getClients(account);
|
|
974
|
+
const res = await drive.files.update({
|
|
975
|
+
fileId, media: { mimeType: "text/html", body: markdownToHtml(markdownContent) },
|
|
976
|
+
fields: "id,name,modifiedTime,webViewLink", supportsAllDrives: true,
|
|
977
|
+
});
|
|
978
|
+
return jsonResponse(res.data);
|
|
979
|
+
} catch (err) { return errorResponse(err.message); }
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// ═══ SHEETS TOOLS ════════════════════════════════════════════════════════════
|
|
983
|
+
|
|
984
|
+
server.registerTool("gdrive_create_sheet", {
|
|
985
|
+
description: "Create a new Google Spreadsheet.",
|
|
986
|
+
inputSchema: {
|
|
987
|
+
name: z.string().describe("Spreadsheet title"),
|
|
988
|
+
sheetNames: z.array(z.string()).optional().describe("Sheet/tab names (default: ['Sheet1'])"),
|
|
989
|
+
parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
|
|
990
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
991
|
+
},
|
|
992
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
993
|
+
}, async ({ name, sheetNames, parentFolderId, account }) => {
|
|
994
|
+
try {
|
|
995
|
+
const { sheets, drive } = await getClients(account);
|
|
996
|
+
const sheetProps = (sheetNames || ["Sheet1"]).map((title, i) => ({ properties: { title, index: i } }));
|
|
997
|
+
const res = await sheets.spreadsheets.create({ requestBody: { properties: { title: name }, sheets: sheetProps } });
|
|
998
|
+
if (parentFolderId) {
|
|
999
|
+
await drive.files.update({ fileId: res.data.spreadsheetId, addParents: parentFolderId, removeParents: "root", supportsAllDrives: true });
|
|
1000
|
+
}
|
|
1001
|
+
return jsonResponse({ spreadsheetId: res.data.spreadsheetId, title: res.data.properties.title, url: res.data.spreadsheetUrl });
|
|
1002
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
server.registerTool("gdrive_export_sheet", {
|
|
1006
|
+
description: "Export a Google Sheet to CSV, JSON, or XLSX. Use savePath to save XLSX to disk.",
|
|
1007
|
+
inputSchema: {
|
|
1008
|
+
fileId: z.string().describe("Google Sheet file ID"),
|
|
1009
|
+
format: z.enum(["csv", "json", "xlsx"]).optional().default("csv").describe("Export format (default: csv)"),
|
|
1010
|
+
sheetName: z.string().optional().describe("Specific sheet/tab name (default: first sheet). Only for csv/json."),
|
|
1011
|
+
savePath: z.string().optional().describe("Local file path to save XLSX export. If omitted, returns base64."),
|
|
1012
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
1013
|
+
},
|
|
1014
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1015
|
+
}, async ({ fileId, format, sheetName, savePath, account }) => {
|
|
1016
|
+
try {
|
|
1017
|
+
const { sheets, drive } = await getClients(account);
|
|
1018
|
+
if (format === "xlsx") {
|
|
1019
|
+
const res = await drive.files.export({ fileId, mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, { responseType: "arraybuffer" });
|
|
1020
|
+
return handleBinaryExport(res.data, "xlsx", savePath);
|
|
1021
|
+
}
|
|
1022
|
+
let rangeParam = sheetName;
|
|
1023
|
+
if (!rangeParam) {
|
|
1024
|
+
const meta = await sheets.spreadsheets.get({ spreadsheetId: fileId, fields: "sheets.properties" });
|
|
1025
|
+
rangeParam = meta.data.sheets?.[0]?.properties?.title || "Sheet1";
|
|
1026
|
+
}
|
|
1027
|
+
const res = await sheets.spreadsheets.values.get({ spreadsheetId: fileId, range: rangeParam, valueRenderOption: "FORMATTED_VALUE" });
|
|
1028
|
+
const rows = res.data.values || [];
|
|
1029
|
+
if (format === "json" && rows.length > 0) {
|
|
1030
|
+
const headers = rows[0];
|
|
1031
|
+
return jsonResponse(rows.slice(1).map(row => {
|
|
1032
|
+
const obj = {};
|
|
1033
|
+
headers.forEach((h, i) => { obj[h] = row[i] ?? null; });
|
|
1034
|
+
return obj;
|
|
1035
|
+
}));
|
|
1036
|
+
}
|
|
1037
|
+
return textResponse(rows.map(row => row.map(cell => {
|
|
1038
|
+
const s = String(cell ?? "");
|
|
1039
|
+
return s.includes(",") || s.includes('"') || s.includes("\n") ? `"${s.replace(/"/g, '""')}"` : s;
|
|
1040
|
+
}).join(",")).join("\n"));
|
|
1041
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
server.registerTool("gdrive_list_sheets", {
|
|
1045
|
+
description: "List all sheets/tabs in a Google Spreadsheet.",
|
|
1046
|
+
inputSchema: {
|
|
1047
|
+
fileId: z.string().describe("Google Sheet file ID"),
|
|
1048
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
1049
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
1050
|
+
},
|
|
1051
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1052
|
+
}, async ({ fileId, outputFormat, account }) => {
|
|
1053
|
+
try {
|
|
1054
|
+
const { sheets } = await getClients(account);
|
|
1055
|
+
const res = await sheets.spreadsheets.get({ spreadsheetId: fileId, fields: "sheets.properties" });
|
|
1056
|
+
return formatOutput({ sheets: (res.data.sheets || []).map(s => ({ title: s.properties.title, sheetId: s.properties.sheetId, index: s.properties.index, rowCount: s.properties.gridProperties?.rowCount, columnCount: s.properties.gridProperties?.columnCount })) }, outputFormat);
|
|
1057
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
server.registerTool("gdrive_read_sheet_range", {
|
|
1061
|
+
description: "Read a specific range from a Google Sheet tab.",
|
|
1062
|
+
inputSchema: {
|
|
1063
|
+
fileId: z.string().describe("Google Sheet file ID"),
|
|
1064
|
+
range: z.string().describe("A1 notation range (e.g., 'Sheet1!A1:D10', 'Sales!A:A')"),
|
|
1065
|
+
valueRender: z.enum(["formatted", "unformatted", "formula"]).optional().default("formatted").describe("How to render values (default: formatted)"),
|
|
1066
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
1067
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
1068
|
+
},
|
|
1069
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1070
|
+
}, async ({ fileId, range, valueRender, outputFormat, account }) => {
|
|
1071
|
+
try {
|
|
1072
|
+
const { sheets } = await getClients(account);
|
|
1073
|
+
const renderMap = { formatted: "FORMATTED_VALUE", unformatted: "UNFORMATTED_VALUE", formula: "FORMULA" };
|
|
1074
|
+
const res = await sheets.spreadsheets.values.get({ spreadsheetId: fileId, range, valueRenderOption: renderMap[valueRender] || "FORMATTED_VALUE" });
|
|
1075
|
+
return formatOutput({ range: res.data.range, values: res.data.values || [] }, outputFormat);
|
|
1076
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
server.registerTool("gdrive_write_sheet_range", {
|
|
1080
|
+
description: "Write data to a specific range in a Google Sheet.",
|
|
1081
|
+
inputSchema: {
|
|
1082
|
+
fileId: z.string().describe("Google Sheet file ID"),
|
|
1083
|
+
range: z.string().describe("A1 notation range (e.g., 'Sheet1!A1')"),
|
|
1084
|
+
values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).describe("2D array of values (rows x columns)"),
|
|
1085
|
+
valueInput: z.enum(["raw", "user_entered"]).optional().default("user_entered").describe("How to interpret values (default: user_entered)"),
|
|
1086
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
1087
|
+
},
|
|
1088
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1089
|
+
}, async ({ fileId, range, values, valueInput, account }) => {
|
|
1090
|
+
try {
|
|
1091
|
+
const { sheets } = await getClients(account);
|
|
1092
|
+
const res = await sheets.spreadsheets.values.update({
|
|
1093
|
+
spreadsheetId: fileId, range, valueInputOption: valueInput === "raw" ? "RAW" : "USER_ENTERED", requestBody: { values },
|
|
1094
|
+
});
|
|
1095
|
+
return jsonResponse({ updatedRange: res.data.updatedRange, updatedRows: res.data.updatedRows, updatedColumns: res.data.updatedColumns, updatedCells: res.data.updatedCells });
|
|
1096
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
// ═══ SLIDES TOOLS ════════════════════════════════════════════════════════════
|
|
1100
|
+
|
|
1101
|
+
server.registerTool("gdrive_create_slides", {
|
|
1102
|
+
description: "Create a new Google Slides presentation.",
|
|
1103
|
+
inputSchema: {
|
|
1104
|
+
name: z.string().describe("Presentation title"),
|
|
1105
|
+
parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
|
|
1106
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
1107
|
+
},
|
|
1108
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1109
|
+
}, async ({ name, parentFolderId, account }) => {
|
|
1110
|
+
try {
|
|
1111
|
+
const { slides, drive } = await getClients(account);
|
|
1112
|
+
const res = await slides.presentations.create({ requestBody: { title: name } });
|
|
1113
|
+
if (parentFolderId) {
|
|
1114
|
+
await drive.files.update({ fileId: res.data.presentationId, addParents: parentFolderId, removeParents: "root", supportsAllDrives: true });
|
|
1115
|
+
}
|
|
1116
|
+
return jsonResponse({ presentationId: res.data.presentationId, title: res.data.title, url: `https://docs.google.com/presentation/d/${res.data.presentationId}` });
|
|
1117
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
server.registerTool("gdrive_export_slides", {
|
|
1121
|
+
description: "Export Google Slides to text, PDF, or PPTX. Use savePath to save binary formats to disk.",
|
|
1122
|
+
inputSchema: {
|
|
1123
|
+
fileId: z.string().describe("Google Slides file ID"),
|
|
1124
|
+
format: z.enum(["text", "pdf", "pptx"]).optional().default("text").describe("Export format (default: text)"),
|
|
1125
|
+
savePath: z.string().optional().describe("Local file path to save binary exports (pdf/pptx). If omitted, returns base64."),
|
|
1126
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
1127
|
+
},
|
|
1128
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1129
|
+
}, async ({ fileId, format, savePath, account }) => {
|
|
1130
|
+
try {
|
|
1131
|
+
const { drive } = await getClients(account);
|
|
1132
|
+
const mimeMap = { text: "text/plain", pdf: "application/pdf", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" };
|
|
1133
|
+
const isBinary = ["pdf", "pptx"].includes(format);
|
|
1134
|
+
const res = await drive.files.export({ fileId, mimeType: mimeMap[format] }, { responseType: isBinary ? "arraybuffer" : "text" });
|
|
1135
|
+
if (isBinary) return handleBinaryExport(res.data, format, savePath);
|
|
1136
|
+
return textResponse(res.data);
|
|
1137
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
server.registerTool("gdrive_get_slide_thumbnail", {
|
|
1141
|
+
description: "Get a thumbnail image URL for a specific slide.",
|
|
1142
|
+
inputSchema: {
|
|
1143
|
+
fileId: z.string().describe("Google Slides file ID"),
|
|
1144
|
+
slideIndex: z.number().optional().default(0).describe("Slide index, 0-based (default: first slide)"),
|
|
1145
|
+
outputFormat: OUTPUT_FORMAT_SCHEMA,
|
|
1146
|
+
account: z.string().optional().describe("Account label (default: active account)"),
|
|
1147
|
+
},
|
|
1148
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1149
|
+
}, async ({ fileId, slideIndex, outputFormat, account }) => {
|
|
1150
|
+
try {
|
|
1151
|
+
const { slides } = await getClients(account);
|
|
1152
|
+
const pres = await slides.presentations.get({ presentationId: fileId, fields: "slides.objectId" });
|
|
1153
|
+
const pages = pres.data.slides || [];
|
|
1154
|
+
if (slideIndex >= pages.length) return errorResponse(`Slide index ${slideIndex} out of range (${pages.length} slides)`);
|
|
1155
|
+
const thumb = await slides.presentations.pages.getThumbnail({ presentationId: fileId, pageObjectId: pages[slideIndex].objectId, "thumbnailProperties.mimeType": "PNG" });
|
|
1156
|
+
return formatOutput({ slideIndex, pageId: pages[slideIndex].objectId, thumbnailUrl: thumb.data.contentUrl, width: thumb.data.width, height: thumb.data.height }, outputFormat);
|
|
1157
|
+
} catch (err) { return errorResponse(err.message); }
|
|
1158
|
+
});
|
|
462
1159
|
|
|
463
1160
|
// ─── Start ───────────────────────────────────────────────────────────────────
|
|
464
1161
|
|
|
465
1162
|
async function main() {
|
|
466
|
-
|
|
467
|
-
if (
|
|
468
|
-
|
|
1163
|
+
const authIndex = process.argv.indexOf("--auth");
|
|
1164
|
+
if (authIndex !== -1) {
|
|
1165
|
+
const label = process.argv[authIndex + 1] || "primary";
|
|
1166
|
+
await interactiveAuth(label);
|
|
469
1167
|
process.exit(0);
|
|
470
1168
|
}
|
|
471
1169
|
|
|
1170
|
+
await ensureConfigDirs();
|
|
1171
|
+
const config = await loadConfig();
|
|
1172
|
+
|
|
1173
|
+
// Migrate legacy single token.json
|
|
1174
|
+
if (Object.keys(config.accounts).length === 0) {
|
|
1175
|
+
const legacyToken = join(CONFIG_DIR, "token.json");
|
|
1176
|
+
if (existsSync(legacyToken)) {
|
|
1177
|
+
const token = JSON.parse(await readFile(legacyToken, "utf-8"));
|
|
1178
|
+
await saveTokenFile("primary", token);
|
|
1179
|
+
config.accounts.primary = { email: "migrated" };
|
|
1180
|
+
config.active = "primary";
|
|
1181
|
+
await saveConfig(config);
|
|
1182
|
+
process.stderr.write("Migrated legacy token.json to tokens/primary.json\n");
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
472
1186
|
try {
|
|
473
|
-
|
|
474
|
-
driveClient = google.drive({ version: "v3", auth: authClient });
|
|
1187
|
+
await getClients(config.active);
|
|
475
1188
|
} catch (err) {
|
|
476
1189
|
process.stderr.write(`\nAuth error: ${err.message}\n`);
|
|
477
1190
|
process.exit(1);
|