@trops/dash-core 0.1.335 → 0.1.337

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.
@@ -195,47 +195,25 @@
195
195
  {
196
196
  "id": "google-drive",
197
197
  "name": "Google Drive",
198
- "description": "List, read, and search files in Google Drive with automatic format conversion for Docs, Sheets, and Slides.",
198
+ "description": "Search, read, and write files in Google Drive. Supports folder listing, file creation, and path resolution.",
199
199
  "icon": "google-drive",
200
200
  "tags": ["google", "files", "cloud-storage"],
201
201
  "mcpConfig": {
202
202
  "transport": "stdio",
203
203
  "command": "node",
204
204
  "args": ["{{MCP_DIR}}/servers/google-drive.js"],
205
- "envMapping": {
206
- "GDRIVE_OAUTH_PATH": "oauthKeysPath"
207
- },
208
205
  "staticEnv": {
209
206
  "GDRIVE_CREDENTIALS_PATH": "~/.gdrive-mcp/credentials.json"
210
- },
211
- "tokenRefresh": {
212
- "credentialsPath": "~/.gdrive-mcp/credentials.json",
213
- "oauthKeysPath": "~/.gdrive-mcp/gcp-oauth.keys.json"
214
207
  }
215
208
  },
216
209
  "authCommand": {
217
210
  "command": "node",
218
211
  "args": ["{{MCP_DIR}}/servers/google-drive.js", "auth"],
219
- "setup": {
220
- "copyCredential": {
221
- "from": "oauthKeysPath",
222
- "to": "~/.gdrive-mcp/gcp-oauth.keys.json"
223
- }
224
- },
225
212
  "staticEnv": {
226
- "GDRIVE_CREDENTIALS_PATH": "~/.gdrive-mcp/credentials.json",
227
- "GDRIVE_OAUTH_PATH": "~/.gdrive-mcp/gcp-oauth.keys.json"
213
+ "GDRIVE_CREDENTIALS_PATH": "~/.gdrive-mcp/credentials.json"
228
214
  }
229
215
  },
230
- "credentialSchema": {
231
- "oauthKeysPath": {
232
- "type": "file",
233
- "displayName": "OAuth Keys File",
234
- "required": false,
235
- "secret": false,
236
- "instructions": "Path to your Google OAuth keys file (gcp-oauth.keys.json). Create one at console.cloud.google.com > APIs & Services > Credentials > OAuth 2.0 Client IDs."
237
- }
238
- }
216
+ "credentialSchema": {}
239
217
  },
