capsulemcp 1.6.5 → 1.8.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/README.md +4 -4
- package/dist/http.js +302 -89
- package/dist/index.js +276 -90
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
A [Model Context Protocol](https://modelcontextprotocol.io) server for [Capsule CRM](https://capsulecrm.com). Connect Claude (Desktop, Code, or web Projects via Custom Connector) to your CRM and let it answer natural-language questions across the full record graph: contacts, organisations, opportunities, projects, tasks, and timeline activity. Beyond the basics it covers structured filters with field/operator conditions, saved searches with sort, workflow tracks (templates and instances), file attachments (read + write), audit of deleted records, and batch fetches up to 50 records per call.
|
|
6
6
|
|
|
7
|
-
- **
|
|
7
|
+
- **88 tools** across the Capsule resource graph (49 in read-only mode) — full read coverage plus careful, confirm-gated writes; 6 batched-write tools (`batch_*`) for mass-update workflows
|
|
8
8
|
- **Two transports**: stdio for local installs (Claude Desktop / Code), HTTP+OAuth for hosted Custom Connectors
|
|
9
9
|
- **Read-only mode** as a one-env-var flag; works alongside read-scoped Capsule tokens
|
|
10
|
-
- **MCP tool annotations**: 49 read tools carry `readOnlyHint: true`,
|
|
10
|
+
- **MCP tool annotations**: 49 read tools carry `readOnlyHint: true`, 8 destructive ones carry `destructiveHint: true` — clients that honor these hints can auto-approve safe reads while still prompting for writes/destructive calls
|
|
11
11
|
- **Apache 2.0**
|
|
12
12
|
|
|
13
13
|
## Pick your install
|
|
@@ -48,7 +48,7 @@ For most individual users the install is a single JSON snippet pasted into Claud
|
|
|
48
48
|
|
|
49
49
|
3. Restart Claude Desktop. The Capsule tools appear in the tool picker.
|
|
50
50
|
|
|
51
|
-
That's it. The first launch fetches the package from npm (a few seconds); subsequent launches are instant from the npx cache. To pin a specific version, use `"capsulemcp@1.
|
|
51
|
+
That's it. The first launch fetches the package from npm (a few seconds); subsequent launches are instant from the npx cache. To pin a specific version, use `"capsulemcp@1.8.0"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.8.0"` — same arguments, just installs from a git clone rather than the npm registry. See [INSTALL.md](INSTALL.md) for the Claude Code path, manual install, and troubleshooting.
|
|
52
52
|
|
|
53
53
|
## Tools
|
|
54
54
|
|
|
@@ -67,7 +67,7 @@ That's it. The first launch fetches the package from npm (a few seconds); subseq
|
|
|
67
67
|
| Tracks (workflow instances) | `list_track_definitions`, `list_entity_tracks`, `show_track` | `apply_track`, `update_track`, `remove_track` |
|
|
68
68
|
| Saved filters | `list_saved_filters`, `run_saved_filter` | — |
|
|
69
69
|
| Custom fields (schema) | `list_custom_fields`, `get_custom_field` | — |
|
|
70
|
-
| Tags | `list_tags` | `add_tag`, `remove_tag_by_id` |
|
|
70
|
+
| Tags | `list_tags` | `add_tag`, `remove_tag_by_id`, `delete_tag_definition` |
|
|
71
71
|
| Users & teams | `list_users`, `get_current_user`, `list_teams` | — |
|
|
72
72
|
| Reference metadata | `list_lostreasons`, `list_activitytypes`, `list_categories`, `list_goals`, `get_site` | — |
|
|
73
73
|
|
package/dist/http.js
CHANGED
|
@@ -26,6 +26,22 @@ var chainHandlers = {
|
|
|
26
26
|
"capsule.request": (ctx) => {
|
|
27
27
|
ctx.capsuleCalls += 1;
|
|
28
28
|
},
|
|
29
|
+
// A timed-out or connection-failed call is still an attempt that
|
|
30
|
+
// never reaches the `capsule.request` emit (it throws at the fetch
|
|
31
|
+
// stage). Count it here so `tool.chain.capsuleCalls` stays honest and
|
|
32
|
+
// a chain whose duration ballooned is explained by a visible failure.
|
|
33
|
+
"capsule.timeout": (ctx) => {
|
|
34
|
+
ctx.capsuleCalls += 1;
|
|
35
|
+
},
|
|
36
|
+
"capsule.error": (ctx) => {
|
|
37
|
+
ctx.capsuleCalls += 1;
|
|
38
|
+
},
|
|
39
|
+
// A request that exhausted its 429 retry is a real (doubly-attempted)
|
|
40
|
+
// outbound call that throws before `capsule.request` fires — count it
|
|
41
|
+
// so a chain whose latency ballooned on rate-limit backoff is explained.
|
|
42
|
+
"capsule.ratelimit": (ctx) => {
|
|
43
|
+
ctx.capsuleCalls += 1;
|
|
44
|
+
},
|
|
29
45
|
// Cache-hit events feed the aggregate so the chain stat is right
|
|
30
46
|
// even on tools whose Capsule calls all hit the cache.
|
|
31
47
|
"cache.hit": (ctx) => {
|
|
@@ -184,6 +200,15 @@ var CapsuleApiError = class extends Error {
|
|
|
184
200
|
}
|
|
185
201
|
status;
|
|
186
202
|
};
|
|
203
|
+
var CapsuleTimeoutError = class extends CapsuleApiError {
|
|
204
|
+
constructor() {
|
|
205
|
+
super(
|
|
206
|
+
504,
|
|
207
|
+
`Capsule API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Capsule API may be slow or hung; retry after a short wait. If the failed call was a write/delete, read the entity first to see whether the change actually applied before retrying.`
|
|
208
|
+
);
|
|
209
|
+
this.name = "CapsuleTimeoutError";
|
|
210
|
+
}
|
|
211
|
+
};
|
|
187
212
|
function getToken() {
|
|
188
213
|
const token = process.env["CAPSULE_API_TOKEN"];
|
|
189
214
|
if (!token) {
|
|
@@ -264,30 +289,39 @@ async function mapAbort(p) {
|
|
|
264
289
|
return await p;
|
|
265
290
|
} catch (err) {
|
|
266
291
|
if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
|
|
267
|
-
throw new
|
|
268
|
-
504,
|
|
269
|
-
`Capsule API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Capsule API may be slow or hung; retry after a short wait. If the failed call was a write/delete, read the entity first to see whether the change actually applied before retrying.`
|
|
270
|
-
);
|
|
292
|
+
throw new CapsuleTimeoutError();
|
|
271
293
|
}
|
|
272
294
|
throw err;
|
|
273
295
|
}
|
|
274
296
|
}
|
|
275
297
|
async function fetchWithTimeout(url, options) {
|
|
276
298
|
const { options: opts, cleanup } = withTimeout(options);
|
|
299
|
+
const startedAt = Date.now();
|
|
277
300
|
try {
|
|
278
301
|
const res = await fetch(url, opts);
|
|
279
302
|
return { res, cleanup };
|
|
280
303
|
} catch (err) {
|
|
281
304
|
cleanup();
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
)
|
|
305
|
+
const isAbort = err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message));
|
|
306
|
+
emitCapsuleFailure(
|
|
307
|
+
options?.method ?? "GET",
|
|
308
|
+
url,
|
|
309
|
+
Date.now() - startedAt,
|
|
310
|
+
isAbort ? "timeout" : "network",
|
|
311
|
+
isAbort ? void 0 : err
|
|
312
|
+
);
|
|
313
|
+
if (isAbort) {
|
|
314
|
+
throw new CapsuleTimeoutError();
|
|
287
315
|
}
|
|
288
316
|
throw err;
|
|
289
317
|
}
|
|
290
318
|
}
|
|
319
|
+
async function drainBody(res) {
|
|
320
|
+
try {
|
|
321
|
+
await res.body?.cancel();
|
|
322
|
+
} catch {
|
|
323
|
+
}
|
|
324
|
+
}
|
|
291
325
|
async function doFetch(url, options) {
|
|
292
326
|
const startedAt = Date.now();
|
|
293
327
|
const method = options?.method ?? "GET";
|
|
@@ -295,10 +329,13 @@ async function doFetch(url, options) {
|
|
|
295
329
|
if (first.res.status === 429) {
|
|
296
330
|
const delay = parseRateLimitDelay(first.res);
|
|
297
331
|
first.cleanup();
|
|
332
|
+
await drainBody(first.res);
|
|
298
333
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
299
334
|
const retried = await fetchWithTimeout(url, options);
|
|
300
335
|
if (retried.res.status === 429) {
|
|
301
336
|
retried.cleanup();
|
|
337
|
+
await drainBody(retried.res);
|
|
338
|
+
emitCapsuleRateLimited(method, url, Date.now() - startedAt);
|
|
302
339
|
throw new CapsuleApiError(
|
|
303
340
|
429,
|
|
304
341
|
"Rate limit exceeded after one retry. Please slow down your requests."
|
|
@@ -310,8 +347,7 @@ async function doFetch(url, options) {
|
|
|
310
347
|
}
|
|
311
348
|
async function consumeBody(start, body) {
|
|
312
349
|
try {
|
|
313
|
-
|
|
314
|
-
} finally {
|
|
350
|
+
const result = await body();
|
|
315
351
|
emitCapsuleRequest(
|
|
316
352
|
start.method,
|
|
317
353
|
start.url,
|
|
@@ -319,15 +355,31 @@ async function consumeBody(start, body) {
|
|
|
319
355
|
Date.now() - start.startedAt,
|
|
320
356
|
start.retriedAfter429
|
|
321
357
|
);
|
|
358
|
+
return result;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
if (err instanceof CapsuleTimeoutError) {
|
|
361
|
+
emitCapsuleFailure(start.method, start.url, Date.now() - start.startedAt, "timeout");
|
|
362
|
+
} else {
|
|
363
|
+
emitCapsuleRequest(
|
|
364
|
+
start.method,
|
|
365
|
+
start.url,
|
|
366
|
+
start.res,
|
|
367
|
+
Date.now() - start.startedAt,
|
|
368
|
+
start.retriedAfter429
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
throw err;
|
|
322
372
|
}
|
|
323
373
|
}
|
|
324
|
-
function
|
|
325
|
-
let path = "";
|
|
374
|
+
function redactedPath(url) {
|
|
326
375
|
try {
|
|
327
|
-
|
|
376
|
+
return redactPath(new URL(url).pathname);
|
|
328
377
|
} catch {
|
|
329
|
-
|
|
378
|
+
return "?";
|
|
330
379
|
}
|
|
380
|
+
}
|
|
381
|
+
function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
|
|
382
|
+
const path = redactedPath(url);
|
|
331
383
|
const lenHeader = res.headers.get("content-length");
|
|
332
384
|
const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
|
|
333
385
|
logEvent("capsule.request", {
|
|
@@ -339,6 +391,37 @@ function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
|
|
|
339
391
|
...retriedAfter429 ? { retriedAfter429: true } : {}
|
|
340
392
|
});
|
|
341
393
|
}
|
|
394
|
+
function emitCapsuleFailure(method, url, elapsedMs, reason, err) {
|
|
395
|
+
const path = redactedPath(url);
|
|
396
|
+
if (reason === "timeout") {
|
|
397
|
+
logEvent(
|
|
398
|
+
"capsule.timeout",
|
|
399
|
+
{ method, path, elapsedMs, timeoutMs: REQUEST_TIMEOUT_MS },
|
|
400
|
+
{ force: true }
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const code = extractErrorCode(err);
|
|
405
|
+
logEvent(
|
|
406
|
+
"capsule.error",
|
|
407
|
+
{ method, path, elapsedMs, ...code ? { code } : {} },
|
|
408
|
+
{ force: true }
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
function emitCapsuleRateLimited(method, url, elapsedMs) {
|
|
412
|
+
logEvent(
|
|
413
|
+
"capsule.ratelimit",
|
|
414
|
+
{ method, path: redactedPath(url), elapsedMs, status: 429 },
|
|
415
|
+
{ force: true }
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
function extractErrorCode(err) {
|
|
419
|
+
const e = err;
|
|
420
|
+
const code = e?.cause?.code ?? e?.code;
|
|
421
|
+
if (typeof code === "string") return code;
|
|
422
|
+
if (typeof e?.name === "string" && e.name !== "Error") return e.name;
|
|
423
|
+
return void 0;
|
|
424
|
+
}
|
|
342
425
|
async function throwForStatus(res) {
|
|
343
426
|
if (res.status === 401) {
|
|
344
427
|
const detail = await parseErrorBody(res);
|
|
@@ -1142,6 +1225,22 @@ var abortControllers = /* @__PURE__ */ new Map();
|
|
|
1142
1225
|
function registerAbortController(taskId, controller) {
|
|
1143
1226
|
abortControllers.set(taskId, controller);
|
|
1144
1227
|
}
|
|
1228
|
+
var evictionTimers = /* @__PURE__ */ new Map();
|
|
1229
|
+
var taskTtls = /* @__PURE__ */ new Map();
|
|
1230
|
+
function scheduleEviction(taskId, clientId, ttlMs) {
|
|
1231
|
+
const existing = evictionTimers.get(taskId);
|
|
1232
|
+
if (existing) clearTimeout(existing);
|
|
1233
|
+
taskTtls.set(taskId, ttlMs);
|
|
1234
|
+
const timer = setTimeout(() => {
|
|
1235
|
+
owners.delete(taskId);
|
|
1236
|
+
abortControllers.delete(taskId);
|
|
1237
|
+
evictionTimers.delete(taskId);
|
|
1238
|
+
taskTtls.delete(taskId);
|
|
1239
|
+
logEvent("task.evicted", { taskId, clientId, reason: "ttl" });
|
|
1240
|
+
}, ttlMs);
|
|
1241
|
+
timer.unref?.();
|
|
1242
|
+
evictionTimers.set(taskId, timer);
|
|
1243
|
+
}
|
|
1145
1244
|
function countPerClient(clientId) {
|
|
1146
1245
|
let n = 0;
|
|
1147
1246
|
for (const owner of owners.values()) {
|
|
@@ -1195,12 +1294,7 @@ function createScopedTaskStore(clientId) {
|
|
|
1195
1294
|
sessionId
|
|
1196
1295
|
);
|
|
1197
1296
|
owners.set(task.taskId, clientId);
|
|
1198
|
-
|
|
1199
|
-
owners.delete(task.taskId);
|
|
1200
|
-
abortControllers.delete(task.taskId);
|
|
1201
|
-
logEvent("task.evicted", { taskId: task.taskId, clientId, reason: "ttl" });
|
|
1202
|
-
}, clampedTtl);
|
|
1203
|
-
timer.unref?.();
|
|
1297
|
+
scheduleEviction(task.taskId, clientId, clampedTtl);
|
|
1204
1298
|
logEvent("task.created", {
|
|
1205
1299
|
taskId: task.taskId,
|
|
1206
1300
|
clientId,
|
|
@@ -1219,6 +1313,7 @@ function createScopedTaskStore(clientId) {
|
|
|
1219
1313
|
}
|
|
1220
1314
|
logEvent("task.transition", { taskId, clientId, status });
|
|
1221
1315
|
await global.storeTaskResult(taskId, status, result, sessionId);
|
|
1316
|
+
scheduleEviction(taskId, clientId, taskTtls.get(taskId) ?? getTasksConfig().defaultTtlMs);
|
|
1222
1317
|
},
|
|
1223
1318
|
async getTaskResult(taskId, sessionId) {
|
|
1224
1319
|
if (owners.get(taskId) !== clientId) {
|
|
@@ -1238,6 +1333,7 @@ function createScopedTaskStore(clientId) {
|
|
|
1238
1333
|
}
|
|
1239
1334
|
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
1240
1335
|
abortControllers.delete(taskId);
|
|
1336
|
+
scheduleEviction(taskId, clientId, taskTtls.get(taskId) ?? getTasksConfig().defaultTtlMs);
|
|
1241
1337
|
}
|
|
1242
1338
|
},
|
|
1243
1339
|
async listTasks(cursor, sessionId) {
|
|
@@ -1256,12 +1352,12 @@ function isDestructive(name) {
|
|
|
1256
1352
|
}
|
|
1257
1353
|
function inferAnnotations(name) {
|
|
1258
1354
|
if (READ_PREFIXES.some((p) => name.startsWith(p))) {
|
|
1259
|
-
return { readOnlyHint: true };
|
|
1355
|
+
return { readOnlyHint: true, destructiveHint: false };
|
|
1260
1356
|
}
|
|
1261
1357
|
if (isDestructive(name)) {
|
|
1262
|
-
return { destructiveHint: true };
|
|
1358
|
+
return { readOnlyHint: false, destructiveHint: true };
|
|
1263
1359
|
}
|
|
1264
|
-
return
|
|
1360
|
+
return { readOnlyHint: false, destructiveHint: false };
|
|
1265
1361
|
}
|
|
1266
1362
|
function argFieldNames(input) {
|
|
1267
1363
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
|
|
@@ -1565,6 +1661,26 @@ async function readEntityRefs(path, responseKey) {
|
|
|
1565
1661
|
};
|
|
1566
1662
|
}
|
|
1567
1663
|
|
|
1664
|
+
// src/capsule/multi-get.ts
|
|
1665
|
+
var MULTI_GET_MAX_IDS = 10;
|
|
1666
|
+
async function chunkedMultiGet(base, responseKey, ids, params) {
|
|
1667
|
+
if (ids.length <= MULTI_GET_MAX_IDS) {
|
|
1668
|
+
const { data } = await capsuleGet(
|
|
1669
|
+
`${base}/${ids.join(",")}`,
|
|
1670
|
+
params
|
|
1671
|
+
);
|
|
1672
|
+
return data;
|
|
1673
|
+
}
|
|
1674
|
+
const chunks = chunk(ids, MULTI_GET_MAX_IDS);
|
|
1675
|
+
const responses = await Promise.all(
|
|
1676
|
+
chunks.map(
|
|
1677
|
+
(chunkIds) => capsuleGet(`${base}/${chunkIds.join(",")}`, params)
|
|
1678
|
+
)
|
|
1679
|
+
);
|
|
1680
|
+
const merged = responses.flatMap((r) => r.data[responseKey] ?? []);
|
|
1681
|
+
return { ...responses[0]?.data ?? {}, [responseKey]: merged };
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1568
1684
|
// src/tools/custom-field-helpers.ts
|
|
1569
1685
|
import { z as z6 } from "zod";
|
|
1570
1686
|
var CustomFieldWriteSchema = z6.object({
|
|
@@ -1687,20 +1803,7 @@ var getPartiesSchema = z7.object({
|
|
|
1687
1803
|
embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1688
1804
|
});
|
|
1689
1805
|
async function getParties(input) {
|
|
1690
|
-
|
|
1691
|
-
if (ids.length <= 10) {
|
|
1692
|
-
const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
|
|
1693
|
-
embed
|
|
1694
|
-
});
|
|
1695
|
-
return data;
|
|
1696
|
-
}
|
|
1697
|
-
const chunks = chunk(ids, 10);
|
|
1698
|
-
const responses = await Promise.all(
|
|
1699
|
-
chunks.map(
|
|
1700
|
-
(chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
|
|
1701
|
-
)
|
|
1702
|
-
);
|
|
1703
|
-
return { parties: responses.flatMap((r) => r.data.parties) };
|
|
1806
|
+
return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
|
|
1704
1807
|
}
|
|
1705
1808
|
var listPartyOpportunitiesSchema = z7.object({
|
|
1706
1809
|
partyId: positiveId,
|
|
@@ -2011,23 +2114,7 @@ var getOpportunitiesSchema = z8.object({
|
|
|
2011
2114
|
embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
2012
2115
|
});
|
|
2013
2116
|
async function getOpportunities(input) {
|
|
2014
|
-
|
|
2015
|
-
if (ids.length <= 10) {
|
|
2016
|
-
const { data } = await capsuleGet(
|
|
2017
|
-
`/opportunities/${ids.join(",")}`,
|
|
2018
|
-
{ embed }
|
|
2019
|
-
);
|
|
2020
|
-
return data;
|
|
2021
|
-
}
|
|
2022
|
-
const chunks = chunk(ids, 10);
|
|
2023
|
-
const responses = await Promise.all(
|
|
2024
|
-
chunks.map(
|
|
2025
|
-
(chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
|
|
2026
|
-
embed
|
|
2027
|
-
})
|
|
2028
|
-
)
|
|
2029
|
-
);
|
|
2030
|
-
return { opportunities: responses.flatMap((r) => r.data.opportunities) };
|
|
2117
|
+
return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
|
|
2031
2118
|
}
|
|
2032
2119
|
var createOpportunitySchema = z8.object({
|
|
2033
2120
|
name: z8.string().min(1),
|
|
@@ -2155,20 +2242,7 @@ var getProjectsSchema = z9.object({
|
|
|
2155
2242
|
embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
2156
2243
|
});
|
|
2157
2244
|
async function getProjects(input) {
|
|
2158
|
-
|
|
2159
|
-
if (ids.length <= 10) {
|
|
2160
|
-
const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
|
|
2161
|
-
embed
|
|
2162
|
-
});
|
|
2163
|
-
return data;
|
|
2164
|
-
}
|
|
2165
|
-
const chunks = chunk(ids, 10);
|
|
2166
|
-
const responses = await Promise.all(
|
|
2167
|
-
chunks.map(
|
|
2168
|
-
(chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
|
|
2169
|
-
)
|
|
2170
|
-
);
|
|
2171
|
-
return { kases: responses.flatMap((r) => r.data.kases) };
|
|
2245
|
+
return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
|
|
2172
2246
|
}
|
|
2173
2247
|
var createProjectSchema = z9.object({
|
|
2174
2248
|
name: z9.string().min(1),
|
|
@@ -2300,16 +2374,7 @@ var getTasksSchema = z10.object({
|
|
|
2300
2374
|
)
|
|
2301
2375
|
});
|
|
2302
2376
|
async function getTasks(input) {
|
|
2303
|
-
|
|
2304
|
-
if (ids.length <= 10) {
|
|
2305
|
-
const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
|
|
2306
|
-
return data;
|
|
2307
|
-
}
|
|
2308
|
-
const chunks = chunk(ids, 10);
|
|
2309
|
-
const responses = await Promise.all(
|
|
2310
|
-
chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
|
|
2311
|
-
);
|
|
2312
|
-
return { tasks: responses.flatMap((r) => r.data.tasks) };
|
|
2377
|
+
return chunkedMultiGet("/tasks", "tasks", input.ids);
|
|
2313
2378
|
}
|
|
2314
2379
|
var createTaskSchema = z10.object({
|
|
2315
2380
|
description: z10.string().min(1),
|
|
@@ -2410,14 +2475,102 @@ var listEntriesPagination = {
|
|
|
2410
2475
|
};
|
|
2411
2476
|
var listPartyEntriesSchema = z11.object({
|
|
2412
2477
|
partyId: positiveId,
|
|
2413
|
-
...listEntriesPagination
|
|
2478
|
+
...listEntriesPagination,
|
|
2479
|
+
includeLinkedPersons: z11.boolean().optional().describe(
|
|
2480
|
+
"When true AND `partyId` is an ORGANISATION, also include entries filed against the organisation's linked people (the persons whose `organisation` field references this org). The connector enumerates linked persons via `GET /parties/{orgId}/people`, fans out `GET /parties/{personId}/entries` in parallel (concurrency-capped, default 5 / configurable via `CAPSULE_MCP_BATCH_CONCURRENCY`), and merges into a single feed sorted by `entryAt` descending, deduped by entry id. Default is `false` \u2014 single GET, existing behaviour unchanged. WHY THIS FLAG EXISTS: Capsule's API files each entry against exactly one party row (verified v1.6.6 wire-trace probe 4 \u2014 POST /entries rejects multi-party bodies with 422 'entry must be linked to either a party, opportunity or kase'). For an organisation with multiple contacts, captured emails almost always land on a person row, not the org. As a result, `list_party_entries(orgId)` with `includeLinkedPersons: false` will miss recent customer-facing email \u2014 even though the org's own `lastContactedAt` is updated by the activity. This flag is the correct call for any 'what's new with $ORG?' question. WHEN `partyId` IS A PERSON: silently no-op \u2014 persons have no linked-people relationship in Capsule's data model, so the flag is functionally inert (the connector still issues a cheap `/people` check; the response is empty). LATENCY: 1 + N round trips for an org with N linked people, concurrency-capped (typical: 2-3 waves for N=10). Linked-person enumeration reads the first 100 linked people; use list_employees for explicit pagination when an organisation has more contacts than that. Use `includeLinkedPersons: false` for fast pre-screen reads where you only need the org-row entries (e.g. invoice/contract notes that are typically filed at the org level). PAGINATION CAVEAT: `page` and `perPage` apply to the MERGED window, and the merge has a hard ceiling \u2014 it reliably orders only the most-recent ~100 entries across the org + its people (each party is fetched at Capsule's per-party cap of 100, and a top-100-per-party merge is correct only up to global position 100). Windows that cross the ceiling are truncated to the entries still inside that top-100 set; windows starting beyond it return no entries and end the feed. It does NOT continue into older history. To read a specific contact's full timeline beyond the merged ceiling, call `list_party_entries` on that person's id directly (the default single-GET path paginates natively with no ceiling). For the LLM-driven 'what's the latest with $ORG' query this is the typical use of, the first page is exact and the ceiling is never reached."
|
|
2481
|
+
)
|
|
2414
2482
|
});
|
|
2483
|
+
async function fanOutPartyEntries(partyIds, embed, perPage) {
|
|
2484
|
+
const concurrency = getBatchConcurrency();
|
|
2485
|
+
const results = new Array(partyIds.length);
|
|
2486
|
+
let cursor = 0;
|
|
2487
|
+
async function worker() {
|
|
2488
|
+
while (true) {
|
|
2489
|
+
const i = cursor;
|
|
2490
|
+
cursor += 1;
|
|
2491
|
+
if (i >= partyIds.length) return;
|
|
2492
|
+
const id = partyIds[i];
|
|
2493
|
+
const { data, nextPage } = await capsuleGet(
|
|
2494
|
+
`/parties/${id}/entries`,
|
|
2495
|
+
{
|
|
2496
|
+
embed,
|
|
2497
|
+
page: 1,
|
|
2498
|
+
perPage
|
|
2499
|
+
}
|
|
2500
|
+
);
|
|
2501
|
+
results[i] = { entries: data.entries, nextPage };
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
const workers = [];
|
|
2505
|
+
for (let w = 0; w < Math.min(concurrency, partyIds.length); w++) {
|
|
2506
|
+
workers.push(worker());
|
|
2507
|
+
}
|
|
2508
|
+
await Promise.all(workers);
|
|
2509
|
+
return results;
|
|
2510
|
+
}
|
|
2511
|
+
function mergedTimelineCandidatePerParty(page, perPage) {
|
|
2512
|
+
return Math.min(page * perPage, 100);
|
|
2513
|
+
}
|
|
2514
|
+
function mergedTimelineNextPage(page, perPage, mergedLength, upstreamHasNextPage) {
|
|
2515
|
+
const requestedWindowEnd = page * perPage;
|
|
2516
|
+
if (mergedLength > requestedWindowEnd) return page + 1;
|
|
2517
|
+
const nextWindowWithinCap = requestedWindowEnd < 100;
|
|
2518
|
+
if (nextWindowWithinCap && upstreamHasNextPage) return page + 1;
|
|
2519
|
+
return void 0;
|
|
2520
|
+
}
|
|
2415
2521
|
async function listPartyEntries(input) {
|
|
2416
|
-
const {
|
|
2417
|
-
|
|
2418
|
-
{
|
|
2522
|
+
const { partyId, embed, page, perPage, includeLinkedPersons } = input;
|
|
2523
|
+
if (!includeLinkedPersons) {
|
|
2524
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
2525
|
+
`/parties/${partyId}/entries`,
|
|
2526
|
+
{ embed, page, perPage }
|
|
2527
|
+
);
|
|
2528
|
+
return { ...data, nextPage: nextPage2 };
|
|
2529
|
+
}
|
|
2530
|
+
const { data: peopleData } = await capsuleGet(
|
|
2531
|
+
`/parties/${partyId}/people`,
|
|
2532
|
+
{ page: 1, perPage: 100 }
|
|
2419
2533
|
);
|
|
2420
|
-
|
|
2534
|
+
const peopleIds = (peopleData.parties ?? []).map((p) => p.id);
|
|
2535
|
+
if (peopleIds.length === 0) {
|
|
2536
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
2537
|
+
`/parties/${partyId}/entries`,
|
|
2538
|
+
{ embed, page, perPage }
|
|
2539
|
+
);
|
|
2540
|
+
return { ...data, nextPage: nextPage2 };
|
|
2541
|
+
}
|
|
2542
|
+
const targetIds = [partyId, ...peopleIds];
|
|
2543
|
+
const perPartyPages = await fanOutPartyEntries(
|
|
2544
|
+
targetIds,
|
|
2545
|
+
embed,
|
|
2546
|
+
mergedTimelineCandidatePerParty(page, perPage)
|
|
2547
|
+
);
|
|
2548
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2549
|
+
const merged = [];
|
|
2550
|
+
for (const { entries } of perPartyPages) {
|
|
2551
|
+
for (const raw of entries) {
|
|
2552
|
+
const e = raw;
|
|
2553
|
+
if (typeof e?.id !== "number") continue;
|
|
2554
|
+
if (seen.has(e.id)) continue;
|
|
2555
|
+
seen.add(e.id);
|
|
2556
|
+
merged.push(e);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
merged.sort((a, b) => {
|
|
2560
|
+
const ax = a.entryAt ?? "";
|
|
2561
|
+
const bx = b.entryAt ?? "";
|
|
2562
|
+
if (ax !== bx) return bx.localeCompare(ax);
|
|
2563
|
+
return b.id - a.id;
|
|
2564
|
+
});
|
|
2565
|
+
const start = (page - 1) * perPage;
|
|
2566
|
+
const slice = merged.slice(start, start + perPage);
|
|
2567
|
+
const nextPage = mergedTimelineNextPage(
|
|
2568
|
+
page,
|
|
2569
|
+
perPage,
|
|
2570
|
+
merged.length,
|
|
2571
|
+
perPartyPages.some((p) => p.nextPage !== void 0)
|
|
2572
|
+
);
|
|
2573
|
+
return { entries: slice, ...nextPage !== void 0 ? { nextPage } : {} };
|
|
2421
2574
|
}
|
|
2422
2575
|
var listOpportunityEntriesSchema = z11.object({
|
|
2423
2576
|
opportunityId: positiveId,
|
|
@@ -2640,6 +2793,28 @@ async function removeTagById(input) {
|
|
|
2640
2793
|
invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
|
|
2641
2794
|
return result;
|
|
2642
2795
|
}
|
|
2796
|
+
var deleteTagDefinitionSchema = z14.object({
|
|
2797
|
+
entity: TagEntity,
|
|
2798
|
+
tagId: positiveId.describe(
|
|
2799
|
+
"The tag definition's id (from list_tags, or embed='tags' on a record). NOT an entity id."
|
|
2800
|
+
),
|
|
2801
|
+
confirm: confirmFlag().describe(
|
|
2802
|
+
"Must be set to true. DESTRUCTIVE & tenant-wide: permanently deletes the tag DEFINITION from this entity type's tag namespace, removing it from EVERY record that shares it \u2014 not just one. To detach a tag from a single record while keeping the definition, use remove_tag_by_id instead. Irreversible (the definition is gone; re-creating by name via add_tag mints a new id). Idempotent on retry."
|
|
2803
|
+
)
|
|
2804
|
+
});
|
|
2805
|
+
async function deleteTagDefinition(input) {
|
|
2806
|
+
const { entity, tagId, confirm } = input;
|
|
2807
|
+
if (confirm !== true) {
|
|
2808
|
+
throw new Error("delete_tag_definition requires confirm: true");
|
|
2809
|
+
}
|
|
2810
|
+
const result = await idempotent(
|
|
2811
|
+
() => capsuleDelete(`/${entity}/tags/${tagId}`),
|
|
2812
|
+
() => ({ deleted: true, alreadyDeleted: false, entity, tagId }),
|
|
2813
|
+
() => ({ deleted: true, alreadyDeleted: true, entity, tagId })
|
|
2814
|
+
);
|
|
2815
|
+
invalidateByPrefix(TAG_LIST_PATH[entity], "delete_tag_definition");
|
|
2816
|
+
return result;
|
|
2817
|
+
}
|
|
2643
2818
|
var { schema: batchAddTagSchema, handler: batchAddTag } = defineBatch({
|
|
2644
2819
|
toolName: "batch_add_tag",
|
|
2645
2820
|
itemSchema: addTagSchema,
|
|
@@ -3145,7 +3320,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3145
3320
|
const server = new McpServer(
|
|
3146
3321
|
{
|
|
3147
3322
|
name: "capsulemcp",
|
|
3148
|
-
version: "1.
|
|
3323
|
+
version: "1.8.0",
|
|
3149
3324
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
3150
3325
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
3151
3326
|
icons: ICONS
|
|
@@ -3563,7 +3738,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3563
3738
|
registerTool(
|
|
3564
3739
|
server,
|
|
3565
3740
|
"list_party_entries",
|
|
3566
|
-
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively.",
|
|
3741
|
+
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively. IMPORTANT for organisations: pass `includeLinkedPersons: true` to surface entries filed against the org's linked people (sales-conversation emails almost always land on a person row, not the org row \u2014 Capsule's API files each entry against exactly one party). Without this flag, an org with active customer-facing email will appear quiet here even though its `lastContactedAt` is current. For any 'what's new with $ORG?' query, set `includeLinkedPersons: true`.",
|
|
3567
3742
|
listPartyEntriesSchema,
|
|
3568
3743
|
listPartyEntries
|
|
3569
3744
|
);
|
|
@@ -3600,9 +3775,12 @@ function createCapsuleMcpServer(opts) {
|
|
|
3600
3775
|
"Download an attachment by id. Returns image content for image/* types (Claude can describe it natively); decoded text for text/* and application/json (small files); JSON metadata + base64 payload for other binary types (PDF, Office docs, etc.). Files exceeding maxSizeBytes (default 5MB) return metadata only with a `truncated: true` flag.",
|
|
3601
3776
|
getAttachmentSchema.shape,
|
|
3602
3777
|
// get_attachment is read-only — downloads a binary, never mutates.
|
|
3603
|
-
// Mirrors the auto-inferred `readOnlyHint: true
|
|
3604
|
-
// `registerTool` applies to every other `get_*` tool.
|
|
3605
|
-
|
|
3778
|
+
// Mirrors the auto-inferred `{readOnlyHint: true, destructiveHint:
|
|
3779
|
+
// false}` that `registerTool` applies to every other `get_*` tool.
|
|
3780
|
+
// Explicit destructiveHint: false is load-bearing — MCP spec
|
|
3781
|
+
// defaults destructiveHint to `true`, so omitting it would (in
|
|
3782
|
+
// some client implementations) classify this read as destructive.
|
|
3783
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
3606
3784
|
async (input) => {
|
|
3607
3785
|
const result = await getAttachment(input);
|
|
3608
3786
|
if (result.truncated) {
|
|
@@ -3833,6 +4011,13 @@ function createCapsuleMcpServer(opts) {
|
|
|
3833
4011
|
removeTagByIdSchema,
|
|
3834
4012
|
removeTagById
|
|
3835
4013
|
);
|
|
4014
|
+
registerTool(
|
|
4015
|
+
server,
|
|
4016
|
+
"delete_tag_definition",
|
|
4017
|
+
"DESTRUCTIVE & TENANT-WIDE: permanently delete a tag DEFINITION from an entity type's tag namespace (parties / opportunities / kases). Unlike remove_tag_by_id \u2014 which detaches a tag from ONE record and leaves the definition intact for others \u2014 this removes the definition itself, so the tag disappears from EVERY record that shared it. Use it to clean up stray / mistyped / test tag definitions polluting the tenant-global list. Requires confirm=true. Always read the affected tag first via list_tags and confirm with the user; if you only want to untag one record, use remove_tag_by_id instead. Irreversible (re-creating by name via add_tag mints a brand-new id). Idempotent on retry: `{deleted: true, alreadyDeleted: false, entity, tagId}` on a fresh delete, or `{deleted: true, alreadyDeleted: true, entity, tagId}` if the definition was already gone (Capsule's 404 is caught). Endpoint verified empirically (DELETE /<entity>/tags/{id} \u2192 204).",
|
|
4018
|
+
deleteTagDefinitionSchema,
|
|
4019
|
+
deleteTagDefinition
|
|
4020
|
+
);
|
|
3836
4021
|
registerBatchTool(
|
|
3837
4022
|
server,
|
|
3838
4023
|
"batch_add_tag",
|
|
@@ -3950,6 +4135,34 @@ function createApp(opts) {
|
|
|
3950
4135
|
};
|
|
3951
4136
|
app2.get("/icon.svg", iconHandler);
|
|
3952
4137
|
app2.get("/favicon.ico", iconHandler);
|
|
4138
|
+
const LANDING_HTML = `<!doctype html>
|
|
4139
|
+
<html lang="en">
|
|
4140
|
+
<head>
|
|
4141
|
+
<meta charset="utf-8">
|
|
4142
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4143
|
+
<title>capsulemcp</title>
|
|
4144
|
+
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
|
4145
|
+
<link rel="apple-touch-icon" href="/icon.svg">
|
|
4146
|
+
<meta name="description" content="Model Context Protocol server for Capsule CRM. MCP endpoint: /mcp">
|
|
4147
|
+
<style>
|
|
4148
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:42em;margin:3em auto;padding:0 1em;color:#222;line-height:1.5}
|
|
4149
|
+
h1{font-size:1.6em;margin-bottom:0.2em}
|
|
4150
|
+
code{background:#f3f3f3;padding:0.1em 0.35em;border-radius:3px;font-size:0.95em}
|
|
4151
|
+
a{color:#1e3a8a}
|
|
4152
|
+
.muted{color:#666;font-size:0.92em}
|
|
4153
|
+
</style>
|
|
4154
|
+
</head>
|
|
4155
|
+
<body>
|
|
4156
|
+
<h1>capsulemcp</h1>
|
|
4157
|
+
<p>This is the HTTP+OAuth deployment of <a href="https://github.com/soil-dev/capsulemcp">capsulemcp</a>, a Model Context Protocol (MCP) server for Capsule CRM.</p>
|
|
4158
|
+
<p>The MCP endpoint is at <code>/mcp</code>. Use Claude.ai's Custom Connector flow (or any MCP-compatible client) to connect — this URL is not navigable by hand.</p>
|
|
4159
|
+
<p class="muted">Source: <a href="https://github.com/soil-dev/capsulemcp">github.com/soil-dev/capsulemcp</a> · License: Apache-2.0</p>
|
|
4160
|
+
</body>
|
|
4161
|
+
</html>
|
|
4162
|
+
`;
|
|
4163
|
+
app2.get("/", (_req, res) => {
|
|
4164
|
+
res.set("Content-Type", "text/html; charset=utf-8").set("Cache-Control", "public, max-age=3600").send(LANDING_HTML);
|
|
4165
|
+
});
|
|
3953
4166
|
const guardOrigin = (req, res, next) => {
|
|
3954
4167
|
const origin = req.get("Origin");
|
|
3955
4168
|
if (!origin) {
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,22 @@ var chainHandlers = {
|
|
|
31
31
|
"capsule.request": (ctx) => {
|
|
32
32
|
ctx.capsuleCalls += 1;
|
|
33
33
|
},
|
|
34
|
+
// A timed-out or connection-failed call is still an attempt that
|
|
35
|
+
// never reaches the `capsule.request` emit (it throws at the fetch
|
|
36
|
+
// stage). Count it here so `tool.chain.capsuleCalls` stays honest and
|
|
37
|
+
// a chain whose duration ballooned is explained by a visible failure.
|
|
38
|
+
"capsule.timeout": (ctx) => {
|
|
39
|
+
ctx.capsuleCalls += 1;
|
|
40
|
+
},
|
|
41
|
+
"capsule.error": (ctx) => {
|
|
42
|
+
ctx.capsuleCalls += 1;
|
|
43
|
+
},
|
|
44
|
+
// A request that exhausted its 429 retry is a real (doubly-attempted)
|
|
45
|
+
// outbound call that throws before `capsule.request` fires — count it
|
|
46
|
+
// so a chain whose latency ballooned on rate-limit backoff is explained.
|
|
47
|
+
"capsule.ratelimit": (ctx) => {
|
|
48
|
+
ctx.capsuleCalls += 1;
|
|
49
|
+
},
|
|
34
50
|
// Cache-hit events feed the aggregate so the chain stat is right
|
|
35
51
|
// even on tools whose Capsule calls all hit the cache.
|
|
36
52
|
"cache.hit": (ctx) => {
|
|
@@ -166,6 +182,15 @@ var CapsuleApiError = class extends Error {
|
|
|
166
182
|
}
|
|
167
183
|
status;
|
|
168
184
|
};
|
|
185
|
+
var CapsuleTimeoutError = class extends CapsuleApiError {
|
|
186
|
+
constructor() {
|
|
187
|
+
super(
|
|
188
|
+
504,
|
|
189
|
+
`Capsule API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Capsule API may be slow or hung; retry after a short wait. If the failed call was a write/delete, read the entity first to see whether the change actually applied before retrying.`
|
|
190
|
+
);
|
|
191
|
+
this.name = "CapsuleTimeoutError";
|
|
192
|
+
}
|
|
193
|
+
};
|
|
169
194
|
function getToken() {
|
|
170
195
|
const token = process.env["CAPSULE_API_TOKEN"];
|
|
171
196
|
if (!token) {
|
|
@@ -246,30 +271,39 @@ async function mapAbort(p) {
|
|
|
246
271
|
return await p;
|
|
247
272
|
} catch (err) {
|
|
248
273
|
if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
|
|
249
|
-
throw new
|
|
250
|
-
504,
|
|
251
|
-
`Capsule API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Capsule API may be slow or hung; retry after a short wait. If the failed call was a write/delete, read the entity first to see whether the change actually applied before retrying.`
|
|
252
|
-
);
|
|
274
|
+
throw new CapsuleTimeoutError();
|
|
253
275
|
}
|
|
254
276
|
throw err;
|
|
255
277
|
}
|
|
256
278
|
}
|
|
257
279
|
async function fetchWithTimeout(url, options) {
|
|
258
280
|
const { options: opts, cleanup } = withTimeout(options);
|
|
281
|
+
const startedAt = Date.now();
|
|
259
282
|
try {
|
|
260
283
|
const res = await fetch(url, opts);
|
|
261
284
|
return { res, cleanup };
|
|
262
285
|
} catch (err) {
|
|
263
286
|
cleanup();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
)
|
|
287
|
+
const isAbort = err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message));
|
|
288
|
+
emitCapsuleFailure(
|
|
289
|
+
options?.method ?? "GET",
|
|
290
|
+
url,
|
|
291
|
+
Date.now() - startedAt,
|
|
292
|
+
isAbort ? "timeout" : "network",
|
|
293
|
+
isAbort ? void 0 : err
|
|
294
|
+
);
|
|
295
|
+
if (isAbort) {
|
|
296
|
+
throw new CapsuleTimeoutError();
|
|
269
297
|
}
|
|
270
298
|
throw err;
|
|
271
299
|
}
|
|
272
300
|
}
|
|
301
|
+
async function drainBody(res) {
|
|
302
|
+
try {
|
|
303
|
+
await res.body?.cancel();
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
}
|
|
273
307
|
async function doFetch(url, options) {
|
|
274
308
|
const startedAt = Date.now();
|
|
275
309
|
const method = options?.method ?? "GET";
|
|
@@ -277,10 +311,13 @@ async function doFetch(url, options) {
|
|
|
277
311
|
if (first.res.status === 429) {
|
|
278
312
|
const delay = parseRateLimitDelay(first.res);
|
|
279
313
|
first.cleanup();
|
|
314
|
+
await drainBody(first.res);
|
|
280
315
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
281
316
|
const retried = await fetchWithTimeout(url, options);
|
|
282
317
|
if (retried.res.status === 429) {
|
|
283
318
|
retried.cleanup();
|
|
319
|
+
await drainBody(retried.res);
|
|
320
|
+
emitCapsuleRateLimited(method, url, Date.now() - startedAt);
|
|
284
321
|
throw new CapsuleApiError(
|
|
285
322
|
429,
|
|
286
323
|
"Rate limit exceeded after one retry. Please slow down your requests."
|
|
@@ -292,8 +329,7 @@ async function doFetch(url, options) {
|
|
|
292
329
|
}
|
|
293
330
|
async function consumeBody(start, body) {
|
|
294
331
|
try {
|
|
295
|
-
|
|
296
|
-
} finally {
|
|
332
|
+
const result = await body();
|
|
297
333
|
emitCapsuleRequest(
|
|
298
334
|
start.method,
|
|
299
335
|
start.url,
|
|
@@ -301,15 +337,31 @@ async function consumeBody(start, body) {
|
|
|
301
337
|
Date.now() - start.startedAt,
|
|
302
338
|
start.retriedAfter429
|
|
303
339
|
);
|
|
340
|
+
return result;
|
|
341
|
+
} catch (err) {
|
|
342
|
+
if (err instanceof CapsuleTimeoutError) {
|
|
343
|
+
emitCapsuleFailure(start.method, start.url, Date.now() - start.startedAt, "timeout");
|
|
344
|
+
} else {
|
|
345
|
+
emitCapsuleRequest(
|
|
346
|
+
start.method,
|
|
347
|
+
start.url,
|
|
348
|
+
start.res,
|
|
349
|
+
Date.now() - start.startedAt,
|
|
350
|
+
start.retriedAfter429
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
throw err;
|
|
304
354
|
}
|
|
305
355
|
}
|
|
306
|
-
function
|
|
307
|
-
let path = "";
|
|
356
|
+
function redactedPath(url) {
|
|
308
357
|
try {
|
|
309
|
-
|
|
358
|
+
return redactPath(new URL(url).pathname);
|
|
310
359
|
} catch {
|
|
311
|
-
|
|
360
|
+
return "?";
|
|
312
361
|
}
|
|
362
|
+
}
|
|
363
|
+
function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
|
|
364
|
+
const path = redactedPath(url);
|
|
313
365
|
const lenHeader = res.headers.get("content-length");
|
|
314
366
|
const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
|
|
315
367
|
logEvent("capsule.request", {
|
|
@@ -321,6 +373,37 @@ function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
|
|
|
321
373
|
...retriedAfter429 ? { retriedAfter429: true } : {}
|
|
322
374
|
});
|
|
323
375
|
}
|
|
376
|
+
function emitCapsuleFailure(method, url, elapsedMs, reason, err) {
|
|
377
|
+
const path = redactedPath(url);
|
|
378
|
+
if (reason === "timeout") {
|
|
379
|
+
logEvent(
|
|
380
|
+
"capsule.timeout",
|
|
381
|
+
{ method, path, elapsedMs, timeoutMs: REQUEST_TIMEOUT_MS },
|
|
382
|
+
{ force: true }
|
|
383
|
+
);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const code = extractErrorCode(err);
|
|
387
|
+
logEvent(
|
|
388
|
+
"capsule.error",
|
|
389
|
+
{ method, path, elapsedMs, ...code ? { code } : {} },
|
|
390
|
+
{ force: true }
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
function emitCapsuleRateLimited(method, url, elapsedMs) {
|
|
394
|
+
logEvent(
|
|
395
|
+
"capsule.ratelimit",
|
|
396
|
+
{ method, path: redactedPath(url), elapsedMs, status: 429 },
|
|
397
|
+
{ force: true }
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
function extractErrorCode(err) {
|
|
401
|
+
const e = err;
|
|
402
|
+
const code = e?.cause?.code ?? e?.code;
|
|
403
|
+
if (typeof code === "string") return code;
|
|
404
|
+
if (typeof e?.name === "string" && e.name !== "Error") return e.name;
|
|
405
|
+
return void 0;
|
|
406
|
+
}
|
|
324
407
|
async function throwForStatus(res) {
|
|
325
408
|
if (res.status === 401) {
|
|
326
409
|
const detail = await parseErrorBody(res);
|
|
@@ -639,6 +722,22 @@ var abortControllers = /* @__PURE__ */ new Map();
|
|
|
639
722
|
function registerAbortController(taskId, controller) {
|
|
640
723
|
abortControllers.set(taskId, controller);
|
|
641
724
|
}
|
|
725
|
+
var evictionTimers = /* @__PURE__ */ new Map();
|
|
726
|
+
var taskTtls = /* @__PURE__ */ new Map();
|
|
727
|
+
function scheduleEviction(taskId, clientId, ttlMs) {
|
|
728
|
+
const existing = evictionTimers.get(taskId);
|
|
729
|
+
if (existing) clearTimeout(existing);
|
|
730
|
+
taskTtls.set(taskId, ttlMs);
|
|
731
|
+
const timer = setTimeout(() => {
|
|
732
|
+
owners.delete(taskId);
|
|
733
|
+
abortControllers.delete(taskId);
|
|
734
|
+
evictionTimers.delete(taskId);
|
|
735
|
+
taskTtls.delete(taskId);
|
|
736
|
+
logEvent("task.evicted", { taskId, clientId, reason: "ttl" });
|
|
737
|
+
}, ttlMs);
|
|
738
|
+
timer.unref?.();
|
|
739
|
+
evictionTimers.set(taskId, timer);
|
|
740
|
+
}
|
|
642
741
|
function countPerClient(clientId) {
|
|
643
742
|
let n = 0;
|
|
644
743
|
for (const owner of owners.values()) {
|
|
@@ -692,12 +791,7 @@ function createScopedTaskStore(clientId) {
|
|
|
692
791
|
sessionId
|
|
693
792
|
);
|
|
694
793
|
owners.set(task.taskId, clientId);
|
|
695
|
-
|
|
696
|
-
owners.delete(task.taskId);
|
|
697
|
-
abortControllers.delete(task.taskId);
|
|
698
|
-
logEvent("task.evicted", { taskId: task.taskId, clientId, reason: "ttl" });
|
|
699
|
-
}, clampedTtl);
|
|
700
|
-
timer.unref?.();
|
|
794
|
+
scheduleEviction(task.taskId, clientId, clampedTtl);
|
|
701
795
|
logEvent("task.created", {
|
|
702
796
|
taskId: task.taskId,
|
|
703
797
|
clientId,
|
|
@@ -716,6 +810,7 @@ function createScopedTaskStore(clientId) {
|
|
|
716
810
|
}
|
|
717
811
|
logEvent("task.transition", { taskId, clientId, status });
|
|
718
812
|
await global.storeTaskResult(taskId, status, result, sessionId);
|
|
813
|
+
scheduleEviction(taskId, clientId, taskTtls.get(taskId) ?? getTasksConfig().defaultTtlMs);
|
|
719
814
|
},
|
|
720
815
|
async getTaskResult(taskId, sessionId) {
|
|
721
816
|
if (owners.get(taskId) !== clientId) {
|
|
@@ -735,6 +830,7 @@ function createScopedTaskStore(clientId) {
|
|
|
735
830
|
}
|
|
736
831
|
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
737
832
|
abortControllers.delete(taskId);
|
|
833
|
+
scheduleEviction(taskId, clientId, taskTtls.get(taskId) ?? getTasksConfig().defaultTtlMs);
|
|
738
834
|
}
|
|
739
835
|
},
|
|
740
836
|
async listTasks(cursor, sessionId) {
|
|
@@ -753,12 +849,12 @@ function isDestructive(name) {
|
|
|
753
849
|
}
|
|
754
850
|
function inferAnnotations(name) {
|
|
755
851
|
if (READ_PREFIXES.some((p) => name.startsWith(p))) {
|
|
756
|
-
return { readOnlyHint: true };
|
|
852
|
+
return { readOnlyHint: true, destructiveHint: false };
|
|
757
853
|
}
|
|
758
854
|
if (isDestructive(name)) {
|
|
759
|
-
return { destructiveHint: true };
|
|
855
|
+
return { readOnlyHint: false, destructiveHint: true };
|
|
760
856
|
}
|
|
761
|
-
return
|
|
857
|
+
return { readOnlyHint: false, destructiveHint: false };
|
|
762
858
|
}
|
|
763
859
|
function argFieldNames(input) {
|
|
764
860
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
|
|
@@ -1062,6 +1158,26 @@ async function readEntityRefs(path, responseKey) {
|
|
|
1062
1158
|
};
|
|
1063
1159
|
}
|
|
1064
1160
|
|
|
1161
|
+
// src/capsule/multi-get.ts
|
|
1162
|
+
var MULTI_GET_MAX_IDS = 10;
|
|
1163
|
+
async function chunkedMultiGet(base, responseKey, ids, params) {
|
|
1164
|
+
if (ids.length <= MULTI_GET_MAX_IDS) {
|
|
1165
|
+
const { data } = await capsuleGet(
|
|
1166
|
+
`${base}/${ids.join(",")}`,
|
|
1167
|
+
params
|
|
1168
|
+
);
|
|
1169
|
+
return data;
|
|
1170
|
+
}
|
|
1171
|
+
const chunks = chunk(ids, MULTI_GET_MAX_IDS);
|
|
1172
|
+
const responses = await Promise.all(
|
|
1173
|
+
chunks.map(
|
|
1174
|
+
(chunkIds) => capsuleGet(`${base}/${chunkIds.join(",")}`, params)
|
|
1175
|
+
)
|
|
1176
|
+
);
|
|
1177
|
+
const merged = responses.flatMap((r) => r.data[responseKey] ?? []);
|
|
1178
|
+
return { ...responses[0]?.data ?? {}, [responseKey]: merged };
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1065
1181
|
// src/tools/custom-field-helpers.ts
|
|
1066
1182
|
import { z as z5 } from "zod";
|
|
1067
1183
|
var CustomFieldWriteSchema = z5.object({
|
|
@@ -1184,20 +1300,7 @@ var getPartiesSchema = z6.object({
|
|
|
1184
1300
|
embed: z6.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1185
1301
|
});
|
|
1186
1302
|
async function getParties(input) {
|
|
1187
|
-
|
|
1188
|
-
if (ids.length <= 10) {
|
|
1189
|
-
const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
|
|
1190
|
-
embed
|
|
1191
|
-
});
|
|
1192
|
-
return data;
|
|
1193
|
-
}
|
|
1194
|
-
const chunks = chunk(ids, 10);
|
|
1195
|
-
const responses = await Promise.all(
|
|
1196
|
-
chunks.map(
|
|
1197
|
-
(chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
|
|
1198
|
-
)
|
|
1199
|
-
);
|
|
1200
|
-
return { parties: responses.flatMap((r) => r.data.parties) };
|
|
1303
|
+
return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
|
|
1201
1304
|
}
|
|
1202
1305
|
var listPartyOpportunitiesSchema = z6.object({
|
|
1203
1306
|
partyId: positiveId,
|
|
@@ -1508,23 +1611,7 @@ var getOpportunitiesSchema = z7.object({
|
|
|
1508
1611
|
embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1509
1612
|
});
|
|
1510
1613
|
async function getOpportunities(input) {
|
|
1511
|
-
|
|
1512
|
-
if (ids.length <= 10) {
|
|
1513
|
-
const { data } = await capsuleGet(
|
|
1514
|
-
`/opportunities/${ids.join(",")}`,
|
|
1515
|
-
{ embed }
|
|
1516
|
-
);
|
|
1517
|
-
return data;
|
|
1518
|
-
}
|
|
1519
|
-
const chunks = chunk(ids, 10);
|
|
1520
|
-
const responses = await Promise.all(
|
|
1521
|
-
chunks.map(
|
|
1522
|
-
(chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
|
|
1523
|
-
embed
|
|
1524
|
-
})
|
|
1525
|
-
)
|
|
1526
|
-
);
|
|
1527
|
-
return { opportunities: responses.flatMap((r) => r.data.opportunities) };
|
|
1614
|
+
return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
|
|
1528
1615
|
}
|
|
1529
1616
|
var createOpportunitySchema = z7.object({
|
|
1530
1617
|
name: z7.string().min(1),
|
|
@@ -1652,20 +1739,7 @@ var getProjectsSchema = z8.object({
|
|
|
1652
1739
|
embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1653
1740
|
});
|
|
1654
1741
|
async function getProjects(input) {
|
|
1655
|
-
|
|
1656
|
-
if (ids.length <= 10) {
|
|
1657
|
-
const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
|
|
1658
|
-
embed
|
|
1659
|
-
});
|
|
1660
|
-
return data;
|
|
1661
|
-
}
|
|
1662
|
-
const chunks = chunk(ids, 10);
|
|
1663
|
-
const responses = await Promise.all(
|
|
1664
|
-
chunks.map(
|
|
1665
|
-
(chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
|
|
1666
|
-
)
|
|
1667
|
-
);
|
|
1668
|
-
return { kases: responses.flatMap((r) => r.data.kases) };
|
|
1742
|
+
return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
|
|
1669
1743
|
}
|
|
1670
1744
|
var createProjectSchema = z8.object({
|
|
1671
1745
|
name: z8.string().min(1),
|
|
@@ -1797,16 +1871,7 @@ var getTasksSchema = z9.object({
|
|
|
1797
1871
|
)
|
|
1798
1872
|
});
|
|
1799
1873
|
async function getTasks(input) {
|
|
1800
|
-
|
|
1801
|
-
if (ids.length <= 10) {
|
|
1802
|
-
const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
|
|
1803
|
-
return data;
|
|
1804
|
-
}
|
|
1805
|
-
const chunks = chunk(ids, 10);
|
|
1806
|
-
const responses = await Promise.all(
|
|
1807
|
-
chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
|
|
1808
|
-
);
|
|
1809
|
-
return { tasks: responses.flatMap((r) => r.data.tasks) };
|
|
1874
|
+
return chunkedMultiGet("/tasks", "tasks", input.ids);
|
|
1810
1875
|
}
|
|
1811
1876
|
var createTaskSchema = z9.object({
|
|
1812
1877
|
description: z9.string().min(1),
|
|
@@ -1907,14 +1972,102 @@ var listEntriesPagination = {
|
|
|
1907
1972
|
};
|
|
1908
1973
|
var listPartyEntriesSchema = z10.object({
|
|
1909
1974
|
partyId: positiveId,
|
|
1910
|
-
...listEntriesPagination
|
|
1975
|
+
...listEntriesPagination,
|
|
1976
|
+
includeLinkedPersons: z10.boolean().optional().describe(
|
|
1977
|
+
"When true AND `partyId` is an ORGANISATION, also include entries filed against the organisation's linked people (the persons whose `organisation` field references this org). The connector enumerates linked persons via `GET /parties/{orgId}/people`, fans out `GET /parties/{personId}/entries` in parallel (concurrency-capped, default 5 / configurable via `CAPSULE_MCP_BATCH_CONCURRENCY`), and merges into a single feed sorted by `entryAt` descending, deduped by entry id. Default is `false` \u2014 single GET, existing behaviour unchanged. WHY THIS FLAG EXISTS: Capsule's API files each entry against exactly one party row (verified v1.6.6 wire-trace probe 4 \u2014 POST /entries rejects multi-party bodies with 422 'entry must be linked to either a party, opportunity or kase'). For an organisation with multiple contacts, captured emails almost always land on a person row, not the org. As a result, `list_party_entries(orgId)` with `includeLinkedPersons: false` will miss recent customer-facing email \u2014 even though the org's own `lastContactedAt` is updated by the activity. This flag is the correct call for any 'what's new with $ORG?' question. WHEN `partyId` IS A PERSON: silently no-op \u2014 persons have no linked-people relationship in Capsule's data model, so the flag is functionally inert (the connector still issues a cheap `/people` check; the response is empty). LATENCY: 1 + N round trips for an org with N linked people, concurrency-capped (typical: 2-3 waves for N=10). Linked-person enumeration reads the first 100 linked people; use list_employees for explicit pagination when an organisation has more contacts than that. Use `includeLinkedPersons: false` for fast pre-screen reads where you only need the org-row entries (e.g. invoice/contract notes that are typically filed at the org level). PAGINATION CAVEAT: `page` and `perPage` apply to the MERGED window, and the merge has a hard ceiling \u2014 it reliably orders only the most-recent ~100 entries across the org + its people (each party is fetched at Capsule's per-party cap of 100, and a top-100-per-party merge is correct only up to global position 100). Windows that cross the ceiling are truncated to the entries still inside that top-100 set; windows starting beyond it return no entries and end the feed. It does NOT continue into older history. To read a specific contact's full timeline beyond the merged ceiling, call `list_party_entries` on that person's id directly (the default single-GET path paginates natively with no ceiling). For the LLM-driven 'what's the latest with $ORG' query this is the typical use of, the first page is exact and the ceiling is never reached."
|
|
1978
|
+
)
|
|
1911
1979
|
});
|
|
1980
|
+
async function fanOutPartyEntries(partyIds, embed, perPage) {
|
|
1981
|
+
const concurrency = getBatchConcurrency();
|
|
1982
|
+
const results = new Array(partyIds.length);
|
|
1983
|
+
let cursor = 0;
|
|
1984
|
+
async function worker() {
|
|
1985
|
+
while (true) {
|
|
1986
|
+
const i = cursor;
|
|
1987
|
+
cursor += 1;
|
|
1988
|
+
if (i >= partyIds.length) return;
|
|
1989
|
+
const id = partyIds[i];
|
|
1990
|
+
const { data, nextPage } = await capsuleGet(
|
|
1991
|
+
`/parties/${id}/entries`,
|
|
1992
|
+
{
|
|
1993
|
+
embed,
|
|
1994
|
+
page: 1,
|
|
1995
|
+
perPage
|
|
1996
|
+
}
|
|
1997
|
+
);
|
|
1998
|
+
results[i] = { entries: data.entries, nextPage };
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
const workers = [];
|
|
2002
|
+
for (let w = 0; w < Math.min(concurrency, partyIds.length); w++) {
|
|
2003
|
+
workers.push(worker());
|
|
2004
|
+
}
|
|
2005
|
+
await Promise.all(workers);
|
|
2006
|
+
return results;
|
|
2007
|
+
}
|
|
2008
|
+
function mergedTimelineCandidatePerParty(page, perPage) {
|
|
2009
|
+
return Math.min(page * perPage, 100);
|
|
2010
|
+
}
|
|
2011
|
+
function mergedTimelineNextPage(page, perPage, mergedLength, upstreamHasNextPage) {
|
|
2012
|
+
const requestedWindowEnd = page * perPage;
|
|
2013
|
+
if (mergedLength > requestedWindowEnd) return page + 1;
|
|
2014
|
+
const nextWindowWithinCap = requestedWindowEnd < 100;
|
|
2015
|
+
if (nextWindowWithinCap && upstreamHasNextPage) return page + 1;
|
|
2016
|
+
return void 0;
|
|
2017
|
+
}
|
|
1912
2018
|
async function listPartyEntries(input) {
|
|
1913
|
-
const {
|
|
1914
|
-
|
|
1915
|
-
{
|
|
2019
|
+
const { partyId, embed, page, perPage, includeLinkedPersons } = input;
|
|
2020
|
+
if (!includeLinkedPersons) {
|
|
2021
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
2022
|
+
`/parties/${partyId}/entries`,
|
|
2023
|
+
{ embed, page, perPage }
|
|
2024
|
+
);
|
|
2025
|
+
return { ...data, nextPage: nextPage2 };
|
|
2026
|
+
}
|
|
2027
|
+
const { data: peopleData } = await capsuleGet(
|
|
2028
|
+
`/parties/${partyId}/people`,
|
|
2029
|
+
{ page: 1, perPage: 100 }
|
|
1916
2030
|
);
|
|
1917
|
-
|
|
2031
|
+
const peopleIds = (peopleData.parties ?? []).map((p) => p.id);
|
|
2032
|
+
if (peopleIds.length === 0) {
|
|
2033
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
2034
|
+
`/parties/${partyId}/entries`,
|
|
2035
|
+
{ embed, page, perPage }
|
|
2036
|
+
);
|
|
2037
|
+
return { ...data, nextPage: nextPage2 };
|
|
2038
|
+
}
|
|
2039
|
+
const targetIds = [partyId, ...peopleIds];
|
|
2040
|
+
const perPartyPages = await fanOutPartyEntries(
|
|
2041
|
+
targetIds,
|
|
2042
|
+
embed,
|
|
2043
|
+
mergedTimelineCandidatePerParty(page, perPage)
|
|
2044
|
+
);
|
|
2045
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2046
|
+
const merged = [];
|
|
2047
|
+
for (const { entries } of perPartyPages) {
|
|
2048
|
+
for (const raw of entries) {
|
|
2049
|
+
const e = raw;
|
|
2050
|
+
if (typeof e?.id !== "number") continue;
|
|
2051
|
+
if (seen.has(e.id)) continue;
|
|
2052
|
+
seen.add(e.id);
|
|
2053
|
+
merged.push(e);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
merged.sort((a, b) => {
|
|
2057
|
+
const ax = a.entryAt ?? "";
|
|
2058
|
+
const bx = b.entryAt ?? "";
|
|
2059
|
+
if (ax !== bx) return bx.localeCompare(ax);
|
|
2060
|
+
return b.id - a.id;
|
|
2061
|
+
});
|
|
2062
|
+
const start = (page - 1) * perPage;
|
|
2063
|
+
const slice = merged.slice(start, start + perPage);
|
|
2064
|
+
const nextPage = mergedTimelineNextPage(
|
|
2065
|
+
page,
|
|
2066
|
+
perPage,
|
|
2067
|
+
merged.length,
|
|
2068
|
+
perPartyPages.some((p) => p.nextPage !== void 0)
|
|
2069
|
+
);
|
|
2070
|
+
return { entries: slice, ...nextPage !== void 0 ? { nextPage } : {} };
|
|
1918
2071
|
}
|
|
1919
2072
|
var listOpportunityEntriesSchema = z10.object({
|
|
1920
2073
|
opportunityId: positiveId,
|
|
@@ -2137,6 +2290,28 @@ async function removeTagById(input) {
|
|
|
2137
2290
|
invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
|
|
2138
2291
|
return result;
|
|
2139
2292
|
}
|
|
2293
|
+
var deleteTagDefinitionSchema = z13.object({
|
|
2294
|
+
entity: TagEntity,
|
|
2295
|
+
tagId: positiveId.describe(
|
|
2296
|
+
"The tag definition's id (from list_tags, or embed='tags' on a record). NOT an entity id."
|
|
2297
|
+
),
|
|
2298
|
+
confirm: confirmFlag().describe(
|
|
2299
|
+
"Must be set to true. DESTRUCTIVE & tenant-wide: permanently deletes the tag DEFINITION from this entity type's tag namespace, removing it from EVERY record that shares it \u2014 not just one. To detach a tag from a single record while keeping the definition, use remove_tag_by_id instead. Irreversible (the definition is gone; re-creating by name via add_tag mints a new id). Idempotent on retry."
|
|
2300
|
+
)
|
|
2301
|
+
});
|
|
2302
|
+
async function deleteTagDefinition(input) {
|
|
2303
|
+
const { entity, tagId, confirm } = input;
|
|
2304
|
+
if (confirm !== true) {
|
|
2305
|
+
throw new Error("delete_tag_definition requires confirm: true");
|
|
2306
|
+
}
|
|
2307
|
+
const result = await idempotent(
|
|
2308
|
+
() => capsuleDelete(`/${entity}/tags/${tagId}`),
|
|
2309
|
+
() => ({ deleted: true, alreadyDeleted: false, entity, tagId }),
|
|
2310
|
+
() => ({ deleted: true, alreadyDeleted: true, entity, tagId })
|
|
2311
|
+
);
|
|
2312
|
+
invalidateByPrefix(TAG_LIST_PATH[entity], "delete_tag_definition");
|
|
2313
|
+
return result;
|
|
2314
|
+
}
|
|
2140
2315
|
var { schema: batchAddTagSchema, handler: batchAddTag } = defineBatch({
|
|
2141
2316
|
toolName: "batch_add_tag",
|
|
2142
2317
|
itemSchema: addTagSchema,
|
|
@@ -2642,7 +2817,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
2642
2817
|
const server2 = new McpServer(
|
|
2643
2818
|
{
|
|
2644
2819
|
name: "capsulemcp",
|
|
2645
|
-
version: "1.
|
|
2820
|
+
version: "1.8.0",
|
|
2646
2821
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
2647
2822
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
2648
2823
|
icons: ICONS
|
|
@@ -3060,7 +3235,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3060
3235
|
registerTool(
|
|
3061
3236
|
server2,
|
|
3062
3237
|
"list_party_entries",
|
|
3063
|
-
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively.",
|
|
3238
|
+
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively. IMPORTANT for organisations: pass `includeLinkedPersons: true` to surface entries filed against the org's linked people (sales-conversation emails almost always land on a person row, not the org row \u2014 Capsule's API files each entry against exactly one party). Without this flag, an org with active customer-facing email will appear quiet here even though its `lastContactedAt` is current. For any 'what's new with $ORG?' query, set `includeLinkedPersons: true`.",
|
|
3064
3239
|
listPartyEntriesSchema,
|
|
3065
3240
|
listPartyEntries
|
|
3066
3241
|
);
|
|
@@ -3097,9 +3272,12 @@ function createCapsuleMcpServer(opts) {
|
|
|
3097
3272
|
"Download an attachment by id. Returns image content for image/* types (Claude can describe it natively); decoded text for text/* and application/json (small files); JSON metadata + base64 payload for other binary types (PDF, Office docs, etc.). Files exceeding maxSizeBytes (default 5MB) return metadata only with a `truncated: true` flag.",
|
|
3098
3273
|
getAttachmentSchema.shape,
|
|
3099
3274
|
// get_attachment is read-only — downloads a binary, never mutates.
|
|
3100
|
-
// Mirrors the auto-inferred `readOnlyHint: true
|
|
3101
|
-
// `registerTool` applies to every other `get_*` tool.
|
|
3102
|
-
|
|
3275
|
+
// Mirrors the auto-inferred `{readOnlyHint: true, destructiveHint:
|
|
3276
|
+
// false}` that `registerTool` applies to every other `get_*` tool.
|
|
3277
|
+
// Explicit destructiveHint: false is load-bearing — MCP spec
|
|
3278
|
+
// defaults destructiveHint to `true`, so omitting it would (in
|
|
3279
|
+
// some client implementations) classify this read as destructive.
|
|
3280
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
3103
3281
|
async (input) => {
|
|
3104
3282
|
const result = await getAttachment(input);
|
|
3105
3283
|
if (result.truncated) {
|
|
@@ -3330,6 +3508,13 @@ function createCapsuleMcpServer(opts) {
|
|
|
3330
3508
|
removeTagByIdSchema,
|
|
3331
3509
|
removeTagById
|
|
3332
3510
|
);
|
|
3511
|
+
registerTool(
|
|
3512
|
+
server2,
|
|
3513
|
+
"delete_tag_definition",
|
|
3514
|
+
"DESTRUCTIVE & TENANT-WIDE: permanently delete a tag DEFINITION from an entity type's tag namespace (parties / opportunities / kases). Unlike remove_tag_by_id \u2014 which detaches a tag from ONE record and leaves the definition intact for others \u2014 this removes the definition itself, so the tag disappears from EVERY record that shared it. Use it to clean up stray / mistyped / test tag definitions polluting the tenant-global list. Requires confirm=true. Always read the affected tag first via list_tags and confirm with the user; if you only want to untag one record, use remove_tag_by_id instead. Irreversible (re-creating by name via add_tag mints a brand-new id). Idempotent on retry: `{deleted: true, alreadyDeleted: false, entity, tagId}` on a fresh delete, or `{deleted: true, alreadyDeleted: true, entity, tagId}` if the definition was already gone (Capsule's 404 is caught). Endpoint verified empirically (DELETE /<entity>/tags/{id} \u2192 204).",
|
|
3515
|
+
deleteTagDefinitionSchema,
|
|
3516
|
+
deleteTagDefinition
|
|
3517
|
+
);
|
|
3333
3518
|
registerBatchTool(
|
|
3334
3519
|
server2,
|
|
3335
3520
|
"batch_add_tag",
|
|
@@ -3369,7 +3554,8 @@ if (!process.env["CAPSULE_API_TOKEN"]) {
|
|
|
3369
3554
|
);
|
|
3370
3555
|
process.exit(1);
|
|
3371
3556
|
}
|
|
3372
|
-
var
|
|
3557
|
+
var STDIO_CLIENT_ID = "stdio-local";
|
|
3558
|
+
var server = createCapsuleMcpServer({ clientId: STDIO_CLIENT_ID });
|
|
3373
3559
|
var transport = new StdioServerTransport();
|
|
3374
3560
|
if (isReadOnly()) {
|
|
3375
3561
|
console.error("[capsulemcp] read-only mode: write/delete tools are not registered");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capsulemcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Model Context Protocol server for Capsule CRM. Lets Claude (Desktop, Code, or web Projects via Custom Connector) read and write your CRM in plain English. Covers contacts, opportunities, projects, tasks, timeline activity, structured filters, saved filters with sort, workflow tracks, file attachments, audit, and batch fetches.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -62,6 +62,6 @@
|
|
|
62
62
|
"vitest": "^4.1.7"
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
|
-
"node": ">=22"
|
|
65
|
+
"node": ">=22.19.0"
|
|
66
66
|
}
|
|
67
67
|
}
|