dropbox-mcp-server 2.2.0 → 2.2.1

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.
Files changed (3) hide show
  1. package/dist/index.js +102 -185
  2. package/index.ts +109 -194
  3. package/package.json +6 -2
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
5
5
  import { Dropbox } from "dropbox";
6
6
  import "dotenv/config";
7
7
  // ------------------------------------------------------------------
8
- // 1. Rate Limiter
8
+ // 1. Rate Limiter (Fixed: Removed dangerous Proxy)
9
9
  // ------------------------------------------------------------------
10
10
  const MAX_CONCURRENT = 1;
11
11
  const MIN_GAP_MS = 1000;
@@ -59,17 +59,6 @@ async function limited(fn) {
59
59
  release();
60
60
  }
61
61
  }
62
- function throttleClient(client) {
63
- return new Proxy(client, {
64
- get(target, prop, receiver) {
65
- const val = Reflect.get(target, prop, receiver);
66
- if (typeof val === "function") {
67
- return (...args) => limited(() => val.apply(target, args));
68
- }
69
- return val;
70
- },
71
- });
72
- }
73
62
  // ------------------------------------------------------------------
74
63
  // 2. Error Handling
75
64
  // ------------------------------------------------------------------
@@ -95,86 +84,105 @@ function isBadRequestError(err) {
95
84
  // ------------------------------------------------------------------
96
85
  const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
97
86
  const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
98
- const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL; // optional
87
+ const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL;
88
+ const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET; // Highly recommended for token refresh
99
89
  if (!DROPBOX_APP_KEY || !DROPBOX_REFRESH_TOKEN) {
100
- console.error("Missing required environment variables:");
101
- console.error(" - DROPBOX_APP_KEY");
102
- console.error(" - DROPBOX_REFRESH_TOKEN");
103
- console.error("Note: DROPBOX_SHARED_URL is optional.");
90
+ console.error("Missing required environment variables: DROPBOX_APP_KEY, DROPBOX_REFRESH_TOKEN");
104
91
  process.exit(1);
105
92
  }
106
- const rawClient = new Dropbox({
93
+ // Instantiate direct client (no proxy)
94
+ const client = new Dropbox({
107
95
  clientId: DROPBOX_APP_KEY,
96
+ clientSecret: DROPBOX_APP_SECRET, // Needed if your Dropbox app is configured with a secret
108
97
  refreshToken: DROPBOX_REFRESH_TOKEN,
109
98
  });
110
- const client = throttleClient(rawClient);
111
- const server = new Server({ name: "dropbox-agent-mcp", version: "2.0.0" }, { capabilities: { tools: {} } });
99
+ const server = new Server({ name: "dropbox-agent-mcp", version: "2.0.1" }, { capabilities: { tools: {} } });
100
+ // ------------------------------------------------------------------
101
+ // 4. Helpers (Fixed Pathing & Node 18+ Blobs)
102
+ // ------------------------------------------------------------------
103
+ import officeParser from 'officeparser';
104
+ import * as fs from 'fs/promises';
105
+ import * as path from 'path';
106
+ import * as os from 'os';
112
107
  // ------------------------------------------------------------------
113
- // 4. Helpers
108
+ // 4. Helpers
114
109
  // ------------------------------------------------------------------
115
- /** Normalise a path for use with a shared link: strip leading "/" and handle root. */
116
- function normalizeSharedPath(path) {
117
- if (!path || path === "/")
110
+ function formatDropboxPath(pathStr) {
111
+ if (!pathStr || pathStr === "/" || pathStr.trim() === "")
118
112
  return "";
119
- return path.replace(/^\/+/, "");
113
+ return pathStr.startsWith("/") ? pathStr : `/${pathStr}`;
120
114
  }
121
- /** Check if a filename likely belongs to a text file. */
122
- function isTextFile(pathOrName) {
115
+ function isSupportedFile(pathOrName) {
123
116
  const fileName = pathOrName.split('/').pop() || '';
124
117
  const ext = fileName.split('.').pop()?.toLowerCase() || '';
125
- const textExtensions = [
118
+ if (fileName === ext)
119
+ return true;
120
+ const supportedExtensions = [
126
121
  'csv', 'tsv', 'txt', 'json', 'md', 'log',
127
122
  'xml', 'yaml', 'yml', 'html', 'htm', 'css',
128
- 'js', 'ts', 'py', 'java', 'c', 'cpp', 'h'
123
+ 'js', 'ts', 'py', 'java', 'c', 'cpp', 'h',
124
+ 'pdf', 'docx', 'pptx', 'xlsx', 'xls', 'doc', 'ppt', 'odt', 'odp', 'ods'
129
125
  ];
130
- return textExtensions.includes(ext);
126
+ return supportedExtensions.includes(ext);
131
127
  }
132
- /**
133
- * Download a file from Dropbox.
134
- * - If DROPBOX_SHARED_URL is set, uses shared link APIs with relative paths.
135
- * - Otherwise, uses the regular filesDownload with absolute paths.
136
- * Returns UTF‑8 text, or throws if binary.
137
- */
138
128
  async function downloadHelper(identifier) {
139
129
  let result;
140
130
  let fileName;
141
- // If the identifier is a full URL, we always treat it as a shared link.
142
131
  if (identifier.startsWith("http://") || identifier.startsWith("https://")) {
143
- const response = await client.sharingGetSharedLinkFile({ url: identifier });
132
+ const response = await limited(() => client.sharingGetSharedLinkFile({ url: identifier }));
144
133
  result = response.result;
145
134
  fileName = result.name || identifier.split('/').pop();
146
135
  }
147
- // Otherwise, decide based on DROPBOX_SHARED_URL.
148
136
  else if (DROPBOX_SHARED_URL) {
149
- // Shared link mode – identifier is a relative path.
150
- const path = normalizeSharedPath(identifier);
151
- const response = await client.sharingGetSharedLinkFile({
137
+ const reqPath = formatDropboxPath(identifier);
138
+ const response = await limited(() => client.sharingGetSharedLinkFile({
152
139
  url: DROPBOX_SHARED_URL,
153
- path: path,
154
- });
140
+ path: reqPath,
141
+ }));
155
142
  result = response.result;
156
143
  fileName = result.name || identifier;
157
144
  }
158
145
  else {
159
- // Regular mode identifier is an absolute path.
160
- const response = await client.filesDownload({ path: identifier });
146
+ const reqPath = formatDropboxPath(identifier);
147
+ const response = await limited(() => client.filesDownload({ path: reqPath }));
161
148
  result = response.result;
162
149
  fileName = result.name || identifier;
163
150
  }
164
- if (!result.fileBinary) {
151
+ const fileRef = fileName || identifier;
152
+ if (!isSupportedFile(fileRef)) {
153
+ throw new Error(`File "${fileRef}" appears to be an unsupported file format.`);
154
+ }
155
+ let fileBuffer;
156
+ if (result.fileBinary) {
157
+ fileBuffer = Buffer.from(result.fileBinary);
158
+ }
159
+ else if (result.fileBlob) {
160
+ const arrayBuffer = await result.fileBlob.arrayBuffer();
161
+ fileBuffer = Buffer.from(arrayBuffer);
162
+ }
163
+ else {
165
164
  throw new Error("Download succeeded but no file content was returned.");
166
165
  }
167
- const fileRef = fileName || identifier;
168
- if (!isTextFile(fileRef)) {
169
- throw new Error(`File "${fileRef}" appears to be a binary file. ` +
170
- `This tool only supports text‑based files (CSV, JSON, TXT, etc.).`);
166
+ const ext = fileRef.split('.').pop()?.toLowerCase() || '';
167
+ const binaryExtensions = ['pdf', 'docx', 'pptx', 'xlsx', 'xls', 'doc', 'ppt', 'odt', 'odp', 'ods'];
168
+ if (binaryExtensions.includes(ext)) {
169
+ const tempFilePath = path.join(os.tmpdir(), `temp_${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`);
170
+ try {
171
+ await fs.writeFile(tempFilePath, fileBuffer);
172
+ // officeParser is called from the default import
173
+ const ast = await officeParser.parseOffice(fileBuffer);
174
+ const result = await ast.to('md');
175
+ return result.value || "Document parsed, but it appears to be empty.";
176
+ }
177
+ catch (error) {
178
+ throw new Error(`Failed to parse binary document: ${error.message}`);
179
+ }
180
+ finally {
181
+ await fs.unlink(tempFilePath).catch(() => { });
182
+ }
171
183
  }
172
- return Buffer.from(result.fileBinary).toString('utf-8');
184
+ return fileBuffer.toString('utf-8');
173
185
  }
174
- /**
175
- * Parse a CSV header line, handling quoted fields and trimming.
176
- * Returns an array of clean column names.
177
- */
178
186
  function parseCsvHeader(headerLine) {
179
187
  const fields = [];
180
188
  let current = '';
@@ -202,47 +210,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
202
210
  tools: [
203
211
  {
204
212
  name: "list_folder",
205
- description: DROPBOX_SHARED_URL
206
- ? "List folders and files inside the shared Dropbox folder. Path must be relative to the shared root (e.g., '' for root, 'Data' for /Data)."
207
- : "List folders and files in a specific Dropbox path (absolute, e.g., '/Data' or '' for root).",
213
+ description: "List folders and files. Use '' for root, or '/FolderName' for subfolders.",
208
214
  inputSchema: {
209
215
  type: "object",
210
- properties: {
211
- path: {
212
- type: "string",
213
- description: DROPBOX_SHARED_URL
214
- ? "Relative path inside the shared folder (e.g., '' or 'Data')."
215
- : "Absolute path to list (e.g., '' for root or '/Data')."
216
- }
217
- },
216
+ properties: { path: { type: "string" } },
218
217
  required: ["path"]
219
218
  }
220
219
  },
221
220
  {
222
221
  name: "search_files",
223
- description: "Search for files by name, extension, or content across the entire Dropbox account.",
222
+ description: "Search for files by name, extension, or content.",
224
223
  inputSchema: {
225
224
  type: "object",
226
- properties: {
227
- query: { type: "string", description: "The search query (e.g., 'sales report' or '.csv')" }
228
- },
225
+ properties: { query: { type: "string" } },
229
226
  required: ["query"]
230
227
  }
231
228
  },
232
229
  {
233
230
  name: "get_file_metadata",
234
- description: DROPBOX_SHARED_URL
235
- ? "Retrieve metadata (size, modified date) for a file inside the shared folder. Path is relative to the shared root."
236
- : "Retrieve metadata (size, modified date) for a file at an absolute Dropbox path.",
231
+ description: "Retrieve size/modified date for a file.",
237
232
  inputSchema: {
238
233
  type: "object",
239
- properties: {
240
- path: {
241
- type: "string", description: DROPBOX_SHARED_URL
242
- ? "Relative path to the file inside the shared folder."
243
- : "Exact Dropbox file path (e.g., '/Data/file.csv')."
244
- }
245
- },
234
+ properties: { path: { type: "string" } },
246
235
  required: ["path"]
247
236
  }
248
237
  },
@@ -251,64 +240,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
251
240
  description: "Display the schema (columns/headers) of a CSV or TSV file.",
252
241
  inputSchema: {
253
242
  type: "object",
254
- properties: {
255
- identifier: {
256
- type: "string", description: DROPBOX_SHARED_URL
257
- ? "Relative path to the table file (e.g., 'data.csv') or a shared link URL."
258
- : "Dropbox path (e.g., '/Data/file.csv') or shared link URL."
259
- }
260
- },
243
+ properties: { identifier: { type: "string" } },
261
244
  required: ["identifier"]
262
245
  }
263
246
  },
264
247
  {
265
248
  name: "read_data",
266
- description: "Download and read the content of a text file (CSV, JSON, TXT, etc.).",
249
+ description: "Download and read the content of a text file. Truncated if over 100k chars.",
267
250
  inputSchema: {
268
251
  type: "object",
269
- properties: {
270
- identifier: {
271
- type: "string", description: DROPBOX_SHARED_URL
272
- ? "Relative path (e.g., 'data.csv') or a shared link URL."
273
- : "Dropbox path (e.g., '/Data/file.csv') or shared link URL."
274
- }
275
- },
252
+ properties: { identifier: { type: "string" } },
276
253
  required: ["identifier"]
277
254
  }
278
255
  }
279
256
  ]
280
257
  }));
281
258
  // ------------------------------------------------------------------
282
- // 6. Tool Handlers
259
+ // 6. Tool Handlers (Using direct limited wrapper)
283
260
  // ------------------------------------------------------------------
284
261
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
285
262
  try {
286
263
  const name = request.params.name;
287
264
  const args = request.params.arguments || {};
288
- // --- list_folder ---
289
265
  if (name === "list_folder") {
290
- let path = args.path || "";
266
+ const rawPath = args.path || "";
291
267
  let requestArgs;
292
268
  if (DROPBOX_SHARED_URL) {
293
- path = normalizeSharedPath(path);
294
269
  requestArgs = {
295
- path: path,
270
+ path: formatDropboxPath(rawPath),
296
271
  shared_link: { url: DROPBOX_SHARED_URL }
297
272
  };
298
273
  }
299
274
  else {
300
- // Absolute path: if "/" then empty string for root
301
- requestArgs = { path: path === "/" ? "" : path };
275
+ requestArgs = { path: formatDropboxPath(rawPath) };
302
276
  }
303
- const response = await client.filesListFolder(requestArgs);
277
+ const response = await limited(() => client.filesListFolder(requestArgs));
304
278
  const entries = response.result.entries.map((e) => `[${e['.tag'].toUpperCase()}] ${e.name} (Path: ${e.path_display})`);
305
279
  return {
306
280
  content: [{ type: "text", text: entries.join("\n") || "Folder is empty." }]
307
281
  };
308
282
  }
309
- // --- search_files ---
310
283
  if (name === "search_files") {
311
- const response = await client.filesSearchV2({ query: args.query });
284
+ const response = await limited(() => client.filesSearchV2({ query: args.query }));
312
285
  const matches = response.result.matches.map((m) => {
313
286
  const meta = m.metadata.metadata;
314
287
  return `[${meta['.tag'].toUpperCase()}] ${meta.name} (Path: ${meta.path_display})`;
@@ -317,102 +290,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
317
290
  content: [{ type: "text", text: matches.join("\n") || "No matches found." }]
318
291
  };
319
292
  }
320
- // --- get_file_metadata ---
321
293
  if (name === "get_file_metadata") {
322
294
  let response;
323
- const rawPath = args.path || "";
295
+ const path = formatDropboxPath(args.path || "");
324
296
  if (DROPBOX_SHARED_URL) {
325
- const path = normalizeSharedPath(rawPath);
326
- response = await client.sharingGetSharedLinkMetadata({
297
+ response = await limited(() => client.sharingGetSharedLinkMetadata({
327
298
  url: DROPBOX_SHARED_URL,
328
299
  path: path
329
- });
300
+ }));
330
301
  }
331
302
  else {
332
- response = await client.filesGetMetadata({ path: rawPath });
303
+ response = await limited(() => client.filesGetMetadata({ path: path }));
333
304
  }
334
305
  return {
335
306
  content: [{ type: "text", text: JSON.stringify(response.result, null, 2) }]
336
307
  };
337
308
  }
338
- // --- read_data ---
339
309
  if (name === "read_data") {
340
- try {
341
- const text = await downloadHelper(args.identifier);
342
- const truncated = text.length > 100000
343
- ? text.substring(0, 100000) + "\n... (truncated)"
344
- : text;
345
- return { content: [{ type: "text", text: truncated }] };
346
- }
347
- catch (err) {
348
- if (isNotFoundError(err)) {
349
- return {
350
- content: [{ type: "text", text: `File not found at "${args.identifier}".` }],
351
- isError: true
352
- };
353
- }
354
- if (isPermissionError(err)) {
355
- return {
356
- content: [{ type: "text", text: `Permission denied for "${args.identifier}".` }],
357
- isError: true
358
- };
359
- }
360
- if (isBadRequestError(err)) {
361
- return {
362
- content: [{ type: "text", text: `Invalid request. Check the path/identifier: "${args.identifier}".` }],
363
- isError: true
364
- };
365
- }
366
- if (err.message && err.message.includes("binary file")) {
367
- return { content: [{ type: "text", text: err.message }], isError: true };
368
- }
369
- throw err;
370
- }
310
+ const text = await downloadHelper(args.identifier);
311
+ const truncated = text.length > 100000
312
+ ? text.substring(0, 100000) + "\n... [WARNING: FILE TRUNCATED TO 100K CHARACTERS]"
313
+ : text;
314
+ return { content: [{ type: "text", text: truncated }] };
371
315
  }
372
- // --- describe_table ---
373
316
  if (name === "describe_table") {
374
- try {
375
- const text = await downloadHelper(args.identifier);
376
- const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
377
- if (lines.length === 0) {
378
- return { content: [{ type: "text", text: "File is empty." }] };
379
- }
380
- let headers;
381
- let totalRows = lines.length - 1;
382
- const firstLine = lines[0];
383
- const commaCount = (firstLine.match(/,/g) || []).length;
384
- if (commaCount > 0) {
385
- headers = parseCsvHeader(firstLine);
386
- if (headers.length < 2) {
387
- headers = lines.map((_, i) => `Column ${i + 1}`);
388
- totalRows = lines.length;
389
- }
390
- }
391
- else {
392
- headers = lines.map((_, i) => `Column ${i + 1}`);
393
- totalRows = lines.length;
394
- }
395
- const schemaStr = `Columns detected:\n- ${headers.join('\n- ')}\n\nTotal rows: ${totalRows}`;
396
- return { content: [{ type: "text", text: schemaStr }] };
317
+ // Note: This still downloads the full file to read the first line.
318
+ // Be careful running this on files >50MB as it may cause timeouts.
319
+ const text = await downloadHelper(args.identifier);
320
+ const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
321
+ if (lines.length === 0)
322
+ return { content: [{ type: "text", text: "File is empty." }] };
323
+ let headers;
324
+ const firstLine = lines[0];
325
+ if ((firstLine.match(/,/g) || []).length > 0) {
326
+ headers = parseCsvHeader(firstLine);
397
327
  }
398
- catch (err) {
399
- if (isNotFoundError(err)) {
400
- return {
401
- content: [{ type: "text", text: `File not found at "${args.identifier}".` }],
402
- isError: true
403
- };
404
- }
405
- if (isPermissionError(err)) {
406
- return {
407
- content: [{ type: "text", text: `Permission denied for "${args.identifier}".` }],
408
- isError: true
409
- };
410
- }
411
- if (err.message && err.message.includes("binary file")) {
412
- return { content: [{ type: "text", text: err.message }], isError: true };
413
- }
414
- throw err;
328
+ else {
329
+ headers = lines.map((_, i) => `Column ${i + 1}`);
415
330
  }
331
+ const schemaStr = `Columns detected:\n- ${headers.join('\n- ')}\n\nTotal rows: ${lines.length - 1}`;
332
+ return { content: [{ type: "text", text: schemaStr }] };
416
333
  }
417
334
  throw new Error(`Unknown tool: ${name}`);
418
335
  }
package/index.ts CHANGED
@@ -6,7 +6,7 @@ import { Dropbox } from "dropbox";
6
6
  import "dotenv/config";
7
7
 
8
8
  // ------------------------------------------------------------------
9
- // 1. Rate Limiter
9
+ // 1. Rate Limiter (Fixed: Removed dangerous Proxy)
10
10
  // ------------------------------------------------------------------
11
11
  const MAX_CONCURRENT = 1;
12
12
  const MIN_GAP_MS = 1000;
@@ -62,18 +62,6 @@ async function limited<T>(fn: () => Promise<T>): Promise<T> {
62
62
  }
63
63
  }
64
64
 
65
- function throttleClient<T extends object>(client: T): T {
66
- return new Proxy(client, {
67
- get(target, prop, receiver) {
68
- const val = Reflect.get(target, prop, receiver);
69
- if (typeof val === "function") {
70
- return (...args: any[]) => limited(() => val.apply(target, args));
71
- }
72
- return val;
73
- },
74
- });
75
- }
76
-
77
65
  // ------------------------------------------------------------------
78
66
  // 2. Error Handling
79
67
  // ------------------------------------------------------------------
@@ -100,101 +88,121 @@ function isBadRequestError(err: any): boolean {
100
88
  // ------------------------------------------------------------------
101
89
  const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
102
90
  const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
103
- const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL; // optional
91
+ const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL;
92
+ const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET; // Highly recommended for token refresh
104
93
 
105
94
  if (!DROPBOX_APP_KEY || !DROPBOX_REFRESH_TOKEN) {
106
- console.error("Missing required environment variables:");
107
- console.error(" - DROPBOX_APP_KEY");
108
- console.error(" - DROPBOX_REFRESH_TOKEN");
109
- console.error("Note: DROPBOX_SHARED_URL is optional.");
95
+ console.error("Missing required environment variables: DROPBOX_APP_KEY, DROPBOX_REFRESH_TOKEN");
110
96
  process.exit(1);
111
97
  }
112
98
 
113
- const rawClient = new Dropbox({
99
+ // Instantiate direct client (no proxy)
100
+ const client = new Dropbox({
114
101
  clientId: DROPBOX_APP_KEY,
102
+ clientSecret: DROPBOX_APP_SECRET, // Needed if your Dropbox app is configured with a secret
115
103
  refreshToken: DROPBOX_REFRESH_TOKEN,
116
104
  });
117
- const client = throttleClient(rawClient);
118
105
 
119
106
  const server = new Server(
120
- { name: "dropbox-agent-mcp", version: "2.0.0" },
107
+ { name: "dropbox-agent-mcp", version: "2.0.1" },
121
108
  { capabilities: { tools: {} } }
122
109
  );
123
110
 
124
111
  // ------------------------------------------------------------------
125
- // 4. Helpers
112
+ // 4. Helpers (Fixed Pathing & Node 18+ Blobs)
126
113
  // ------------------------------------------------------------------
127
114
 
128
- /** Normalise a path for use with a shared link: strip leading "/" and handle root. */
129
- function normalizeSharedPath(path: string): string {
130
- if (!path || path === "/") return "";
131
- return path.replace(/^\/+/, "");
115
+ import officeParser from 'officeparser';
116
+ import * as fs from 'fs/promises';
117
+ import * as path from 'path';
118
+ import * as os from 'os';
119
+
120
+ // ------------------------------------------------------------------
121
+ // 4. Helpers
122
+ // ------------------------------------------------------------------
123
+
124
+ function formatDropboxPath(pathStr: string): string {
125
+ if (!pathStr || pathStr === "/" || pathStr.trim() === "") return "";
126
+ return pathStr.startsWith("/") ? pathStr : `/${pathStr}`;
132
127
  }
133
128
 
134
- /** Check if a filename likely belongs to a text file. */
135
- function isTextFile(pathOrName: string): boolean {
129
+ function isSupportedFile(pathOrName: string): boolean {
136
130
  const fileName = pathOrName.split('/').pop() || '';
137
131
  const ext = fileName.split('.').pop()?.toLowerCase() || '';
138
- const textExtensions = [
132
+ if (fileName === ext) return true;
133
+
134
+ const supportedExtensions = [
139
135
  'csv', 'tsv', 'txt', 'json', 'md', 'log',
140
136
  'xml', 'yaml', 'yml', 'html', 'htm', 'css',
141
- 'js', 'ts', 'py', 'java', 'c', 'cpp', 'h'
137
+ 'js', 'ts', 'py', 'java', 'c', 'cpp', 'h',
138
+ 'pdf', 'docx', 'pptx', 'xlsx', 'xls', 'doc', 'ppt', 'odt', 'odp', 'ods'
142
139
  ];
143
- return textExtensions.includes(ext);
140
+ return supportedExtensions.includes(ext);
144
141
  }
145
142
 
146
- /**
147
- * Download a file from Dropbox.
148
- * - If DROPBOX_SHARED_URL is set, uses shared link APIs with relative paths.
149
- * - Otherwise, uses the regular filesDownload with absolute paths.
150
- * Returns UTF‑8 text, or throws if binary.
151
- */
152
143
  async function downloadHelper(identifier: string): Promise<string> {
153
144
  let result: any;
154
145
  let fileName: string | undefined;
155
146
 
156
- // If the identifier is a full URL, we always treat it as a shared link.
157
147
  if (identifier.startsWith("http://") || identifier.startsWith("https://")) {
158
- const response = await client.sharingGetSharedLinkFile({ url: identifier });
148
+ const response = await limited(() => client.sharingGetSharedLinkFile({ url: identifier }));
159
149
  result = response.result;
160
150
  fileName = result.name || identifier.split('/').pop();
161
- }
162
- // Otherwise, decide based on DROPBOX_SHARED_URL.
163
- else if (DROPBOX_SHARED_URL) {
164
- // Shared link mode – identifier is a relative path.
165
- const path = normalizeSharedPath(identifier);
166
- const response = await client.sharingGetSharedLinkFile({
151
+ } else if (DROPBOX_SHARED_URL) {
152
+ const reqPath = formatDropboxPath(identifier);
153
+ const response = await limited(() => client.sharingGetSharedLinkFile({
167
154
  url: DROPBOX_SHARED_URL,
168
- path: path,
169
- });
155
+ path: reqPath,
156
+ }));
170
157
  result = response.result;
171
158
  fileName = result.name || identifier;
172
159
  } else {
173
- // Regular mode identifier is an absolute path.
174
- const response = await client.filesDownload({ path: identifier });
160
+ const reqPath = formatDropboxPath(identifier);
161
+ const response = await limited(() => client.filesDownload({ path: reqPath }));
175
162
  result = response.result;
176
163
  fileName = result.name || identifier;
177
164
  }
178
165
 
179
- if (!result.fileBinary) {
166
+ const fileRef = fileName || identifier;
167
+ if (!isSupportedFile(fileRef)) {
168
+ throw new Error(`File "${fileRef}" appears to be an unsupported file format.`);
169
+ }
170
+
171
+ let fileBuffer: Buffer;
172
+ if (result.fileBinary) {
173
+ fileBuffer = Buffer.from(result.fileBinary);
174
+ } else if (result.fileBlob) {
175
+ const arrayBuffer = await result.fileBlob.arrayBuffer();
176
+ fileBuffer = Buffer.from(arrayBuffer);
177
+ } else {
180
178
  throw new Error("Download succeeded but no file content was returned.");
181
179
  }
182
180
 
183
- const fileRef = fileName || identifier;
184
- if (!isTextFile(fileRef)) {
185
- throw new Error(
186
- `File "${fileRef}" appears to be a binary file. ` +
187
- `This tool only supports text‑based files (CSV, JSON, TXT, etc.).`
181
+ const ext = fileRef.split('.').pop()?.toLowerCase() || '';
182
+ const binaryExtensions = ['pdf', 'docx', 'pptx', 'xlsx', 'xls', 'doc', 'ppt', 'odt', 'odp', 'ods'];
183
+
184
+ if (binaryExtensions.includes(ext)) {
185
+ const tempFilePath = path.join(
186
+ os.tmpdir(),
187
+ `temp_${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`
188
188
  );
189
+
190
+ try {
191
+ await fs.writeFile(tempFilePath, fileBuffer);
192
+ // officeParser is called from the default import
193
+ const ast = await officeParser.parseOffice(fileBuffer);
194
+
195
+ const result = await ast.to('md');
196
+ return (result.value as string) || "Document parsed, but it appears to be empty.";
197
+ } catch (error: any) {
198
+ throw new Error(`Failed to parse binary document: ${error.message}`);
199
+ } finally {
200
+ await fs.unlink(tempFilePath).catch(() => { });
201
+ }
189
202
  }
190
203
 
191
- return Buffer.from(result.fileBinary).toString('utf-8');
204
+ return fileBuffer.toString('utf-8');
192
205
  }
193
-
194
- /**
195
- * Parse a CSV header line, handling quoted fields and trimming.
196
- * Returns an array of clean column names.
197
- */
198
206
  function parseCsvHeader(headerLine: string): string[] {
199
207
  const fields: string[] = [];
200
208
  let current = '';
@@ -223,47 +231,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
223
231
  tools: [
224
232
  {
225
233
  name: "list_folder",
226
- description: DROPBOX_SHARED_URL
227
- ? "List folders and files inside the shared Dropbox folder. Path must be relative to the shared root (e.g., '' for root, 'Data' for /Data)."
228
- : "List folders and files in a specific Dropbox path (absolute, e.g., '/Data' or '' for root).",
234
+ description: "List folders and files. Use '' for root, or '/FolderName' for subfolders.",
229
235
  inputSchema: {
230
236
  type: "object",
231
- properties: {
232
- path: {
233
- type: "string",
234
- description: DROPBOX_SHARED_URL
235
- ? "Relative path inside the shared folder (e.g., '' or 'Data')."
236
- : "Absolute path to list (e.g., '' for root or '/Data')."
237
- }
238
- },
237
+ properties: { path: { type: "string" } },
239
238
  required: ["path"]
240
239
  }
241
240
  },
242
241
  {
243
242
  name: "search_files",
244
- description: "Search for files by name, extension, or content across the entire Dropbox account.",
243
+ description: "Search for files by name, extension, or content.",
245
244
  inputSchema: {
246
245
  type: "object",
247
- properties: {
248
- query: { type: "string", description: "The search query (e.g., 'sales report' or '.csv')" }
249
- },
246
+ properties: { query: { type: "string" } },
250
247
  required: ["query"]
251
248
  }
252
249
  },
253
250
  {
254
251
  name: "get_file_metadata",
255
- description: DROPBOX_SHARED_URL
256
- ? "Retrieve metadata (size, modified date) for a file inside the shared folder. Path is relative to the shared root."
257
- : "Retrieve metadata (size, modified date) for a file at an absolute Dropbox path.",
252
+ description: "Retrieve size/modified date for a file.",
258
253
  inputSchema: {
259
254
  type: "object",
260
- properties: {
261
- path: {
262
- type: "string", description: DROPBOX_SHARED_URL
263
- ? "Relative path to the file inside the shared folder."
264
- : "Exact Dropbox file path (e.g., '/Data/file.csv')."
265
- }
266
- },
255
+ properties: { path: { type: "string" } },
267
256
  required: ["path"]
268
257
  }
269
258
  },
@@ -272,28 +261,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
272
261
  description: "Display the schema (columns/headers) of a CSV or TSV file.",
273
262
  inputSchema: {
274
263
  type: "object",
275
- properties: {
276
- identifier: {
277
- type: "string", description: DROPBOX_SHARED_URL
278
- ? "Relative path to the table file (e.g., 'data.csv') or a shared link URL."
279
- : "Dropbox path (e.g., '/Data/file.csv') or shared link URL."
280
- }
281
- },
264
+ properties: { identifier: { type: "string" } },
282
265
  required: ["identifier"]
283
266
  }
284
267
  },
285
268
  {
286
269
  name: "read_data",
287
- description: "Download and read the content of a text file (CSV, JSON, TXT, etc.).",
270
+ description: "Download and read the content of a text file. Truncated if over 100k chars.",
288
271
  inputSchema: {
289
272
  type: "object",
290
- properties: {
291
- identifier: {
292
- type: "string", description: DROPBOX_SHARED_URL
293
- ? "Relative path (e.g., 'data.csv') or a shared link URL."
294
- : "Dropbox path (e.g., '/Data/file.csv') or shared link URL."
295
- }
296
- },
273
+ properties: { identifier: { type: "string" } },
297
274
  required: ["identifier"]
298
275
  }
299
276
  }
@@ -301,30 +278,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
301
278
  }));
302
279
 
303
280
  // ------------------------------------------------------------------
304
- // 6. Tool Handlers
281
+ // 6. Tool Handlers (Using direct limited wrapper)
305
282
  // ------------------------------------------------------------------
306
283
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
307
284
  try {
308
285
  const name = request.params.name;
309
286
  const args = request.params.arguments || {};
310
287
 
311
- // --- list_folder ---
312
288
  if (name === "list_folder") {
313
- let path = (args.path as string) || "";
289
+ const rawPath = (args.path as string) || "";
314
290
  let requestArgs: any;
315
291
 
316
292
  if (DROPBOX_SHARED_URL) {
317
- path = normalizeSharedPath(path);
318
293
  requestArgs = {
319
- path: path,
294
+ path: formatDropboxPath(rawPath),
320
295
  shared_link: { url: DROPBOX_SHARED_URL }
321
296
  };
322
297
  } else {
323
- // Absolute path: if "/" then empty string for root
324
- requestArgs = { path: path === "/" ? "" : path };
298
+ requestArgs = { path: formatDropboxPath(rawPath) };
325
299
  }
326
300
 
327
- const response = await client.filesListFolder(requestArgs);
301
+ const response = await limited(() => client.filesListFolder(requestArgs));
328
302
  const entries = response.result.entries.map((e: any) =>
329
303
  `[${e['.tag'].toUpperCase()}] ${e.name} (Path: ${e.path_display})`
330
304
  );
@@ -333,11 +307,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
333
307
  };
334
308
  }
335
309
 
336
- // --- search_files ---
337
310
  if (name === "search_files") {
338
- const response = await client.filesSearchV2({ query: args.query as string });
311
+ const response = await limited(() => client.filesSearchV2({ query: args.query as string }));
339
312
  const matches = response.result.matches.map((m: any) => {
340
- const meta = (m.metadata as any).metadata;
313
+ const meta = m.metadata.metadata;
341
314
  return `[${meta['.tag'].toUpperCase()}] ${meta.name} (Path: ${meta.path_display})`;
342
315
  });
343
316
  return {
@@ -345,106 +318,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
345
318
  };
346
319
  }
347
320
 
348
- // --- get_file_metadata ---
349
321
  if (name === "get_file_metadata") {
350
322
  let response;
351
- const rawPath = (args.path as string) || "";
323
+ const path = formatDropboxPath((args.path as string) || "");
352
324
 
353
325
  if (DROPBOX_SHARED_URL) {
354
- const path = normalizeSharedPath(rawPath);
355
- response = await client.sharingGetSharedLinkMetadata({
326
+ response = await limited(() => client.sharingGetSharedLinkMetadata({
356
327
  url: DROPBOX_SHARED_URL,
357
328
  path: path
358
- });
329
+ }));
359
330
  } else {
360
- response = await client.filesGetMetadata({ path: rawPath });
331
+ response = await limited(() => client.filesGetMetadata({ path: path }));
361
332
  }
362
-
363
333
  return {
364
334
  content: [{ type: "text", text: JSON.stringify(response.result, null, 2) }]
365
335
  };
366
336
  }
367
337
 
368
- // --- read_data ---
369
338
  if (name === "read_data") {
370
- try {
371
- const text = await downloadHelper(args.identifier as string);
372
- const truncated = text.length > 100000
373
- ? text.substring(0, 100000) + "\n... (truncated)"
374
- : text;
375
- return { content: [{ type: "text", text: truncated }] };
376
- } catch (err: any) {
377
- if (isNotFoundError(err)) {
378
- return {
379
- content: [{ type: "text", text: `File not found at "${args.identifier}".` }],
380
- isError: true
381
- };
382
- }
383
- if (isPermissionError(err)) {
384
- return {
385
- content: [{ type: "text", text: `Permission denied for "${args.identifier}".` }],
386
- isError: true
387
- };
388
- }
389
- if (isBadRequestError(err)) {
390
- return {
391
- content: [{ type: "text", text: `Invalid request. Check the path/identifier: "${args.identifier}".` }],
392
- isError: true
393
- };
394
- }
395
- if (err.message && err.message.includes("binary file")) {
396
- return { content: [{ type: "text", text: err.message }], isError: true };
397
- }
398
- throw err;
399
- }
339
+ const text = await downloadHelper(args.identifier as string);
340
+ const truncated = text.length > 100000
341
+ ? text.substring(0, 100000) + "\n... [WARNING: FILE TRUNCATED TO 100K CHARACTERS]"
342
+ : text;
343
+ return { content: [{ type: "text", text: truncated }] };
400
344
  }
401
345
 
402
- // --- describe_table ---
403
346
  if (name === "describe_table") {
404
- try {
405
- const text = await downloadHelper(args.identifier as string);
406
- const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
407
- if (lines.length === 0) {
408
- return { content: [{ type: "text", text: "File is empty." }] };
409
- }
410
-
411
- let headers: string[];
412
- let totalRows = lines.length - 1;
413
- const firstLine = lines[0];
414
- const commaCount = (firstLine.match(/,/g) || []).length;
415
-
416
- if (commaCount > 0) {
417
- headers = parseCsvHeader(firstLine);
418
- if (headers.length < 2) {
419
- headers = lines.map((_, i) => `Column ${i + 1}`);
420
- totalRows = lines.length;
421
- }
422
- } else {
423
- headers = lines.map((_, i) => `Column ${i + 1}`);
424
- totalRows = lines.length;
425
- }
426
-
427
- const schemaStr =
428
- `Columns detected:\n- ${headers.join('\n- ')}\n\nTotal rows: ${totalRows}`;
429
- return { content: [{ type: "text", text: schemaStr }] };
430
- } catch (err: any) {
431
- if (isNotFoundError(err)) {
432
- return {
433
- content: [{ type: "text", text: `File not found at "${args.identifier}".` }],
434
- isError: true
435
- };
436
- }
437
- if (isPermissionError(err)) {
438
- return {
439
- content: [{ type: "text", text: `Permission denied for "${args.identifier}".` }],
440
- isError: true
441
- };
442
- }
443
- if (err.message && err.message.includes("binary file")) {
444
- return { content: [{ type: "text", text: err.message }], isError: true };
445
- }
446
- throw err;
347
+ // Note: This still downloads the full file to read the first line.
348
+ // Be careful running this on files >50MB as it may cause timeouts.
349
+ const text = await downloadHelper(args.identifier as string);
350
+ const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
351
+ if (lines.length === 0) return { content: [{ type: "text", text: "File is empty." }] };
352
+
353
+ let headers: string[];
354
+ const firstLine = lines[0];
355
+ if ((firstLine.match(/,/g) || []).length > 0) {
356
+ headers = parseCsvHeader(firstLine);
357
+ } else {
358
+ headers = lines.map((_, i) => `Column ${i + 1}`);
447
359
  }
360
+
361
+ const schemaStr = `Columns detected:\n- ${headers.join('\n- ')}\n\nTotal rows: ${lines.length - 1}`;
362
+ return { content: [{ type: "text", text: schemaStr }] };
448
363
  }
449
364
 
450
365
  throw new Error(`Unknown tool: ${name}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dropbox-mcp-server",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "dropbox-mcp-server": "dist/index.js"
@@ -13,7 +13,11 @@
13
13
  "@modelcontextprotocol/sdk": "^1.0.0",
14
14
  "dotenv": "^16.4.5",
15
15
  "dropbox": "^10.34.0",
16
- "dropbox-mcp-server": "^2.0.0"
16
+ "dropbox-mcp-server": "^2.0.0",
17
+ "fs": "^0.0.1-security",
18
+ "officeparser": "^7.3.0",
19
+ "os": "^0.1.2",
20
+ "path": "^0.12.7"
17
21
  },
18
22
  "devDependencies": {
19
23
  "typescript": "^5.9.3"