basecamp-mcp 1.1.2 → 1.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.
Files changed (75) hide show
  1. package/dist/index.d.ts +7 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +16 -7
  4. package/dist/index.js.map +1 -1
  5. package/dist/tools/activity.d.ts +6 -4
  6. package/dist/tools/activity.d.ts.map +1 -1
  7. package/dist/tools/activity.js +13 -285
  8. package/dist/tools/activity.js.map +1 -1
  9. package/dist/tools/auth.d.ts.map +1 -1
  10. package/dist/tools/auth.js +5 -11
  11. package/dist/tools/auth.js.map +1 -1
  12. package/dist/tools/campfires.d.ts +13 -0
  13. package/dist/tools/campfires.d.ts.map +1 -0
  14. package/dist/tools/campfires.js +221 -0
  15. package/dist/tools/campfires.js.map +1 -0
  16. package/dist/tools/checkins.d.ts.map +1 -1
  17. package/dist/tools/checkins.js +10 -74
  18. package/dist/tools/checkins.js.map +1 -1
  19. package/dist/tools/comments.d.ts.map +1 -1
  20. package/dist/tools/comments.js +9 -44
  21. package/dist/tools/comments.js.map +1 -1
  22. package/dist/tools/files.d.ts +21 -0
  23. package/dist/tools/files.d.ts.map +1 -0
  24. package/dist/tools/files.js +659 -0
  25. package/dist/tools/files.js.map +1 -0
  26. package/dist/tools/kanban.d.ts.map +1 -1
  27. package/dist/tools/kanban.js +78 -137
  28. package/dist/tools/kanban.js.map +1 -1
  29. package/dist/tools/messages.d.ts.map +1 -1
  30. package/dist/tools/messages.js +31 -71
  31. package/dist/tools/messages.js.map +1 -1
  32. package/dist/tools/people.d.ts.map +1 -1
  33. package/dist/tools/people.js +5 -19
  34. package/dist/tools/people.js.map +1 -1
  35. package/dist/tools/projects.d.ts.map +1 -1
  36. package/dist/tools/projects.js +4 -16
  37. package/dist/tools/projects.js.map +1 -1
  38. package/dist/tools/todos.d.ts.map +1 -1
  39. package/dist/tools/todos.js +60 -84
  40. package/dist/tools/todos.js.map +1 -1
  41. package/dist/utils/auth.d.ts +7 -2
  42. package/dist/utils/auth.d.ts.map +1 -1
  43. package/dist/utils/auth.js +19 -11
  44. package/dist/utils/auth.js.map +1 -1
  45. package/dist/utils/contentOperations.d.ts +2 -2
  46. package/dist/utils/credentials.d.ts.map +1 -1
  47. package/dist/utils/credentials.js.map +1 -1
  48. package/dist/utils/errorHandlers.d.ts.map +1 -1
  49. package/dist/utils/errorHandlers.js +53 -29
  50. package/dist/utils/errorHandlers.js.map +1 -1
  51. package/dist/utils/oauth.d.ts +1 -0
  52. package/dist/utils/oauth.d.ts.map +1 -1
  53. package/dist/utils/oauth.js +18 -0
  54. package/dist/utils/oauth.js.map +1 -1
  55. package/dist/utils/pagination.d.ts +43 -0
  56. package/dist/utils/pagination.d.ts.map +1 -0
  57. package/dist/utils/pagination.js +72 -0
  58. package/dist/utils/pagination.js.map +1 -0
  59. package/dist/utils/parseSince.d.ts +11 -0
  60. package/dist/utils/parseSince.d.ts.map +1 -0
  61. package/dist/utils/parseSince.js +48 -0
  62. package/dist/utils/parseSince.js.map +1 -0
  63. package/dist/utils/serializers.d.ts +17 -2
  64. package/dist/utils/serializers.d.ts.map +1 -1
  65. package/dist/utils/serializers.js +16 -0
  66. package/dist/utils/serializers.js.map +1 -1
  67. package/package.json +5 -3
  68. package/dist/tools/documents.d.ts +0 -11
  69. package/dist/tools/documents.d.ts.map +0 -1
  70. package/dist/tools/documents.js +0 -453
  71. package/dist/tools/documents.js.map +0 -1
  72. package/dist/tools/uploads.d.ts +0 -9
  73. package/dist/tools/uploads.d.ts.map +0 -1
  74. package/dist/tools/uploads.js +0 -319
  75. package/dist/tools/uploads.js.map +0 -1
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Docs & Files tools for Basecamp MCP server.
3
+ *
4
+ * This is one Basecamp dock — a vault tree containing both documents and
5
+ * uploads — so the whole hierarchy lives in one module:
6
+ * - Vaults are folders in the "Docs & Files" section (the container).
7
+ * - Documents are rich-text documents stored inside vaults.
8
+ * - Uploads are files stored inside vaults.
9
+ * - Blobs are inline attachments embedded in rich text via <bc-attachment>.
10
+ */
11
+ import { mkdirSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { z } from "zod";
15
+ import { BasecampIdSchema } from "../schemas/common.js";
16
+ import { initializeBasecampClient } from "../utils/auth.js";
17
+ import { applyContentOperations, ContentOperationFields, htmlRules, validateContentOperations, } from "../utils/contentOperations.js";
18
+ import { readCredentials } from "../utils/credentials.js";
19
+ import { handleBasecampError } from "../utils/errorHandlers.js";
20
+ import { serializePerson } from "../utils/serializers.js";
21
+ const DOWNLOAD_DIR = join(tmpdir(), "basecamp-downloads");
22
+ function saveToDisk(filename, data) {
23
+ mkdirSync(DOWNLOAD_DIR, { recursive: true });
24
+ const filePath = join(DOWNLOAD_DIR, filename);
25
+ writeFileSync(filePath, data);
26
+ return filePath;
27
+ }
28
+ /**
29
+ * Consume a download stream into a single byte array. The SDK's downloadURL
30
+ * returns a ReadableStream that the caller must fully consume or cancel.
31
+ */
32
+ export async function streamToBytes(stream) {
33
+ const arrayBuffer = await new Response(stream).arrayBuffer();
34
+ return new Uint8Array(arrayBuffer);
35
+ }
36
+ const IMAGE_CONTENT_TYPES = [
37
+ "image/png",
38
+ "image/jpeg",
39
+ "image/gif",
40
+ "image/webp",
41
+ ];
42
+ const TEXT_CONTENT_TYPES = [
43
+ "text/plain",
44
+ "text/csv",
45
+ "text/html",
46
+ "text/markdown",
47
+ "application/json",
48
+ "application/xml",
49
+ "text/xml",
50
+ ];
51
+ export function isImageContentType(contentType) {
52
+ return IMAGE_CONTENT_TYPES.some((t) => contentType.startsWith(t));
53
+ }
54
+ export function isTextContentType(contentType) {
55
+ return TEXT_CONTENT_TYPES.some((t) => contentType.startsWith(t));
56
+ }
57
+ export function inferContentType(filename) {
58
+ const ext = filename.split(".").pop()?.toLowerCase();
59
+ const map = {
60
+ png: "image/png",
61
+ jpg: "image/jpeg",
62
+ jpeg: "image/jpeg",
63
+ gif: "image/gif",
64
+ webp: "image/webp",
65
+ txt: "text/plain",
66
+ csv: "text/csv",
67
+ json: "application/json",
68
+ xml: "application/xml",
69
+ md: "text/markdown",
70
+ html: "text/html",
71
+ };
72
+ return map[ext || ""] || "application/octet-stream";
73
+ }
74
+ export function registerFilesTools(server) {
75
+ // ===== VAULTS (folders) =====
76
+ // basecamp_list_vaults
77
+ server.registerTool("basecamp_list_vaults", {
78
+ title: "List Basecamp Vaults",
79
+ description: "List sub-vaults (folders) under a parent vault in the Docs & Files section.",
80
+ inputSchema: {
81
+ parent_vault_id: BasecampIdSchema.describe("Parent vault ID (use the vault ID from the project's dock)"),
82
+ filter: z
83
+ .string()
84
+ .optional()
85
+ .describe("Optional regular expression to filter vaults by title"),
86
+ },
87
+ annotations: {
88
+ readOnlyHint: true,
89
+ destructiveHint: false,
90
+ idempotentHint: true,
91
+ openWorldHint: true,
92
+ },
93
+ }, async (params) => {
94
+ try {
95
+ const client = await initializeBasecampClient();
96
+ const vaults = await client.vaults.list(params.parent_vault_id);
97
+ let filtered = [...vaults];
98
+ if (params.filter) {
99
+ const regex = new RegExp(params.filter, "i");
100
+ filtered = vaults.filter((v) => regex.test(v.title));
101
+ }
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text",
106
+ text: JSON.stringify(filtered.map((v) => ({
107
+ id: v.id,
108
+ title: v.title,
109
+ documents_count: v.documents_count,
110
+ uploads_count: v.uploads_count,
111
+ vaults_count: v.vaults_count,
112
+ creator: serializePerson(v.creator),
113
+ created_at: v.created_at,
114
+ url: v.app_url,
115
+ })), null, 2),
116
+ },
117
+ ],
118
+ };
119
+ }
120
+ catch (error) {
121
+ return {
122
+ content: [{ type: "text", text: handleBasecampError(error) }],
123
+ };
124
+ }
125
+ });
126
+ // basecamp_get_vault
127
+ server.registerTool("basecamp_get_vault", {
128
+ title: "Get Basecamp Vault",
129
+ description: "Get details of a vault (folder) including document/upload/sub-vault counts.",
130
+ inputSchema: {
131
+ vault_id: BasecampIdSchema.describe("Vault ID to retrieve"),
132
+ },
133
+ annotations: {
134
+ readOnlyHint: true,
135
+ destructiveHint: false,
136
+ idempotentHint: true,
137
+ openWorldHint: true,
138
+ },
139
+ }, async (params) => {
140
+ try {
141
+ const client = await initializeBasecampClient();
142
+ const vault = await client.vaults.get(params.vault_id);
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: JSON.stringify({
148
+ id: vault.id,
149
+ title: vault.title,
150
+ documents_count: vault.documents_count,
151
+ documents_url: vault.documents_url,
152
+ uploads_count: vault.uploads_count,
153
+ uploads_url: vault.uploads_url,
154
+ vaults_count: vault.vaults_count,
155
+ vaults_url: vault.vaults_url,
156
+ creator: serializePerson(vault.creator),
157
+ created_at: vault.created_at,
158
+ updated_at: vault.updated_at,
159
+ url: vault.app_url,
160
+ }, null, 2),
161
+ },
162
+ ],
163
+ };
164
+ }
165
+ catch (error) {
166
+ return {
167
+ content: [{ type: "text", text: handleBasecampError(error) }],
168
+ };
169
+ }
170
+ });
171
+ // basecamp_create_vault
172
+ server.registerTool("basecamp_create_vault", {
173
+ title: "Create Basecamp Vault",
174
+ description: "Create a new vault (folder) under a parent vault.",
175
+ inputSchema: {
176
+ parent_vault_id: BasecampIdSchema.describe("Parent vault ID to create the new vault under"),
177
+ title: z.string().min(1).describe("Vault title/name"),
178
+ },
179
+ annotations: {
180
+ readOnlyHint: false,
181
+ destructiveHint: false,
182
+ idempotentHint: false,
183
+ openWorldHint: true,
184
+ },
185
+ }, async (params) => {
186
+ try {
187
+ const client = await initializeBasecampClient();
188
+ const vault = await client.vaults.create(params.parent_vault_id, {
189
+ title: params.title,
190
+ });
191
+ return {
192
+ content: [
193
+ {
194
+ type: "text",
195
+ text: `Vault created successfully!\n\nID: ${vault.id}\nTitle: ${vault.title}\nURL: ${vault.app_url}`,
196
+ },
197
+ ],
198
+ };
199
+ }
200
+ catch (error) {
201
+ return {
202
+ content: [{ type: "text", text: handleBasecampError(error) }],
203
+ };
204
+ }
205
+ });
206
+ // basecamp_update_vault
207
+ server.registerTool("basecamp_update_vault", {
208
+ title: "Update Basecamp Vault",
209
+ description: "Update the title of a vault (folder).",
210
+ inputSchema: {
211
+ vault_id: BasecampIdSchema.describe("Vault ID to update"),
212
+ title: z.string().min(1).describe("New vault title"),
213
+ },
214
+ annotations: {
215
+ readOnlyHint: false,
216
+ destructiveHint: false,
217
+ idempotentHint: true,
218
+ openWorldHint: true,
219
+ },
220
+ }, async (params) => {
221
+ try {
222
+ const client = await initializeBasecampClient();
223
+ const vault = await client.vaults.update(params.vault_id, {
224
+ title: params.title,
225
+ });
226
+ return {
227
+ content: [
228
+ {
229
+ type: "text",
230
+ text: `Vault updated successfully!\n\nID: ${vault.id}\nTitle: ${vault.title}`,
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ catch (error) {
236
+ return {
237
+ content: [{ type: "text", text: handleBasecampError(error) }],
238
+ };
239
+ }
240
+ });
241
+ // ===== DOCUMENTS =====
242
+ // basecamp_list_documents
243
+ server.registerTool("basecamp_list_documents", {
244
+ title: "List Basecamp Documents",
245
+ description: "List documents in a vault.",
246
+ inputSchema: {
247
+ vault_id: BasecampIdSchema.describe("Vault ID containing the documents"),
248
+ filter: z
249
+ .string()
250
+ .optional()
251
+ .describe("Optional regular expression to filter documents by title"),
252
+ },
253
+ annotations: {
254
+ readOnlyHint: true,
255
+ destructiveHint: false,
256
+ idempotentHint: true,
257
+ openWorldHint: true,
258
+ },
259
+ }, async (params) => {
260
+ try {
261
+ const client = await initializeBasecampClient();
262
+ const documents = await client.documents.list(params.vault_id);
263
+ let filtered = [...documents];
264
+ if (params.filter) {
265
+ const regex = new RegExp(params.filter, "i");
266
+ filtered = documents.filter((d) => regex.test(d.title) || regex.test(d.content || ""));
267
+ }
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text",
272
+ text: JSON.stringify(filtered.map((d) => ({
273
+ id: d.id,
274
+ title: d.title,
275
+ creator: serializePerson(d.creator),
276
+ created_at: d.created_at,
277
+ updated_at: d.updated_at,
278
+ url: d.app_url,
279
+ })), null, 2),
280
+ },
281
+ ],
282
+ };
283
+ }
284
+ catch (error) {
285
+ return {
286
+ content: [{ type: "text", text: handleBasecampError(error) }],
287
+ };
288
+ }
289
+ });
290
+ // basecamp_get_document
291
+ server.registerTool("basecamp_get_document", {
292
+ title: "Get Basecamp Document",
293
+ description: "Retrieve a single document with its full content.",
294
+ inputSchema: {
295
+ document_id: BasecampIdSchema.describe("Document ID to retrieve"),
296
+ },
297
+ annotations: {
298
+ readOnlyHint: true,
299
+ destructiveHint: false,
300
+ idempotentHint: true,
301
+ openWorldHint: true,
302
+ },
303
+ }, async (params) => {
304
+ try {
305
+ const client = await initializeBasecampClient();
306
+ const doc = await client.documents.get(params.document_id);
307
+ return {
308
+ content: [
309
+ {
310
+ type: "text",
311
+ text: JSON.stringify({
312
+ id: doc.id,
313
+ title: doc.title,
314
+ content: doc.content || "",
315
+ author: serializePerson(doc.creator),
316
+ created_at: doc.created_at,
317
+ updated_at: doc.updated_at,
318
+ url: doc.app_url,
319
+ }, null, 2),
320
+ },
321
+ ],
322
+ };
323
+ }
324
+ catch (error) {
325
+ return {
326
+ content: [{ type: "text", text: handleBasecampError(error) }],
327
+ };
328
+ }
329
+ });
330
+ // basecamp_create_document
331
+ server.registerTool("basecamp_create_document", {
332
+ title: "Create Basecamp Document",
333
+ description: `Create a new document in a vault. ${htmlRules}`,
334
+ inputSchema: {
335
+ vault_id: BasecampIdSchema.describe("Vault ID to create the document in"),
336
+ title: z.string().min(1).describe("Document title"),
337
+ content: z.string().describe("HTML document content"),
338
+ status: z
339
+ .enum(["active", "drafted"])
340
+ .default("active")
341
+ .describe(`Document status. Use "active" to publish, "drafted" to save as an unpublished draft.`),
342
+ },
343
+ annotations: {
344
+ readOnlyHint: false,
345
+ destructiveHint: false,
346
+ idempotentHint: false,
347
+ openWorldHint: true,
348
+ },
349
+ }, async (params) => {
350
+ try {
351
+ const client = await initializeBasecampClient();
352
+ const doc = await client.documents.create(params.vault_id, {
353
+ title: params.title,
354
+ content: params.content,
355
+ status: params.status,
356
+ });
357
+ return {
358
+ content: [
359
+ {
360
+ type: "text",
361
+ text: `Document created successfully!\n\nID: ${doc.id}\nTitle: ${doc.title}\nURL: ${doc.app_url}`,
362
+ },
363
+ ],
364
+ };
365
+ }
366
+ catch (error) {
367
+ return {
368
+ content: [{ type: "text", text: handleBasecampError(error) }],
369
+ };
370
+ }
371
+ });
372
+ // basecamp_update_document
373
+ server.registerTool("basecamp_update_document", {
374
+ title: "Update Basecamp Document",
375
+ description: `Update a document. Use partial content operations when possible to save on token usage. ${htmlRules}`,
376
+ inputSchema: {
377
+ document_id: BasecampIdSchema.describe("Document ID to update"),
378
+ title: z.string().min(1).optional().describe("New document title"),
379
+ ...ContentOperationFields,
380
+ },
381
+ annotations: {
382
+ readOnlyHint: false,
383
+ destructiveHint: false,
384
+ idempotentHint: false,
385
+ openWorldHint: true,
386
+ },
387
+ }, async (params) => {
388
+ try {
389
+ validateContentOperations(params, ["title"]);
390
+ const client = await initializeBasecampClient();
391
+ let finalContent;
392
+ const hasPartialOps = params.content_append ||
393
+ params.content_prepend ||
394
+ params.search_replace;
395
+ if (hasPartialOps || params.content !== undefined) {
396
+ if (hasPartialOps) {
397
+ const current = await client.documents.get(params.document_id);
398
+ const currentContent = current.content || "";
399
+ finalContent = applyContentOperations(currentContent, params);
400
+ }
401
+ else {
402
+ finalContent = params.content;
403
+ }
404
+ }
405
+ const doc = await client.documents.update(params.document_id, {
406
+ ...(params.title ? { title: params.title } : {}),
407
+ ...(finalContent !== undefined ? { content: finalContent } : {}),
408
+ });
409
+ return {
410
+ content: [
411
+ {
412
+ type: "text",
413
+ text: `Document updated successfully!\n\nID: ${doc.id}\nTitle: ${doc.title}`,
414
+ },
415
+ ],
416
+ };
417
+ }
418
+ catch (error) {
419
+ return {
420
+ content: [{ type: "text", text: handleBasecampError(error) }],
421
+ };
422
+ }
423
+ });
424
+ // ===== UPLOADS (files) =====
425
+ // basecamp_list_uploads
426
+ server.registerTool("basecamp_list_uploads", {
427
+ title: "List Basecamp Uploads",
428
+ description: "List files uploaded to a vault in the Docs & Files section.",
429
+ inputSchema: {
430
+ vault_id: BasecampIdSchema.describe("Vault ID containing the uploads"),
431
+ filter: z
432
+ .string()
433
+ .optional()
434
+ .describe("Optional regular expression to filter uploads by filename"),
435
+ },
436
+ annotations: {
437
+ readOnlyHint: true,
438
+ destructiveHint: false,
439
+ idempotentHint: true,
440
+ openWorldHint: true,
441
+ },
442
+ }, async (params) => {
443
+ try {
444
+ const client = await initializeBasecampClient();
445
+ const uploads = await client.uploads.list(params.vault_id);
446
+ let filtered = [...uploads];
447
+ if (params.filter) {
448
+ const regex = new RegExp(params.filter, "i");
449
+ filtered = uploads.filter((u) => regex.test(u.filename || "") || regex.test(u.title || ""));
450
+ }
451
+ return {
452
+ content: [
453
+ {
454
+ type: "text",
455
+ text: JSON.stringify(filtered.map((u) => ({
456
+ id: u.id,
457
+ title: u.title,
458
+ filename: u.filename,
459
+ content_type: u.content_type,
460
+ byte_size: u.byte_size,
461
+ width: u.width,
462
+ height: u.height,
463
+ description: u.description,
464
+ creator: serializePerson(u.creator),
465
+ created_at: u.created_at,
466
+ updated_at: u.updated_at,
467
+ url: u.app_url,
468
+ download_url: u.download_url,
469
+ })), null, 2),
470
+ },
471
+ ],
472
+ };
473
+ }
474
+ catch (error) {
475
+ return {
476
+ content: [{ type: "text", text: handleBasecampError(error) }],
477
+ };
478
+ }
479
+ });
480
+ // basecamp_get_upload
481
+ server.registerTool("basecamp_get_upload", {
482
+ title: "Get Basecamp Upload",
483
+ description: "Get a file uploaded to a vault. For images, returns the image content that the LLM can see directly. For text-based files (plain text, CSV, JSON, XML, etc.), returns the file content as text. For other binary formats, returns metadata only.",
484
+ inputSchema: {
485
+ upload_id: BasecampIdSchema.describe("Upload ID to retrieve"),
486
+ },
487
+ annotations: {
488
+ readOnlyHint: true,
489
+ destructiveHint: false,
490
+ idempotentHint: true,
491
+ openWorldHint: true,
492
+ },
493
+ }, async (params) => {
494
+ try {
495
+ const client = await initializeBasecampClient();
496
+ const upload = await client.uploads.get(params.upload_id);
497
+ if (!upload.download_url) {
498
+ throw new Error(`Upload ${params.upload_id} has no download URL available.`);
499
+ }
500
+ const downloadUrl = upload.download_url;
501
+ const contentType = upload.content_type || "application/octet-stream";
502
+ const filename = upload.filename || `upload-${upload.id}`;
503
+ const metadata = {
504
+ id: upload.id,
505
+ title: upload.title,
506
+ filename: upload.filename,
507
+ content_type: upload.content_type,
508
+ byte_size: upload.byte_size,
509
+ width: upload.width,
510
+ height: upload.height,
511
+ description: upload.description,
512
+ creator: serializePerson(upload.creator),
513
+ created_at: upload.created_at,
514
+ updated_at: upload.updated_at,
515
+ url: upload.app_url,
516
+ download_url: upload.download_url,
517
+ };
518
+ if (isImageContentType(contentType)) {
519
+ const result = await client.downloadURL(downloadUrl);
520
+ const bytes = await streamToBytes(result.body);
521
+ const base64 = Buffer.from(bytes).toString("base64");
522
+ return {
523
+ content: [
524
+ {
525
+ type: "text",
526
+ text: JSON.stringify(metadata, null, 2),
527
+ },
528
+ {
529
+ type: "image",
530
+ data: base64,
531
+ mimeType: contentType,
532
+ },
533
+ ],
534
+ };
535
+ }
536
+ if (isTextContentType(contentType)) {
537
+ const result = await client.downloadURL(downloadUrl);
538
+ const bytes = await streamToBytes(result.body);
539
+ const text = new TextDecoder().decode(bytes);
540
+ return {
541
+ content: [
542
+ {
543
+ type: "text",
544
+ text: JSON.stringify(metadata, null, 2),
545
+ },
546
+ {
547
+ type: "text",
548
+ text: `--- File content of ${filename} ---\n${text}`,
549
+ },
550
+ ],
551
+ };
552
+ }
553
+ const result = await client.downloadURL(downloadUrl);
554
+ const bytes = await streamToBytes(result.body);
555
+ const filePath = saveToDisk(filename, bytes);
556
+ return {
557
+ content: [
558
+ {
559
+ type: "text",
560
+ text: JSON.stringify({
561
+ ...metadata,
562
+ saved_to: filePath,
563
+ note: `Binary file saved to disk. Use the file path to read it.`,
564
+ }, null, 2),
565
+ },
566
+ ],
567
+ };
568
+ }
569
+ catch (error) {
570
+ return {
571
+ content: [{ type: "text", text: handleBasecampError(error) }],
572
+ };
573
+ }
574
+ });
575
+ // ===== BLOBS (inline attachments) =====
576
+ // basecamp_download_blob
577
+ server.registerTool("basecamp_download_blob", {
578
+ title: "Download Basecamp Blob",
579
+ description: "Download an inline attachment from a <bc-attachment> tag found in document/message/comment HTML content. Extract the blob_id and filename from the href attribute (format: https://storage.3.basecamp.com/{accountId}/blobs/{blobId}/download/{filename}). For images, returns the image content that the LLM can see directly. For text-based files, returns the file content as text.",
580
+ inputSchema: {
581
+ blob_id: z
582
+ .string()
583
+ .describe("Blob UUID extracted from the <bc-attachment> href URL"),
584
+ filename: z
585
+ .string()
586
+ .describe("Filename extracted from the <bc-attachment> href URL (URL-decoded)"),
587
+ content_type: z
588
+ .string()
589
+ .optional()
590
+ .describe('Content type from the <bc-attachment> content-type attribute (e.g. "image/png"). If not provided, will attempt to infer from filename.'),
591
+ },
592
+ annotations: {
593
+ readOnlyHint: true,
594
+ destructiveHint: false,
595
+ idempotentHint: true,
596
+ openWorldHint: true,
597
+ },
598
+ }, async (params) => {
599
+ try {
600
+ const creds = await readCredentials();
601
+ if (!creds) {
602
+ throw new Error("Not logged in. Use basecamp_login to authenticate.");
603
+ }
604
+ const client = await initializeBasecampClient();
605
+ // Reconstruct the storage download URL from its parts. downloadURL
606
+ // rewrites the origin to the configured API host, so only the path
607
+ // (accountId/blobs/blobId/download/filename) needs to be correct.
608
+ const blobUrl = `https://storage.3.basecamp.com/${creds.accountId}/blobs/${params.blob_id}/download/${encodeURIComponent(params.filename)}`;
609
+ const result = await client.downloadURL(blobUrl);
610
+ const bytes = await streamToBytes(result.body);
611
+ const contentType = params.content_type ||
612
+ (result.contentType !== "application/octet-stream"
613
+ ? result.contentType
614
+ : inferContentType(params.filename));
615
+ if (isImageContentType(contentType)) {
616
+ const base64 = Buffer.from(bytes).toString("base64");
617
+ return {
618
+ content: [
619
+ {
620
+ type: "text",
621
+ text: `Blob: ${params.filename} (${contentType})`,
622
+ },
623
+ {
624
+ type: "image",
625
+ data: base64,
626
+ mimeType: contentType,
627
+ },
628
+ ],
629
+ };
630
+ }
631
+ if (isTextContentType(contentType)) {
632
+ const text = new TextDecoder().decode(bytes);
633
+ return {
634
+ content: [
635
+ {
636
+ type: "text",
637
+ text: `--- File content of ${params.filename} ---\n${text}`,
638
+ },
639
+ ],
640
+ };
641
+ }
642
+ const filePath = saveToDisk(params.filename, bytes);
643
+ return {
644
+ content: [
645
+ {
646
+ type: "text",
647
+ text: `Downloaded ${params.filename} (${contentType}, ${bytes.byteLength} bytes) and saved to: ${filePath}\n\nUse the file path to read it.`,
648
+ },
649
+ ],
650
+ };
651
+ }
652
+ catch (error) {
653
+ return {
654
+ content: [{ type: "text", text: handleBasecampError(error) }],
655
+ };
656
+ }
657
+ });
658
+ }
659
+ //# sourceMappingURL=files.js.map