dropbox-mcp-server 2.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/dist/index.js +222 -52
- package/index.ts +240 -51
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -84,41 +84,143 @@ function isNotFoundError(err) {
|
|
|
84
84
|
return false;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
function isPermissionError(err) {
|
|
88
|
+
return err?.status === 403;
|
|
89
|
+
}
|
|
90
|
+
function isBadRequestError(err) {
|
|
91
|
+
return err?.status === 400;
|
|
92
|
+
}
|
|
87
93
|
// ------------------------------------------------------------------
|
|
88
|
-
// 3.
|
|
94
|
+
// 3. Environment & Client
|
|
89
95
|
// ------------------------------------------------------------------
|
|
90
96
|
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
|
|
91
97
|
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
|
|
92
|
-
const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL;
|
|
98
|
+
const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL; // optional
|
|
93
99
|
if (!DROPBOX_APP_KEY || !DROPBOX_REFRESH_TOKEN) {
|
|
94
|
-
console.error("Missing
|
|
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.");
|
|
95
104
|
process.exit(1);
|
|
96
105
|
}
|
|
97
106
|
const rawClient = new Dropbox({
|
|
98
107
|
clientId: DROPBOX_APP_KEY,
|
|
99
|
-
refreshToken: DROPBOX_REFRESH_TOKEN
|
|
108
|
+
refreshToken: DROPBOX_REFRESH_TOKEN,
|
|
100
109
|
});
|
|
101
110
|
const client = throttleClient(rawClient);
|
|
102
|
-
const server = new Server({ name: "dropbox-agent-mcp", version: "2.
|
|
111
|
+
const server = new Server({ name: "dropbox-agent-mcp", version: "2.0.0" }, { capabilities: { tools: {} } });
|
|
112
|
+
// ------------------------------------------------------------------
|
|
113
|
+
// 4. Helpers
|
|
114
|
+
// ------------------------------------------------------------------
|
|
115
|
+
/** Normalise a path for use with a shared link: strip leading "/" and handle root. */
|
|
116
|
+
function normalizeSharedPath(path) {
|
|
117
|
+
if (!path || path === "/")
|
|
118
|
+
return "";
|
|
119
|
+
return path.replace(/^\/+/, "");
|
|
120
|
+
}
|
|
121
|
+
/** Check if a filename likely belongs to a text file. */
|
|
122
|
+
function isTextFile(pathOrName) {
|
|
123
|
+
const fileName = pathOrName.split('/').pop() || '';
|
|
124
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
125
|
+
const textExtensions = [
|
|
126
|
+
'csv', 'tsv', 'txt', 'json', 'md', 'log',
|
|
127
|
+
'xml', 'yaml', 'yml', 'html', 'htm', 'css',
|
|
128
|
+
'js', 'ts', 'py', 'java', 'c', 'cpp', 'h'
|
|
129
|
+
];
|
|
130
|
+
return textExtensions.includes(ext);
|
|
131
|
+
}
|
|
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
|
+
async function downloadHelper(identifier) {
|
|
139
|
+
let result;
|
|
140
|
+
let fileName;
|
|
141
|
+
// If the identifier is a full URL, we always treat it as a shared link.
|
|
142
|
+
if (identifier.startsWith("http://") || identifier.startsWith("https://")) {
|
|
143
|
+
const response = await client.sharingGetSharedLinkFile({ url: identifier });
|
|
144
|
+
result = response.result;
|
|
145
|
+
fileName = result.name || identifier.split('/').pop();
|
|
146
|
+
}
|
|
147
|
+
// Otherwise, decide based on DROPBOX_SHARED_URL.
|
|
148
|
+
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({
|
|
152
|
+
url: DROPBOX_SHARED_URL,
|
|
153
|
+
path: path,
|
|
154
|
+
});
|
|
155
|
+
result = response.result;
|
|
156
|
+
fileName = result.name || identifier;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Regular mode – identifier is an absolute path.
|
|
160
|
+
const response = await client.filesDownload({ path: identifier });
|
|
161
|
+
result = response.result;
|
|
162
|
+
fileName = result.name || identifier;
|
|
163
|
+
}
|
|
164
|
+
if (!result.fileBinary) {
|
|
165
|
+
throw new Error("Download succeeded but no file content was returned.");
|
|
166
|
+
}
|
|
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.).`);
|
|
171
|
+
}
|
|
172
|
+
return Buffer.from(result.fileBinary).toString('utf-8');
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Parse a CSV header line, handling quoted fields and trimming.
|
|
176
|
+
* Returns an array of clean column names.
|
|
177
|
+
*/
|
|
178
|
+
function parseCsvHeader(headerLine) {
|
|
179
|
+
const fields = [];
|
|
180
|
+
let current = '';
|
|
181
|
+
let insideQuotes = false;
|
|
182
|
+
for (let i = 0; i < headerLine.length; i++) {
|
|
183
|
+
const ch = headerLine[i];
|
|
184
|
+
if (ch === '"') {
|
|
185
|
+
insideQuotes = !insideQuotes;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (ch === ',' && !insideQuotes) {
|
|
189
|
+
fields.push(current.trim());
|
|
190
|
+
current = '';
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
current += ch;
|
|
194
|
+
}
|
|
195
|
+
fields.push(current.trim());
|
|
196
|
+
return fields.map(f => f.replace(/^"|"$/g, '').trim());
|
|
197
|
+
}
|
|
103
198
|
// ------------------------------------------------------------------
|
|
104
|
-
//
|
|
199
|
+
// 5. Tool Definitions
|
|
105
200
|
// ------------------------------------------------------------------
|
|
106
201
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
107
202
|
tools: [
|
|
108
203
|
{
|
|
109
204
|
name: "list_folder",
|
|
110
|
-
description:
|
|
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).",
|
|
111
208
|
inputSchema: {
|
|
112
209
|
type: "object",
|
|
113
210
|
properties: {
|
|
114
|
-
path: {
|
|
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
|
+
}
|
|
115
217
|
},
|
|
116
218
|
required: ["path"]
|
|
117
219
|
}
|
|
118
220
|
},
|
|
119
221
|
{
|
|
120
222
|
name: "search_files",
|
|
121
|
-
description: "Search for files by name, extension, or content across the Dropbox account.",
|
|
223
|
+
description: "Search for files by name, extension, or content across the entire Dropbox account.",
|
|
122
224
|
inputSchema: {
|
|
123
225
|
type: "object",
|
|
124
226
|
properties: {
|
|
@@ -129,33 +231,47 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
129
231
|
},
|
|
130
232
|
{
|
|
131
233
|
name: "get_file_metadata",
|
|
132
|
-
description:
|
|
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.",
|
|
133
237
|
inputSchema: {
|
|
134
238
|
type: "object",
|
|
135
239
|
properties: {
|
|
136
|
-
path: {
|
|
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
|
+
}
|
|
137
245
|
},
|
|
138
246
|
required: ["path"]
|
|
139
247
|
}
|
|
140
248
|
},
|
|
141
249
|
{
|
|
142
250
|
name: "describe_table",
|
|
143
|
-
description: "Display the schema (columns/headers) of a
|
|
251
|
+
description: "Display the schema (columns/headers) of a CSV or TSV file.",
|
|
144
252
|
inputSchema: {
|
|
145
253
|
type: "object",
|
|
146
254
|
properties: {
|
|
147
|
-
identifier: {
|
|
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
|
+
}
|
|
148
260
|
},
|
|
149
261
|
required: ["identifier"]
|
|
150
262
|
}
|
|
151
263
|
},
|
|
152
264
|
{
|
|
153
265
|
name: "read_data",
|
|
154
|
-
description: "Download and read
|
|
266
|
+
description: "Download and read the content of a text file (CSV, JSON, TXT, etc.).",
|
|
155
267
|
inputSchema: {
|
|
156
268
|
type: "object",
|
|
157
269
|
properties: {
|
|
158
|
-
identifier: {
|
|
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
|
+
}
|
|
159
275
|
},
|
|
160
276
|
required: ["identifier"]
|
|
161
277
|
}
|
|
@@ -163,83 +279,137 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
163
279
|
]
|
|
164
280
|
}));
|
|
165
281
|
// ------------------------------------------------------------------
|
|
166
|
-
//
|
|
282
|
+
// 6. Tool Handlers
|
|
167
283
|
// ------------------------------------------------------------------
|
|
168
|
-
async function downloadHelper(identifier) {
|
|
169
|
-
let result;
|
|
170
|
-
if (identifier.startsWith("http://") || identifier.startsWith("https://")) {
|
|
171
|
-
const response = await client.sharingGetSharedLinkFile({ url: identifier });
|
|
172
|
-
result = response.result;
|
|
173
|
-
}
|
|
174
|
-
else if (DROPBOX_SHARED_URL) {
|
|
175
|
-
const response = await client.sharingGetSharedLinkFile({ url: DROPBOX_SHARED_URL, path: identifier });
|
|
176
|
-
result = response.result;
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
const response = await client.filesDownload({ path: identifier });
|
|
180
|
-
result = response.result;
|
|
181
|
-
}
|
|
182
|
-
if (!result.fileBinary)
|
|
183
|
-
throw new Error("Download succeeded but no file content was returned.");
|
|
184
|
-
return Buffer.from(result.fileBinary).toString('utf-8');
|
|
185
|
-
}
|
|
186
284
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
187
285
|
try {
|
|
188
286
|
const name = request.params.name;
|
|
189
287
|
const args = request.params.arguments || {};
|
|
288
|
+
// --- list_folder ---
|
|
190
289
|
if (name === "list_folder") {
|
|
191
|
-
|
|
192
|
-
|
|
290
|
+
let path = args.path || "";
|
|
291
|
+
let requestArgs;
|
|
193
292
|
if (DROPBOX_SHARED_URL) {
|
|
194
|
-
|
|
293
|
+
path = normalizeSharedPath(path);
|
|
294
|
+
requestArgs = {
|
|
295
|
+
path: path,
|
|
296
|
+
shared_link: { url: DROPBOX_SHARED_URL }
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
// Absolute path: if "/" then empty string for root
|
|
301
|
+
requestArgs = { path: path === "/" ? "" : path };
|
|
195
302
|
}
|
|
196
303
|
const response = await client.filesListFolder(requestArgs);
|
|
197
|
-
const entries = response.result.entries.map(e => `[${e['.tag'].toUpperCase()}] ${e.name} (Path: ${e.path_display})`);
|
|
198
|
-
return {
|
|
304
|
+
const entries = response.result.entries.map((e) => `[${e['.tag'].toUpperCase()}] ${e.name} (Path: ${e.path_display})`);
|
|
305
|
+
return {
|
|
306
|
+
content: [{ type: "text", text: entries.join("\n") || "Folder is empty." }]
|
|
307
|
+
};
|
|
199
308
|
}
|
|
309
|
+
// --- search_files ---
|
|
200
310
|
if (name === "search_files") {
|
|
201
311
|
const response = await client.filesSearchV2({ query: args.query });
|
|
202
|
-
const matches = response.result.matches.map(m => {
|
|
312
|
+
const matches = response.result.matches.map((m) => {
|
|
203
313
|
const meta = m.metadata.metadata;
|
|
204
314
|
return `[${meta['.tag'].toUpperCase()}] ${meta.name} (Path: ${meta.path_display})`;
|
|
205
315
|
});
|
|
206
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
content: [{ type: "text", text: matches.join("\n") || "No matches found." }]
|
|
318
|
+
};
|
|
207
319
|
}
|
|
320
|
+
// --- get_file_metadata ---
|
|
208
321
|
if (name === "get_file_metadata") {
|
|
209
322
|
let response;
|
|
323
|
+
const rawPath = args.path || "";
|
|
210
324
|
if (DROPBOX_SHARED_URL) {
|
|
211
|
-
|
|
325
|
+
const path = normalizeSharedPath(rawPath);
|
|
326
|
+
response = await client.sharingGetSharedLinkMetadata({
|
|
327
|
+
url: DROPBOX_SHARED_URL,
|
|
328
|
+
path: path
|
|
329
|
+
});
|
|
212
330
|
}
|
|
213
331
|
else {
|
|
214
|
-
response = await client.filesGetMetadata({ path:
|
|
332
|
+
response = await client.filesGetMetadata({ path: rawPath });
|
|
215
333
|
}
|
|
216
|
-
return {
|
|
334
|
+
return {
|
|
335
|
+
content: [{ type: "text", text: JSON.stringify(response.result, null, 2) }]
|
|
336
|
+
};
|
|
217
337
|
}
|
|
338
|
+
// --- read_data ---
|
|
218
339
|
if (name === "read_data") {
|
|
219
340
|
try {
|
|
220
341
|
const text = await downloadHelper(args.identifier);
|
|
221
|
-
|
|
342
|
+
const truncated = text.length > 100000
|
|
343
|
+
? text.substring(0, 100000) + "\n... (truncated)"
|
|
344
|
+
: text;
|
|
345
|
+
return { content: [{ type: "text", text: truncated }] };
|
|
222
346
|
}
|
|
223
347
|
catch (err) {
|
|
224
348
|
if (isNotFoundError(err)) {
|
|
225
|
-
return {
|
|
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 };
|
|
226
368
|
}
|
|
227
369
|
throw err;
|
|
228
370
|
}
|
|
229
371
|
}
|
|
372
|
+
// --- describe_table ---
|
|
230
373
|
if (name === "describe_table") {
|
|
231
374
|
try {
|
|
232
375
|
const text = await downloadHelper(args.identifier);
|
|
233
376
|
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
|
|
234
|
-
if (lines.length === 0)
|
|
377
|
+
if (lines.length === 0) {
|
|
235
378
|
return { content: [{ type: "text", text: "File is empty." }] };
|
|
236
|
-
|
|
237
|
-
|
|
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}`;
|
|
238
396
|
return { content: [{ type: "text", text: schemaStr }] };
|
|
239
397
|
}
|
|
240
398
|
catch (err) {
|
|
241
399
|
if (isNotFoundError(err)) {
|
|
242
|
-
return {
|
|
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 };
|
|
243
413
|
}
|
|
244
414
|
throw err;
|
|
245
415
|
}
|
|
@@ -254,11 +424,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
254
424
|
}
|
|
255
425
|
});
|
|
256
426
|
// ------------------------------------------------------------------
|
|
257
|
-
//
|
|
427
|
+
// 7. Start the Server
|
|
258
428
|
// ------------------------------------------------------------------
|
|
259
429
|
async function run() {
|
|
260
430
|
const transport = new StdioServerTransport();
|
|
261
431
|
await server.connect(transport);
|
|
262
|
-
console.error(
|
|
432
|
+
console.error(`Dropbox MCP Server running on stdio ${DROPBOX_SHARED_URL ? '(shared link mode)' : '(full account mode)'}`);
|
|
263
433
|
}
|
|
264
434
|
run().catch(console.error);
|
package/index.ts
CHANGED
|
@@ -87,48 +87,161 @@ function isNotFoundError(err: any): boolean {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function isPermissionError(err: any): boolean {
|
|
91
|
+
return err?.status === 403;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isBadRequestError(err: any): boolean {
|
|
95
|
+
return err?.status === 400;
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
// ------------------------------------------------------------------
|
|
91
|
-
// 3.
|
|
99
|
+
// 3. Environment & Client
|
|
92
100
|
// ------------------------------------------------------------------
|
|
93
101
|
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
|
|
94
102
|
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
|
|
95
|
-
const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL;
|
|
103
|
+
const DROPBOX_SHARED_URL = process.env.DROPBOX_SHARED_URL; // optional
|
|
96
104
|
|
|
97
105
|
if (!DROPBOX_APP_KEY || !DROPBOX_REFRESH_TOKEN) {
|
|
98
|
-
console.error("Missing
|
|
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.");
|
|
99
110
|
process.exit(1);
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
const rawClient = new Dropbox({
|
|
103
114
|
clientId: DROPBOX_APP_KEY,
|
|
104
|
-
refreshToken: DROPBOX_REFRESH_TOKEN
|
|
115
|
+
refreshToken: DROPBOX_REFRESH_TOKEN,
|
|
105
116
|
});
|
|
106
117
|
const client = throttleClient(rawClient);
|
|
107
118
|
|
|
108
119
|
const server = new Server(
|
|
109
|
-
{ name: "dropbox-agent-mcp", version: "2.
|
|
120
|
+
{ name: "dropbox-agent-mcp", version: "2.0.0" },
|
|
110
121
|
{ capabilities: { tools: {} } }
|
|
111
122
|
);
|
|
112
123
|
|
|
113
124
|
// ------------------------------------------------------------------
|
|
114
|
-
// 4.
|
|
125
|
+
// 4. Helpers
|
|
126
|
+
// ------------------------------------------------------------------
|
|
127
|
+
|
|
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(/^\/+/, "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Check if a filename likely belongs to a text file. */
|
|
135
|
+
function isTextFile(pathOrName: string): boolean {
|
|
136
|
+
const fileName = pathOrName.split('/').pop() || '';
|
|
137
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
138
|
+
const textExtensions = [
|
|
139
|
+
'csv', 'tsv', 'txt', 'json', 'md', 'log',
|
|
140
|
+
'xml', 'yaml', 'yml', 'html', 'htm', 'css',
|
|
141
|
+
'js', 'ts', 'py', 'java', 'c', 'cpp', 'h'
|
|
142
|
+
];
|
|
143
|
+
return textExtensions.includes(ext);
|
|
144
|
+
}
|
|
145
|
+
|
|
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
|
+
async function downloadHelper(identifier: string): Promise<string> {
|
|
153
|
+
let result: any;
|
|
154
|
+
let fileName: string | undefined;
|
|
155
|
+
|
|
156
|
+
// If the identifier is a full URL, we always treat it as a shared link.
|
|
157
|
+
if (identifier.startsWith("http://") || identifier.startsWith("https://")) {
|
|
158
|
+
const response = await client.sharingGetSharedLinkFile({ url: identifier });
|
|
159
|
+
result = response.result;
|
|
160
|
+
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({
|
|
167
|
+
url: DROPBOX_SHARED_URL,
|
|
168
|
+
path: path,
|
|
169
|
+
});
|
|
170
|
+
result = response.result;
|
|
171
|
+
fileName = result.name || identifier;
|
|
172
|
+
} else {
|
|
173
|
+
// Regular mode – identifier is an absolute path.
|
|
174
|
+
const response = await client.filesDownload({ path: identifier });
|
|
175
|
+
result = response.result;
|
|
176
|
+
fileName = result.name || identifier;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!result.fileBinary) {
|
|
180
|
+
throw new Error("Download succeeded but no file content was returned.");
|
|
181
|
+
}
|
|
182
|
+
|
|
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.).`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Buffer.from(result.fileBinary).toString('utf-8');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse a CSV header line, handling quoted fields and trimming.
|
|
196
|
+
* Returns an array of clean column names.
|
|
197
|
+
*/
|
|
198
|
+
function parseCsvHeader(headerLine: string): string[] {
|
|
199
|
+
const fields: string[] = [];
|
|
200
|
+
let current = '';
|
|
201
|
+
let insideQuotes = false;
|
|
202
|
+
for (let i = 0; i < headerLine.length; i++) {
|
|
203
|
+
const ch = headerLine[i];
|
|
204
|
+
if (ch === '"') {
|
|
205
|
+
insideQuotes = !insideQuotes;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (ch === ',' && !insideQuotes) {
|
|
209
|
+
fields.push(current.trim());
|
|
210
|
+
current = '';
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
current += ch;
|
|
214
|
+
}
|
|
215
|
+
fields.push(current.trim());
|
|
216
|
+
return fields.map(f => f.replace(/^"|"$/g, '').trim());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ------------------------------------------------------------------
|
|
220
|
+
// 5. Tool Definitions
|
|
115
221
|
// ------------------------------------------------------------------
|
|
116
222
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
117
223
|
tools: [
|
|
118
224
|
{
|
|
119
225
|
name: "list_folder",
|
|
120
|
-
description:
|
|
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).",
|
|
121
229
|
inputSchema: {
|
|
122
230
|
type: "object",
|
|
123
231
|
properties: {
|
|
124
|
-
path: {
|
|
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
|
+
}
|
|
125
238
|
},
|
|
126
239
|
required: ["path"]
|
|
127
240
|
}
|
|
128
241
|
},
|
|
129
242
|
{
|
|
130
243
|
name: "search_files",
|
|
131
|
-
description: "Search for files by name, extension, or content across the Dropbox account.",
|
|
244
|
+
description: "Search for files by name, extension, or content across the entire Dropbox account.",
|
|
132
245
|
inputSchema: {
|
|
133
246
|
type: "object",
|
|
134
247
|
properties: {
|
|
@@ -139,33 +252,47 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
139
252
|
},
|
|
140
253
|
{
|
|
141
254
|
name: "get_file_metadata",
|
|
142
|
-
description:
|
|
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.",
|
|
143
258
|
inputSchema: {
|
|
144
259
|
type: "object",
|
|
145
260
|
properties: {
|
|
146
|
-
path: {
|
|
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
|
+
}
|
|
147
266
|
},
|
|
148
267
|
required: ["path"]
|
|
149
268
|
}
|
|
150
269
|
},
|
|
151
270
|
{
|
|
152
271
|
name: "describe_table",
|
|
153
|
-
description: "Display the schema (columns/headers) of a
|
|
272
|
+
description: "Display the schema (columns/headers) of a CSV or TSV file.",
|
|
154
273
|
inputSchema: {
|
|
155
274
|
type: "object",
|
|
156
275
|
properties: {
|
|
157
|
-
identifier: {
|
|
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
|
+
}
|
|
158
281
|
},
|
|
159
282
|
required: ["identifier"]
|
|
160
283
|
}
|
|
161
284
|
},
|
|
162
285
|
{
|
|
163
286
|
name: "read_data",
|
|
164
|
-
description: "Download and read
|
|
287
|
+
description: "Download and read the content of a text file (CSV, JSON, TXT, etc.).",
|
|
165
288
|
inputSchema: {
|
|
166
289
|
type: "object",
|
|
167
290
|
properties: {
|
|
168
|
-
identifier: {
|
|
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
|
+
}
|
|
169
296
|
},
|
|
170
297
|
required: ["identifier"]
|
|
171
298
|
}
|
|
@@ -174,85 +301,147 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
174
301
|
}));
|
|
175
302
|
|
|
176
303
|
// ------------------------------------------------------------------
|
|
177
|
-
//
|
|
304
|
+
// 6. Tool Handlers
|
|
178
305
|
// ------------------------------------------------------------------
|
|
179
|
-
async function downloadHelper(identifier: string) {
|
|
180
|
-
let result: any;
|
|
181
|
-
if (identifier.startsWith("http://") || identifier.startsWith("https://")) {
|
|
182
|
-
const response = await client.sharingGetSharedLinkFile({ url: identifier });
|
|
183
|
-
result = response.result;
|
|
184
|
-
} else if (DROPBOX_SHARED_URL) {
|
|
185
|
-
const response = await client.sharingGetSharedLinkFile({ url: DROPBOX_SHARED_URL, path: identifier });
|
|
186
|
-
result = response.result;
|
|
187
|
-
} else {
|
|
188
|
-
const response = await client.filesDownload({ path: identifier });
|
|
189
|
-
result = response.result;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (!result.fileBinary) throw new Error("Download succeeded but no file content was returned.");
|
|
193
|
-
return Buffer.from(result.fileBinary).toString('utf-8');
|
|
194
|
-
}
|
|
195
|
-
|
|
196
306
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
197
307
|
try {
|
|
198
308
|
const name = request.params.name;
|
|
199
309
|
const args = request.params.arguments || {};
|
|
200
310
|
|
|
311
|
+
// --- list_folder ---
|
|
201
312
|
if (name === "list_folder") {
|
|
202
|
-
|
|
203
|
-
|
|
313
|
+
let path = (args.path as string) || "";
|
|
314
|
+
let requestArgs: any;
|
|
315
|
+
|
|
204
316
|
if (DROPBOX_SHARED_URL) {
|
|
205
|
-
|
|
317
|
+
path = normalizeSharedPath(path);
|
|
318
|
+
requestArgs = {
|
|
319
|
+
path: path,
|
|
320
|
+
shared_link: { url: DROPBOX_SHARED_URL }
|
|
321
|
+
};
|
|
322
|
+
} else {
|
|
323
|
+
// Absolute path: if "/" then empty string for root
|
|
324
|
+
requestArgs = { path: path === "/" ? "" : path };
|
|
206
325
|
}
|
|
207
326
|
|
|
208
327
|
const response = await client.filesListFolder(requestArgs);
|
|
209
|
-
const entries = response.result.entries.map(
|
|
210
|
-
|
|
328
|
+
const entries = response.result.entries.map((e: any) =>
|
|
329
|
+
`[${e['.tag'].toUpperCase()}] ${e.name} (Path: ${e.path_display})`
|
|
330
|
+
);
|
|
331
|
+
return {
|
|
332
|
+
content: [{ type: "text", text: entries.join("\n") || "Folder is empty." }]
|
|
333
|
+
};
|
|
211
334
|
}
|
|
212
335
|
|
|
336
|
+
// --- search_files ---
|
|
213
337
|
if (name === "search_files") {
|
|
214
338
|
const response = await client.filesSearchV2({ query: args.query as string });
|
|
215
|
-
const matches = response.result.matches.map(m => {
|
|
339
|
+
const matches = response.result.matches.map((m: any) => {
|
|
216
340
|
const meta = (m.metadata as any).metadata;
|
|
217
341
|
return `[${meta['.tag'].toUpperCase()}] ${meta.name} (Path: ${meta.path_display})`;
|
|
218
342
|
});
|
|
219
|
-
return {
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text", text: matches.join("\n") || "No matches found." }]
|
|
345
|
+
};
|
|
220
346
|
}
|
|
221
347
|
|
|
348
|
+
// --- get_file_metadata ---
|
|
222
349
|
if (name === "get_file_metadata") {
|
|
223
350
|
let response;
|
|
351
|
+
const rawPath = (args.path as string) || "";
|
|
352
|
+
|
|
224
353
|
if (DROPBOX_SHARED_URL) {
|
|
225
|
-
|
|
354
|
+
const path = normalizeSharedPath(rawPath);
|
|
355
|
+
response = await client.sharingGetSharedLinkMetadata({
|
|
356
|
+
url: DROPBOX_SHARED_URL,
|
|
357
|
+
path: path
|
|
358
|
+
});
|
|
226
359
|
} else {
|
|
227
|
-
response = await client.filesGetMetadata({ path:
|
|
360
|
+
response = await client.filesGetMetadata({ path: rawPath });
|
|
228
361
|
}
|
|
229
|
-
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: "text", text: JSON.stringify(response.result, null, 2) }]
|
|
365
|
+
};
|
|
230
366
|
}
|
|
231
367
|
|
|
368
|
+
// --- read_data ---
|
|
232
369
|
if (name === "read_data") {
|
|
233
370
|
try {
|
|
234
371
|
const text = await downloadHelper(args.identifier as string);
|
|
235
|
-
|
|
372
|
+
const truncated = text.length > 100000
|
|
373
|
+
? text.substring(0, 100000) + "\n... (truncated)"
|
|
374
|
+
: text;
|
|
375
|
+
return { content: [{ type: "text", text: truncated }] };
|
|
236
376
|
} catch (err: any) {
|
|
237
377
|
if (isNotFoundError(err)) {
|
|
238
|
-
return {
|
|
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 };
|
|
239
397
|
}
|
|
240
398
|
throw err;
|
|
241
399
|
}
|
|
242
400
|
}
|
|
243
401
|
|
|
402
|
+
// --- describe_table ---
|
|
244
403
|
if (name === "describe_table") {
|
|
245
404
|
try {
|
|
246
405
|
const text = await downloadHelper(args.identifier as string);
|
|
247
406
|
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
|
|
248
|
-
if (lines.length === 0)
|
|
407
|
+
if (lines.length === 0) {
|
|
408
|
+
return { content: [{ type: "text", text: "File is empty." }] };
|
|
409
|
+
}
|
|
249
410
|
|
|
250
|
-
|
|
251
|
-
|
|
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}`;
|
|
252
429
|
return { content: [{ type: "text", text: schemaStr }] };
|
|
253
430
|
} catch (err: any) {
|
|
254
431
|
if (isNotFoundError(err)) {
|
|
255
|
-
return {
|
|
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 };
|
|
256
445
|
}
|
|
257
446
|
throw err;
|
|
258
447
|
}
|
|
@@ -268,12 +457,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
268
457
|
});
|
|
269
458
|
|
|
270
459
|
// ------------------------------------------------------------------
|
|
271
|
-
//
|
|
460
|
+
// 7. Start the Server
|
|
272
461
|
// ------------------------------------------------------------------
|
|
273
462
|
async function run() {
|
|
274
463
|
const transport = new StdioServerTransport();
|
|
275
464
|
await server.connect(transport);
|
|
276
|
-
console.error(
|
|
465
|
+
console.error(`Dropbox MCP Server running on stdio ${DROPBOX_SHARED_URL ? '(shared link mode)' : '(full account mode)'}`);
|
|
277
466
|
}
|
|
278
467
|
|
|
279
468
|
run().catch(console.error);
|