240
218
  {
241
219
  "id": "gmail",
@@ -2,12 +2,11 @@
2
2
  /**
3
3
  * Custom Google Drive MCP server.
4
4
  *
5
- * Replaces the archived @modelcontextprotocol/server-gdrive which has a
6
- * fundamental bug: it creates OAuth2 clients without client_id/client_secret,
7
- * so it can never refresh tokens.
5
+ * Tools: search, list_folder, create_folder, read_file, write_file, resolve_path
8
6
  *
9
- * Exposes a single "search" tool with { query: string } input — identical
10
- * interface to the original, so no widget changes are needed.
7
+ * OAuth uses PKCE with a bundled client_id no client_secret, no per-user
8
+ * GCP project setup. Users just run `node google-drive.js auth` to grant
9
+ * Drive access via browser.
11
10
  *
12
11
  * Usage:
13
12
  * MCP server: node google-drive.js (stdio transport)
@@ -15,7 +14,6 @@
15
14
  *
16
15
  * Environment variables:
17
16
  * GDRIVE_CREDENTIALS_PATH — path to stored OAuth credentials (access/refresh tokens)
18
- * GDRIVE_OAUTH_PATH — path to Google OAuth client keys file
19
17
  */
20
18
  const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
21
19
  const {
@@ -28,26 +26,22 @@ const {
28
26
  const fs = require("fs");
29
27
  const https = require("https");
30
28
  const path = require("path");
29
+ const crypto = require("crypto");
31
30
 
32
31
  const credentialsPath = (process.env.GDRIVE_CREDENTIALS_PATH || "").replace(
33
32
  /^~/,
34
33
  process.env.HOME || "",
35
34
  );
36
- const oauthKeysPath = (process.env.GDRIVE_OAUTH_PATH || "").replace(
37
- /^~/,
38
- process.env.HOME || "",
39
- );
40
35
 
41
- /**
42
- * Read OAuth client credentials from the keys file.
43
- */
44
- function getClientCredentials() {
45
- const keysFile = JSON.parse(fs.readFileSync(oauthKeysPath, "utf8"));
46
- const keyData = keysFile.installed || keysFile.web;
47
- return {
48
- client_id: keyData.client_id,
49
- client_secret: keyData.client_secret,
50
- };
36
+ // Bundled OAuth client_id for the Dash platform's GCP project.
37
+ // Desktop OAuth client_ids are inherently public they're identifiers,
38
+ // not secrets. Auth uses PKCE (code_verifier/code_challenge) instead of
39
+ // a client_secret.
40
+ const BUNDLED_CLIENT_ID =
41
+ "785070273499-mr9b0vup4u24he8duh3c6j5gpk7qj54j.apps.googleusercontent.com";
42
+
43
+ function getClientId() {
44
+ return BUNDLED_CLIENT_ID;
51
45
  }
52
46
 
53
47
  /**
@@ -62,17 +56,16 @@ function readCredentials() {
62
56
  */
63
57
  async function getAccessToken() {
64
58
  let creds = readCredentials();
65
- const { client_id, client_secret } = getClientCredentials();
59
+ const clientId = getClientId();
66
60
 
67
61
  // Still valid (>60s remaining)?
68
62
  if (creds.expiry_date && creds.expiry_date > Date.now() + 60 * 1000) {
69
63
  return creds.access_token;
70
64
  }
71
65
 
72
- // Refresh
66
+ // Refresh — PKCE-based installed apps don't need client_secret for refresh
73
67
  const postData = [
74
- `client_id=${encodeURIComponent(client_id)}`,
75
- `client_secret=${encodeURIComponent(client_secret)}`,
68
+ `client_id=${encodeURIComponent(clientId)}`,
76
69
  `refresh_token=${encodeURIComponent(creds.refresh_token)}`,
77
70
  "grant_type=refresh_token",
78
71
  ].join("&");
@@ -117,43 +110,194 @@ async function getAccessToken() {
117
110
  }
118
111
 
119
112
  /**
120
- * Make a Google Drive API request.
113
+ * Make a Google Drive API request (GET, POST, PATCH, etc.).
121
114
  */
122
- function driveRequest(path, token) {
115
+ function driveRequest(apiPath, token, method = "GET", body = null) {
123
116
  return new Promise((resolve, reject) => {
117
+ const headers = { Authorization: `Bearer ${token}` };
118
+ if (body) {
119
+ headers["Content-Type"] = "application/json";
120
+ headers["Content-Length"] = Buffer.byteLength(body);
121
+ }
122
+ const req = https.request(
123
+ { hostname: "www.googleapis.com", path: apiPath, method, headers },
124
+ (res) => {
125
+ let data = "";
126
+ res.on("data", (chunk) => (data += chunk));
127
+ res.on("end", () => {
128
+ if (res.statusCode >= 200 && res.statusCode < 300) {
129
+ try {
130
+ resolve(JSON.parse(data));
131
+ } catch {
132
+ resolve(data);
133
+ }
134
+ } else {
135
+ reject(
136
+ new Error(`Drive API ${method} (${res.statusCode}): ${data}`),
137
+ );
138
+ }
139
+ });
140
+ },
141
+ );
142
+ req.on("error", reject);
143
+ if (body) req.write(body);
144
+ req.end();
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Multipart upload to Google Drive (for creating/updating file content).
150
+ */
151
+ function driveUploadRequest(
152
+ apiPath,
153
+ token,
154
+ method,
155
+ metadata,
156
+ content,
157
+ mimeType,
158
+ ) {
159
+ return new Promise((resolve, reject) => {
160
+ const boundary = "dash_boundary_" + Date.now().toString(36);
161
+ const body =
162
+ `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n` +
163
+ `${JSON.stringify(metadata)}\r\n` +
164
+ `--${boundary}\r\nContent-Type: ${mimeType}\r\n\r\n` +
165
+ `${content}\r\n` +
166
+ `--${boundary}--`;
167
+
124
168
  const req = https.request(
125
169
  {
126
170
  hostname: "www.googleapis.com",
127
- path,
128
- method: "GET",
129
- headers: { Authorization: `Bearer ${token}` },
171
+ path: apiPath,
172
+ method,
173
+ headers: {
174
+ Authorization: `Bearer ${token}`,
175
+ "Content-Type": `multipart/related; boundary=${boundary}`,
176
+ "Content-Length": Buffer.byteLength(body),
177
+ },
130
178
  },
131
179
  (res) => {
132
180
  let data = "";
133
- res.on("data", (chunk) => (data += chunk));
181
+ res.on("data", (c) => (data += c));
134
182
  res.on("end", () => {
135
- if (res.statusCode === 200) {
136
- resolve(JSON.parse(data));
183
+ if (res.statusCode >= 200 && res.statusCode < 300) {
184
+ try {
185
+ resolve(JSON.parse(data));
186
+ } catch {
187
+ resolve(data);
188
+ }
137
189
  } else {
138
- reject(new Error(`Drive API error (${res.statusCode}): ${data}`));
190
+ reject(
191
+ new Error(`Drive upload ${method} (${res.statusCode}): ${data}`),
192
+ );
139
193
  }
140
194
  });
141
195
  },
142
196
  );
143
197
  req.on("error", reject);
198
+ req.write(body);
144
199
  req.end();
145
200
  });
146
201
  }
147
202
 
203
+ // ── Tool helper functions ────────────────────────────────────────────
204
+
205
+ async function listFolder(token, folderId) {
206
+ const q = encodeURIComponent(`'${folderId}' in parents and trashed=false`);
207
+ const fields = encodeURIComponent("files(id,name,mimeType)");
208
+ const result = await driveRequest(
209
+ `/drive/v3/files?q=${q}&fields=${fields}&pageSize=200`,
210
+ token,
211
+ );
212
+ return result.files || [];
213
+ }
214
+
215
+ async function createFolder(token, parentId, name) {
216
+ const body = JSON.stringify({
217
+ name,
218
+ mimeType: "application/vnd.google-apps.folder",
219
+ parents: [parentId],
220
+ });
221
+ return await driveRequest(
222
+ "/drive/v3/files?fields=id,name",
223
+ token,
224
+ "POST",
225
+ body,
226
+ );
227
+ }
228
+
229
+ async function readFile(token, fileId) {
230
+ return await driveRequest(`/drive/v3/files/${fileId}?alt=media`, token);
231
+ }
232
+
233
+ async function writeFile(token, parentId, name, content, mimeType) {
234
+ mimeType = mimeType || "text/markdown";
235
+ // Upsert: check if file with this name already exists in parent
236
+ const escapedName = name.replace(/'/g, "\\'");
237
+ const q = encodeURIComponent(
238
+ `name='${escapedName}' and '${parentId}' in parents and trashed=false`,
239
+ );
240
+ const existing = await driveRequest(
241
+ `/drive/v3/files?q=${q}&fields=files(id)`,
242
+ token,
243
+ );
244
+ const existingId = existing.files?.[0]?.id;
245
+
246
+ if (existingId) {
247
+ const result = await driveUploadRequest(
248
+ `/upload/drive/v3/files/${existingId}?uploadType=multipart&fields=id,name`,
249
+ token,
250
+ "PATCH",
251
+ {},
252
+ content,
253
+ mimeType,
254
+ );
255
+ return { ...result, _action: "updated" };
256
+ } else {
257
+ const result = await driveUploadRequest(
258
+ `/upload/drive/v3/files?uploadType=multipart&fields=id,name`,
259
+ token,
260
+ "POST",
261
+ { name, parents: [parentId], mimeType },
262
+ content,
263
+ mimeType,
264
+ );
265
+ return { ...result, _action: "created" };
266
+ }
267
+ }
268
+
269
+ async function resolvePath(token, pathStr) {
270
+ const segments = pathStr
271
+ .split("/")
272
+ .map((s) => s.trim())
273
+ .filter(Boolean);
274
+ let currentId = "root";
275
+ for (const segment of segments) {
276
+ const children = await listFolder(token, currentId);
277
+ const match = children.find((c) => c.name === segment);
278
+ if (!match) return null;
279
+ currentId = match.id;
280
+ }
281
+ return currentId;
282
+ }
283
+
148
284
  // ── Auth subcommand ──────────────────────────────────────────────────
149
285
  if (process.argv[2] === "auth") {
150
286
  (async () => {
151
287
  try {
152
288
  const http = require("http");
153
289
  const { URL } = require("url");
154
- const { client_id, client_secret } = getClientCredentials();
290
+ const clientId = getClientId();
291
+
292
+ const scopes = ["https://www.googleapis.com/auth/drive"];
293
+
294
+ // PKCE: generate code verifier + challenge (no client_secret needed)
295
+ const codeVerifier = crypto.randomBytes(32).toString("base64url");
296
+ const codeChallenge = crypto
297
+ .createHash("sha256")
298
+ .update(codeVerifier)
299
+ .digest("base64url");
155
300
 
156
- const scopes = ["https://www.googleapis.com/auth/drive.readonly"];
157
301
  let redirectUri;
158
302
 
159
303
  // Start local server to catch the callback
@@ -166,11 +310,11 @@ if (process.argv[2] === "auth") {
166
310
  return;
167
311
  }
168
312
 
169
- // Exchange code for tokens
313
+ // Exchange code for tokens using PKCE code_verifier
170
314
  const postData = [
171
315
  `code=${encodeURIComponent(code)}`,
172
- `client_id=${encodeURIComponent(client_id)}`,
173
- `client_secret=${encodeURIComponent(client_secret)}`,
316
+ `client_id=${encodeURIComponent(clientId)}`,
317
+ `code_verifier=${encodeURIComponent(codeVerifier)}`,
174
318
  `redirect_uri=${encodeURIComponent(redirectUri)}`,
175
319
  `grant_type=authorization_code`,
176
320
  ].join("&");
@@ -246,12 +390,14 @@ if (process.argv[2] === "auth") {
246
390
 
247
391
  const authUrl =
248
392
  `https://accounts.google.com/o/oauth2/v2/auth?` +
249
- `client_id=${encodeURIComponent(client_id)}` +
393
+ `client_id=${encodeURIComponent(clientId)}` +
250
394
  `&redirect_uri=${encodeURIComponent(redirectUri)}` +
251
395
  `&response_type=code` +
252
396
  `&scope=${encodeURIComponent(scopes.join(" "))}` +
253
397
  `&access_type=offline` +
254
- `&prompt=consent`;
398
+ `&prompt=consent` +
399
+ `&code_challenge=${encodeURIComponent(codeChallenge)}` +
400
+ `&code_challenge_method=S256`;
255
401
 
256
402
  const { exec } = require("child_process");
257
403
  exec(`open "${authUrl}"`);
@@ -280,72 +426,269 @@ if (process.argv[2] === "auth") {
280
426
  inputSchema: {
281
427
  type: "object",
282
428
  properties: {
283
- query: {
429
+ query: { type: "string", description: "Search query" },
430
+ },
431
+ required: ["query"],
432
+ },
433
+ },
434
+ {
435
+ name: "list_folder",
436
+ description:
437
+ "List children of a Google Drive folder by ID. Use 'root' for My Drive.",
438
+ inputSchema: {
439
+ type: "object",
440
+ properties: {
441
+ folderId: {
284
442
  type: "string",
285
- description: "Search query",
443
+ description: "Folder ID, or 'root' for My Drive",
286
444
  },
287
445
  },
288
- required: ["query"],
446
+ required: ["folderId"],
447
+ },
448
+ },
449
+ {
450
+ name: "create_folder",
451
+ description:
452
+ "Create a new folder inside a parent folder. Returns the new folder's ID.",
453
+ inputSchema: {
454
+ type: "object",
455
+ properties: {
456
+ parentId: { type: "string", description: "Parent folder ID" },
457
+ name: { type: "string", description: "New folder name" },
458
+ },
459
+ required: ["parentId", "name"],
460
+ },
461
+ },
462
+ {
463
+ name: "read_file",
464
+ description:
465
+ "Read the text content of a Drive file by ID. Plain text files only.",
466
+ inputSchema: {
467
+ type: "object",
468
+ properties: {
469
+ fileId: { type: "string", description: "File ID" },
470
+ },
471
+ required: ["fileId"],
472
+ },
473
+ },
474
+ {
475
+ name: "write_file",
476
+ description:
477
+ "Create or update a text file in a folder (upsert by name).",
478
+ inputSchema: {
479
+ type: "object",
480
+ properties: {
481
+ parentId: { type: "string", description: "Parent folder ID" },
482
+ name: { type: "string", description: "File name" },
483
+ content: { type: "string", description: "File content" },
484
+ mimeType: {
485
+ type: "string",
486
+ description: "Optional MIME type (default: text/markdown)",
487
+ },
488
+ },
489
+ required: ["parentId", "name", "content"],
490
+ },
491
+ },
492
+ {
493
+ name: "resolve_path",
494
+ description:
495
+ "Walk a slash-separated path from My Drive root and return the final file/folder ID, or null if any segment is missing.",
496
+ inputSchema: {
497
+ type: "object",
498
+ properties: {
499
+ path: {
500
+ type: "string",
501
+ description:
502
+ "Slash-separated path, e.g. 'Sales Pipeline/AMER/ENT/Acme Corp'",
503
+ },
504
+ },
505
+ required: ["path"],
289
506
  },
290
507
  },
291
508
  ],
292
509
  }));
293
510
 
294
511
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
295
- if (request.params.name !== "search") {
296
- return {
297
- content: [
298
- {
299
- type: "text",
300
- text: `Unknown tool: ${request.params.name}`,
301
- },
302
- ],
303
- isError: true,
304
- };
305
- }
306
-
307
- const query = request.params.arguments?.query;
308
- if (!query) {
309
- return {
310
- content: [{ type: "text", text: "Missing required argument: query" }],
311
- isError: true,
312
- };
313
- }
512
+ const toolName = request.params.name;
513
+ const args = request.params.arguments || {};
314
514
 
315
515
  try {
316
516
  const token = await getAccessToken();
317
- const encodedQuery = encodeURIComponent(
318
- `fullText contains '${query.replace(/'/g, "\\'")}'`,
319
- );
320
- const result = await driveRequest(
321
- `/drive/v3/files?q=${encodedQuery}&fields=files(id,name,mimeType,modifiedTime,webViewLink)&pageSize=20`,
322
- token,
323
- );
324
517
 
325
- const files = result.files || [];
326
- if (files.length === 0) {
327
- return {
328
- content: [
329
- {
330
- type: "text",
331
- text: `No files found for query: ${query}`,
332
- },
333
- ],
334
- };
335
- }
518
+ switch (toolName) {
519
+ case "search": {
520
+ const query = args.query;
521
+ if (!query) {
522
+ return {
523
+ content: [
524
+ { type: "text", text: "Missing required argument: query" },
525
+ ],
526
+ isError: true,
527
+ };
528
+ }
529
+ const encodedQuery = encodeURIComponent(
530
+ `fullText contains '${query.replace(/'/g, "\\'")}'`,
531
+ );
532
+ const result = await driveRequest(
533
+ `/drive/v3/files?q=${encodedQuery}&fields=files(id,name,mimeType,modifiedTime,webViewLink)&pageSize=20`,
534
+ token,
535
+ );
536
+ const files = result.files || [];
537
+ if (files.length === 0) {
538
+ return {
539
+ content: [
540
+ { type: "text", text: `No files found for query: ${query}` },
541
+ ],
542
+ };
543
+ }
544
+ const lines = files.map(
545
+ (f) =>
546
+ `${f.name} (${f.mimeType})${f.webViewLink ? ` - ${f.webViewLink}` : ""}`,
547
+ );
548
+ return {
549
+ content: [
550
+ {
551
+ type: "text",
552
+ text: `Found ${files.length} files:\n${lines.join("\n")}`,
553
+ },
554
+ ],
555
+ };
556
+ }
336
557
 
337
- const lines = files.map(
338
- (f) =>
339
- `${f.name} (${f.mimeType})${f.webViewLink ? ` - ${f.webViewLink}` : ""}`,
340
- );
341
- return {
342
- content: [
343
- {
344
- type: "text",
345
- text: `Found ${files.length} files:\n${lines.join("\n")}`,
346
- },
347
- ],
348
- };
558
+ case "list_folder": {
559
+ if (!args.folderId) {
560
+ return {
561
+ content: [
562
+ {
563
+ type: "text",
564
+ text: "Missing required argument: folderId",
565
+ },
566
+ ],
567
+ isError: true,
568
+ };
569
+ }
570
+ const children = await listFolder(token, args.folderId);
571
+ if (children.length === 0) {
572
+ return {
573
+ content: [{ type: "text", text: "Folder is empty." }],
574
+ };
575
+ }
576
+ const childLines = children.map(
577
+ (f) => `${f.name} (${f.mimeType}) [${f.id}]`,
578
+ );
579
+ return {
580
+ content: [
581
+ {
582
+ type: "text",
583
+ text: `${children.length} children:\n${childLines.join("\n")}`,
584
+ },
585
+ ],
586
+ };
587
+ }
588
+
589
+ case "create_folder": {
590
+ if (!args.parentId || !args.name) {
591
+ return {
592
+ content: [
593
+ {
594
+ type: "text",
595
+ text: "Missing required arguments: parentId, name",
596
+ },
597
+ ],
598
+ isError: true,
599
+ };
600
+ }
601
+ const folder = await createFolder(token, args.parentId, args.name);
602
+ return {
603
+ content: [
604
+ {
605
+ type: "text",
606
+ text: `Created folder "${folder.name}" [${folder.id}]`,
607
+ },
608
+ ],
609
+ };
610
+ }
611
+
612
+ case "read_file": {
613
+ if (!args.fileId) {
614
+ return {
615
+ content: [
616
+ { type: "text", text: "Missing required argument: fileId" },
617
+ ],
618
+ isError: true,
619
+ };
620
+ }
621
+ const content = await readFile(token, args.fileId);
622
+ return {
623
+ content: [
624
+ {
625
+ type: "text",
626
+ text:
627
+ typeof content === "string"
628
+ ? content
629
+ : JSON.stringify(content),
630
+ },
631
+ ],
632
+ };
633
+ }
634
+
635
+ case "write_file": {
636
+ if (!args.parentId || !args.name || args.content == null) {
637
+ return {
638
+ content: [
639
+ {
640
+ type: "text",
641
+ text: "Missing required arguments: parentId, name, content",
642
+ },
643
+ ],
644
+ isError: true,
645
+ };
646
+ }
647
+ const writeResult = await writeFile(
648
+ token,
649
+ args.parentId,
650
+ args.name,
651
+ args.content,
652
+ args.mimeType,
653
+ );
654
+ return {
655
+ content: [
656
+ {
657
+ type: "text",
658
+ text: `${writeResult._action} "${writeResult.name}" [${writeResult.id}]`,
659
+ },
660
+ ],
661
+ };
662
+ }
663
+
664
+ case "resolve_path": {
665
+ if (!args.path) {
666
+ return {
667
+ content: [
668
+ { type: "text", text: "Missing required argument: path" },
669
+ ],
670
+ isError: true,
671
+ };
672
+ }
673
+ const resolvedId = await resolvePath(token, args.path);
674
+ if (resolvedId) {
675
+ return {
676
+ content: [
677
+ { type: "text", text: `Resolved to ID: ${resolvedId}` },
678
+ ],
679
+ };
680
+ }
681
+ return {
682
+ content: [{ type: "text", text: `Path not found: ${args.path}` }],
683
+ };
684
+ }
685
+
686
+ default:
687
+ return {
688
+ content: [{ type: "text", text: `Unknown tool: ${toolName}` }],
689
+ isError: true,
690
+ };
691
+ }
349
692
  } catch (err) {
350
693
  return {
351
694
  content: [{ type: "text", text: `Error: ${err.message}` }],