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 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
- - **87 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
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`, 7 destructive ones carry `destructiveHint: true` — clients that honor these hints can auto-approve safe reads while still prompting for writes/destructive calls
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.6.5"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.6.5"` — 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.
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 CapsuleApiError(
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
- if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
283
- throw new CapsuleApiError(
284
- 504,
285
- `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.`
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
- return await body();
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 emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
325
- let path = "";
374
+ function redactedPath(url) {
326
375
  try {
327
- path = redactPath(new URL(url).pathname);
376
+ return redactPath(new URL(url).pathname);
328
377
  } catch {
329
- path = "?";
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
- const timer = setTimeout(() => {
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 void 0;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids } = input;
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 { data, nextPage } = await capsuleGet(
2417
- `/parties/${input.partyId}/entries`,
2418
- { embed: input.embed, page: input.page, perPage: input.perPage }
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
- return { ...data, nextPage };
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.6.5",
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` that
3604
- // `registerTool` applies to every other `get_*` tool.
3605
- { readOnlyHint: true },
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 &mdash; 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> &middot; 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 CapsuleApiError(
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
- if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
265
- throw new CapsuleApiError(
266
- 504,
267
- `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.`
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
- return await body();
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 emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
307
- let path = "";
356
+ function redactedPath(url) {
308
357
  try {
309
- path = redactPath(new URL(url).pathname);
358
+ return redactPath(new URL(url).pathname);
310
359
  } catch {
311
- path = "?";
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
- const timer = setTimeout(() => {
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 void 0;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids } = input;
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 { data, nextPage } = await capsuleGet(
1914
- `/parties/${input.partyId}/entries`,
1915
- { embed: input.embed, page: input.page, perPage: input.perPage }
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
- return { ...data, nextPage };
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.6.5",
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` that
3101
- // `registerTool` applies to every other `get_*` tool.
3102
- { readOnlyHint: true },
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 server = createCapsuleMcpServer();
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.6.5",
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
  }