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.
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -7
- package/dist/index.js.map +1 -1
- package/dist/tools/activity.d.ts +6 -4
- package/dist/tools/activity.d.ts.map +1 -1
- package/dist/tools/activity.js +13 -285
- package/dist/tools/activity.js.map +1 -1
- package/dist/tools/auth.d.ts.map +1 -1
- package/dist/tools/auth.js +5 -11
- package/dist/tools/auth.js.map +1 -1
- package/dist/tools/campfires.d.ts +13 -0
- package/dist/tools/campfires.d.ts.map +1 -0
- package/dist/tools/campfires.js +221 -0
- package/dist/tools/campfires.js.map +1 -0
- package/dist/tools/checkins.d.ts.map +1 -1
- package/dist/tools/checkins.js +10 -74
- package/dist/tools/checkins.js.map +1 -1
- package/dist/tools/comments.d.ts.map +1 -1
- package/dist/tools/comments.js +9 -44
- package/dist/tools/comments.js.map +1 -1
- package/dist/tools/files.d.ts +21 -0
- package/dist/tools/files.d.ts.map +1 -0
- package/dist/tools/files.js +659 -0
- package/dist/tools/files.js.map +1 -0
- package/dist/tools/kanban.d.ts.map +1 -1
- package/dist/tools/kanban.js +78 -137
- package/dist/tools/kanban.js.map +1 -1
- package/dist/tools/messages.d.ts.map +1 -1
- package/dist/tools/messages.js +31 -71
- package/dist/tools/messages.js.map +1 -1
- package/dist/tools/people.d.ts.map +1 -1
- package/dist/tools/people.js +5 -19
- package/dist/tools/people.js.map +1 -1
- package/dist/tools/projects.d.ts.map +1 -1
- package/dist/tools/projects.js +4 -16
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/todos.d.ts.map +1 -1
- package/dist/tools/todos.js +60 -84
- package/dist/tools/todos.js.map +1 -1
- package/dist/utils/auth.d.ts +7 -2
- package/dist/utils/auth.d.ts.map +1 -1
- package/dist/utils/auth.js +19 -11
- package/dist/utils/auth.js.map +1 -1
- package/dist/utils/contentOperations.d.ts +2 -2
- package/dist/utils/credentials.d.ts.map +1 -1
- package/dist/utils/credentials.js.map +1 -1
- package/dist/utils/errorHandlers.d.ts.map +1 -1
- package/dist/utils/errorHandlers.js +53 -29
- package/dist/utils/errorHandlers.js.map +1 -1
- package/dist/utils/oauth.d.ts +1 -0
- package/dist/utils/oauth.d.ts.map +1 -1
- package/dist/utils/oauth.js +18 -0
- package/dist/utils/oauth.js.map +1 -1
- package/dist/utils/pagination.d.ts +43 -0
- package/dist/utils/pagination.d.ts.map +1 -0
- package/dist/utils/pagination.js +72 -0
- package/dist/utils/pagination.js.map +1 -0
- package/dist/utils/parseSince.d.ts +11 -0
- package/dist/utils/parseSince.d.ts.map +1 -0
- package/dist/utils/parseSince.js +48 -0
- package/dist/utils/parseSince.js.map +1 -0
- package/dist/utils/serializers.d.ts +17 -2
- package/dist/utils/serializers.d.ts.map +1 -1
- package/dist/utils/serializers.js +16 -0
- package/dist/utils/serializers.js.map +1 -1
- package/package.json +5 -3
- package/dist/tools/documents.d.ts +0 -11
- package/dist/tools/documents.d.ts.map +0 -1
- package/dist/tools/documents.js +0 -453
- package/dist/tools/documents.js.map +0 -1
- package/dist/tools/uploads.d.ts +0 -9
- package/dist/tools/uploads.d.ts.map +0 -1
- package/dist/tools/uploads.js +0 -319
- 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
|