capsulemcp 1.0.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +6 -3
  2. package/dist/http.js +920 -152
  3. package/dist/index.js +869 -144
  4. package/package.json +10 -5
package/dist/index.js CHANGED
@@ -5,6 +5,124 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
 
6
6
  // src/capsule/client.ts
7
7
  import { fetch } from "undici";
8
+
9
+ // src/env.ts
10
+ function readBool(name) {
11
+ const raw = process.env[name]?.toLowerCase();
12
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
13
+ }
14
+ function readPositiveInt(name, fallback, min = 1) {
15
+ const raw = process.env[name];
16
+ if (raw === void 0 || raw === "") return fallback;
17
+ const n = Number(raw);
18
+ if (!Number.isFinite(n) || n < min) return fallback;
19
+ return Math.floor(n);
20
+ }
21
+
22
+ // src/log.ts
23
+ import { AsyncLocalStorage } from "async_hooks";
24
+ function logVerbose() {
25
+ return readBool("CAPSULE_MCP_LOG_VERBOSE");
26
+ }
27
+ var chainHandlers = {
28
+ "tool.call": (ctx, f) => {
29
+ if (typeof f["tool"] === "string") ctx.tools.push(f["tool"]);
30
+ },
31
+ "capsule.request": (ctx) => {
32
+ ctx.capsuleCalls += 1;
33
+ },
34
+ // Cache-hit events feed the aggregate so the chain stat is right
35
+ // even on tools whose Capsule calls all hit the cache.
36
+ "cache.hit": (ctx) => {
37
+ ctx.cacheHits += 1;
38
+ }
39
+ };
40
+ function logEvent(event, fields, opts = {}) {
41
+ const ctx = requestContext.getStore();
42
+ if (ctx) chainHandlers[event]?.(ctx, fields);
43
+ if (!opts.force && !logVerbose()) return;
44
+ process.stderr.write(
45
+ `${JSON.stringify({ event, ...fields, timestamp: (/* @__PURE__ */ new Date()).toISOString() })}
46
+ `
47
+ );
48
+ }
49
+ function redactPath(path) {
50
+ const noQuery = path.split("?")[0] ?? path;
51
+ return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id");
52
+ }
53
+ var requestContext = new AsyncLocalStorage();
54
+ function getRequestContext() {
55
+ return requestContext.getStore();
56
+ }
57
+
58
+ // src/capsule/cache.ts
59
+ var cache = /* @__PURE__ */ new Map();
60
+ var MAX_ENTRIES = 64;
61
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
62
+ function getCacheTtlMs() {
63
+ return readPositiveInt("CAPSULE_MCP_CACHE_TTL_MS", DEFAULT_TTL_MS, 0);
64
+ }
65
+ function explicitlyDisabled() {
66
+ return readBool("CAPSULE_MCP_CACHE_DISABLED");
67
+ }
68
+ function cacheDisabled() {
69
+ return explicitlyDisabled() || getCacheTtlMs() === 0;
70
+ }
71
+ function cacheKey(path, params) {
72
+ if (!params) return `GET ${path}`;
73
+ const entries = Object.entries(params).filter(([, v]) => v !== void 0);
74
+ if (entries.length === 0) return `GET ${path}`;
75
+ entries.sort(([a], [b]) => a.localeCompare(b));
76
+ const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
77
+ return `GET ${path}?${qs}`;
78
+ }
79
+ function cacheLookup(key) {
80
+ const entry = cache.get(key);
81
+ if (!entry) return { hit: false, reason: "empty" };
82
+ const now = Date.now();
83
+ if (entry.expiresAt < now) {
84
+ cache.delete(key);
85
+ return { hit: false, reason: "expired" };
86
+ }
87
+ return { hit: true, result: entry.result, ageMs: now - entry.storedAt };
88
+ }
89
+ function cacheSet(key, result) {
90
+ if (cacheDisabled()) return;
91
+ const ttl = getCacheTtlMs();
92
+ while (cache.size >= MAX_ENTRIES) {
93
+ const oldest = cache.keys().next().value;
94
+ if (oldest === void 0) break;
95
+ cache.delete(oldest);
96
+ const evictedKey = `GET ${redactPath(oldest.replace(/^GET /, ""))}`;
97
+ logEvent("cache.evict", { evictedKey, cacheSize: cache.size, reason: "cap" });
98
+ }
99
+ const now = Date.now();
100
+ cache.set(key, {
101
+ result,
102
+ storedAt: now,
103
+ expiresAt: now + ttl
104
+ });
105
+ }
106
+ function invalidateByPrefix(pathPrefix, trigger) {
107
+ const needle = `GET ${pathPrefix}`;
108
+ let droppedCount = 0;
109
+ for (const k of cache.keys()) {
110
+ if (k === needle || k.startsWith(`${needle}?`) || k.startsWith(`${needle}/`)) {
111
+ cache.delete(k);
112
+ droppedCount++;
113
+ }
114
+ }
115
+ if (droppedCount > 0) {
116
+ logEvent("cache.invalidate", {
117
+ prefix: pathPrefix,
118
+ droppedCount,
119
+ cacheSize: cache.size,
120
+ ...trigger ? { trigger } : {}
121
+ });
122
+ }
123
+ }
124
+
125
+ // src/capsule/client.ts
8
126
  var DEFAULT_BASE_URL = "https://api.capsulecrm.com/api/v2";
9
127
  function baseUrl() {
10
128
  const override = process.env["CAPSULE_API_BASE_URL"];
@@ -24,8 +142,7 @@ function baseUrl() {
24
142
  return override;
25
143
  }
26
144
  function isReadOnly() {
27
- const v = process.env["CAPSULE_MCP_READONLY"]?.toLowerCase();
28
- return v === "1" || v === "true" || v === "yes";
145
+ return readBool("CAPSULE_MCP_READONLY");
29
146
  }
30
147
  var CapsuleReadOnlyError = class extends Error {
31
148
  constructor(method) {
@@ -154,6 +271,8 @@ async function fetchWithTimeout(url, options) {
154
271
  }
155
272
  }
156
273
  async function doFetch(url, options) {
274
+ const startedAt = Date.now();
275
+ const method = options?.method ?? "GET";
157
276
  const first = await fetchWithTimeout(url, options);
158
277
  if (first.res.status === 429) {
159
278
  const delay = parseRateLimitDelay(first.res);
@@ -167,9 +286,40 @@ async function doFetch(url, options) {
167
286
  "Rate limit exceeded after one retry. Please slow down your requests."
168
287
  );
169
288
  }
170
- return retried;
289
+ return { ...retried, startedAt, method, url, retriedAfter429: true };
171
290
  }
172
- return first;
291
+ return { ...first, startedAt, method, url, retriedAfter429: false };
292
+ }
293
+ async function consumeBody(start, body) {
294
+ try {
295
+ return await body();
296
+ } finally {
297
+ emitCapsuleRequest(
298
+ start.method,
299
+ start.url,
300
+ start.res,
301
+ Date.now() - start.startedAt,
302
+ start.retriedAfter429
303
+ );
304
+ }
305
+ }
306
+ function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
307
+ let path = "";
308
+ try {
309
+ path = redactPath(new URL(url).pathname);
310
+ } catch {
311
+ path = "?";
312
+ }
313
+ const lenHeader = res.headers.get("content-length");
314
+ const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
315
+ logEvent("capsule.request", {
316
+ method,
317
+ path,
318
+ status: res.status,
319
+ durationMs,
320
+ responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0,
321
+ ...retriedAfter429 ? { retriedAfter429: true } : {}
322
+ });
173
323
  }
174
324
  async function throwForStatus(res) {
175
325
  if (res.status === 401) {
@@ -201,136 +351,173 @@ function buildUrl(path, params) {
201
351
  async function capsuleGet(path, params) {
202
352
  const token = getToken();
203
353
  const url = buildUrl(path, params);
204
- const { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
354
+ const start = await doFetch(url, { headers: baseHeaders(token) });
205
355
  try {
206
- const data = await handleResponse(res);
207
- const nextPage = parseNextPage(res.headers.get("Link"));
208
- return { data, nextPage };
356
+ return await consumeBody(start, async () => {
357
+ const data = await handleResponse(start.res);
358
+ const nextPage = parseNextPage(start.res.headers.get("Link"));
359
+ return { data, nextPage };
360
+ });
209
361
  } finally {
210
- cleanup();
362
+ start.cleanup();
211
363
  }
212
364
  }
365
+ async function capsuleGetCached(path, params) {
366
+ if (cacheDisabled()) return capsuleGet(path, params);
367
+ const key = cacheKey(path, params);
368
+ const lookup = cacheLookup(key);
369
+ if (lookup.hit) {
370
+ if (logVerbose()) {
371
+ logEvent("cache.hit", {
372
+ path: redactPath(path),
373
+ ...params ? { paramFields: Object.keys(params) } : {},
374
+ ageMs: lookup.ageMs
375
+ });
376
+ }
377
+ return lookup.result;
378
+ }
379
+ const fetchStart = Date.now();
380
+ const result = await capsuleGet(path, params);
381
+ const latencyMs = Date.now() - fetchStart;
382
+ cacheSet(key, result);
383
+ if (logVerbose()) {
384
+ logEvent("cache.miss", {
385
+ path: redactPath(path),
386
+ ...params ? { paramFields: Object.keys(params) } : {},
387
+ reason: lookup.reason,
388
+ latencyMs
389
+ });
390
+ }
391
+ return result;
392
+ }
213
393
  async function capsulePost(path, body) {
214
394
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
215
395
  const token = getToken();
216
396
  const url = buildUrl(path);
217
- const { res, cleanup } = await doFetch(url, {
397
+ const start = await doFetch(url, {
218
398
  method: "POST",
219
399
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
220
400
  body: JSON.stringify(body)
221
401
  });
222
402
  try {
223
- return await handleResponse(res);
403
+ return await consumeBody(start, () => handleResponse(start.res));
224
404
  } finally {
225
- cleanup();
405
+ start.cleanup();
226
406
  }
227
407
  }
228
408
  async function capsulePostNoContent(path) {
229
409
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
230
410
  const token = getToken();
231
411
  const url = buildUrl(path);
232
- const { res, cleanup } = await doFetch(url, {
412
+ const start = await doFetch(url, {
233
413
  method: "POST",
234
414
  headers: baseHeaders(token)
235
415
  });
236
416
  try {
237
- if (res.status === 204) return;
238
- await throwForStatus(res);
239
- await mapAbort(res.text());
417
+ await consumeBody(start, async () => {
418
+ if (start.res.status === 204) return;
419
+ await throwForStatus(start.res);
420
+ await mapAbort(start.res.text());
421
+ });
240
422
  } finally {
241
- cleanup();
423
+ start.cleanup();
242
424
  }
243
425
  }
244
426
  async function capsuleSearch(path, body, params) {
245
427
  const token = getToken();
246
428
  const url = buildUrl(path, params);
247
- const { res, cleanup } = await doFetch(url, {
429
+ const start = await doFetch(url, {
248
430
  method: "POST",
249
431
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
250
432
  body: JSON.stringify(body)
251
433
  });
252
434
  try {
253
- const data = await handleResponse(res);
254
- const nextPage = parseNextPage(res.headers.get("Link"));
255
- return { data, nextPage };
435
+ return await consumeBody(start, async () => {
436
+ const data = await handleResponse(start.res);
437
+ const nextPage = parseNextPage(start.res.headers.get("Link"));
438
+ return { data, nextPage };
439
+ });
256
440
  } finally {
257
- cleanup();
441
+ start.cleanup();
258
442
  }
259
443
  }
260
444
  async function capsulePut(path, body) {
261
445
  if (isReadOnly()) throw new CapsuleReadOnlyError("PUT");
262
446
  const token = getToken();
263
447
  const url = buildUrl(path);
264
- const { res, cleanup } = await doFetch(url, {
448
+ const start = await doFetch(url, {
265
449
  method: "PUT",
266
450
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
267
451
  body: JSON.stringify(body)
268
452
  });
269
453
  try {
270
- return await handleResponse(res);
454
+ return await consumeBody(start, () => handleResponse(start.res));
271
455
  } finally {
272
- cleanup();
456
+ start.cleanup();
273
457
  }
274
458
  }
275
459
  async function capsuleGetBinary(path, maxBytes) {
276
460
  const token = getToken();
277
461
  const url = buildUrl(path);
278
- const { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
462
+ const start = await doFetch(url, { headers: baseHeaders(token) });
279
463
  try {
280
- await throwForStatus(res);
281
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
282
- const declared = res.headers.get("Content-Length");
283
- const declaredBytes = declared ? Number(declared) : NaN;
284
- if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
285
- if (res.body) await res.body.cancel().catch(() => {
286
- });
287
- return {
288
- contentType,
289
- buffer: Buffer.alloc(0),
290
- truncated: true,
291
- sizeBytes: declaredBytes
292
- };
293
- }
294
- if (maxBytes !== void 0 && res.body) {
295
- const reader = res.body.getReader();
296
- const chunks = [];
297
- let total = 0;
298
- let truncated = false;
299
- while (true) {
300
- const { done, value } = await mapAbort(reader.read());
301
- if (done) break;
302
- total += value.byteLength;
303
- if (total > maxBytes) {
304
- truncated = true;
305
- await reader.cancel().catch(() => {
306
- });
307
- break;
308
- }
309
- chunks.push(value);
310
- }
311
- if (truncated) {
464
+ return await consumeBody(start, async () => {
465
+ const res = start.res;
466
+ await throwForStatus(res);
467
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
468
+ const declared = res.headers.get("Content-Length");
469
+ const declaredBytes = declared ? Number(declared) : NaN;
470
+ if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
471
+ if (res.body) await res.body.cancel().catch(() => {
472
+ });
312
473
  return {
313
474
  contentType,
314
475
  buffer: Buffer.alloc(0),
315
476
  truncated: true,
316
- sizeBytes: total
477
+ sizeBytes: declaredBytes
317
478
  };
318
479
  }
319
- const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
320
- return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
321
- }
322
- const arrayBuffer = await mapAbort(res.arrayBuffer());
323
- const buffer = Buffer.from(arrayBuffer);
324
- return { contentType, buffer, sizeBytes: buffer.length };
480
+ if (maxBytes !== void 0 && res.body) {
481
+ const reader = res.body.getReader();
482
+ const chunks = [];
483
+ let total = 0;
484
+ let truncated = false;
485
+ while (true) {
486
+ const { done, value } = await mapAbort(reader.read());
487
+ if (done) break;
488
+ total += value.byteLength;
489
+ if (total > maxBytes) {
490
+ truncated = true;
491
+ await reader.cancel().catch(() => {
492
+ });
493
+ break;
494
+ }
495
+ chunks.push(value);
496
+ }
497
+ if (truncated) {
498
+ return {
499
+ contentType,
500
+ buffer: Buffer.alloc(0),
501
+ truncated: true,
502
+ sizeBytes: total
503
+ };
504
+ }
505
+ const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
506
+ return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
507
+ }
508
+ const arrayBuffer = await mapAbort(res.arrayBuffer());
509
+ const buffer = Buffer.from(arrayBuffer);
510
+ return { contentType, buffer, sizeBytes: buffer.length };
511
+ });
325
512
  } finally {
326
- cleanup();
513
+ start.cleanup();
327
514
  }
328
515
  }
329
516
  async function capsulePostBinary(path, body, contentType, filename) {
330
517
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
331
518
  const token = getToken();
332
519
  const url = buildUrl(path);
333
- const { res, cleanup } = await doFetch(url, {
520
+ const start = await doFetch(url, {
334
521
  method: "POST",
335
522
  headers: {
336
523
  ...baseHeaders(token),
@@ -341,25 +528,27 @@ async function capsulePostBinary(path, body, contentType, filename) {
341
528
  body
342
529
  });
343
530
  try {
344
- return await handleResponse(res);
531
+ return await consumeBody(start, () => handleResponse(start.res));
345
532
  } finally {
346
- cleanup();
533
+ start.cleanup();
347
534
  }
348
535
  }
349
536
  async function capsuleDelete(path) {
350
537
  if (isReadOnly()) throw new CapsuleReadOnlyError("DELETE");
351
538
  const token = getToken();
352
539
  const url = buildUrl(path);
353
- const { res, cleanup } = await doFetch(url, {
540
+ const start = await doFetch(url, {
354
541
  method: "DELETE",
355
542
  headers: baseHeaders(token)
356
543
  });
357
544
  try {
358
- if (res.status === 204) return;
359
- await throwForStatus(res);
360
- await mapAbort(res.text());
545
+ await consumeBody(start, async () => {
546
+ if (start.res.status === 204) return;
547
+ await throwForStatus(start.res);
548
+ await mapAbort(start.res.text());
549
+ });
361
550
  } finally {
362
- cleanup();
551
+ start.cleanup();
363
552
  }
364
553
  }
365
554
 
@@ -396,7 +585,195 @@ var ICONS = [
396
585
  }
397
586
  ];
398
587
 
588
+ // src/tasks/store.ts
589
+ import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
590
+ import {
591
+ ErrorCode,
592
+ McpError
593
+ } from "@modelcontextprotocol/sdk/types.js";
594
+
595
+ // src/tasks/config.ts
596
+ var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
597
+ var DEFAULT_MAX_KEEP_ALIVE_MS = 15 * 60 * 1e3;
598
+ var MIN_TASK_TTL_MS = 1e3;
599
+ var DEFAULT_POLL_FREQUENCY_MS = 1500;
600
+ var MIN_POLL_FREQUENCY_MS = 500;
601
+ var DEFAULT_MAX_PER_CLIENT = 20;
602
+ var DEFAULT_MAX_TOTAL = 200;
603
+ function getTasksConfig() {
604
+ const enabled = readBool("MCP_TASKS_ENABLED");
605
+ const maxKeepAliveMs = Math.max(
606
+ readPositiveInt("MCP_TASKS_MAX_KEEP_ALIVE_MS", DEFAULT_MAX_KEEP_ALIVE_MS),
607
+ MIN_TASK_TTL_MS
608
+ );
609
+ const defaultTtlMs = Math.min(
610
+ Math.max(readPositiveInt("MCP_TASKS_DEFAULT_TTL_MS", DEFAULT_TTL_MS2), MIN_TASK_TTL_MS),
611
+ maxKeepAliveMs
612
+ );
613
+ const defaultPollFrequencyMs = Math.max(
614
+ readPositiveInt("MCP_TASKS_DEFAULT_POLL_FREQUENCY_MS", DEFAULT_POLL_FREQUENCY_MS),
615
+ MIN_POLL_FREQUENCY_MS
616
+ );
617
+ const maxPerClient = readPositiveInt("MCP_TASKS_MAX_PER_CLIENT", DEFAULT_MAX_PER_CLIENT);
618
+ const maxTotal = readPositiveInt("MCP_TASKS_MAX_TOTAL", DEFAULT_MAX_TOTAL);
619
+ return {
620
+ enabled,
621
+ defaultTtlMs,
622
+ maxKeepAliveMs,
623
+ defaultPollFrequencyMs,
624
+ maxPerClient,
625
+ maxTotal
626
+ };
627
+ }
628
+
629
+ // src/tasks/store.ts
630
+ var _globalStore = null;
631
+ function getGlobalStore() {
632
+ if (_globalStore === null) {
633
+ _globalStore = new InMemoryTaskStore();
634
+ }
635
+ return _globalStore;
636
+ }
637
+ var owners = /* @__PURE__ */ new Map();
638
+ var abortControllers = /* @__PURE__ */ new Map();
639
+ function registerAbortController(taskId, controller) {
640
+ abortControllers.set(taskId, controller);
641
+ }
642
+ function countPerClient(clientId) {
643
+ let n = 0;
644
+ for (const owner of owners.values()) {
645
+ if (owner === clientId) n++;
646
+ }
647
+ return n;
648
+ }
649
+ function createScopedTaskStore(clientId) {
650
+ if (!clientId) {
651
+ throw new Error("createScopedTaskStore: clientId is required");
652
+ }
653
+ const global = getGlobalStore();
654
+ async function getOwned(taskId) {
655
+ if (owners.get(taskId) !== clientId) return null;
656
+ return global.getTask(taskId);
657
+ }
658
+ return {
659
+ async createTask(taskParams, requestId, request, sessionId) {
660
+ const cfg = getTasksConfig();
661
+ const totalNow = owners.size;
662
+ if (totalNow >= cfg.maxTotal) {
663
+ logEvent("task.rejected", {
664
+ reason: "max_total",
665
+ clientId,
666
+ totalNow,
667
+ cap: cfg.maxTotal
668
+ });
669
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this server instance");
670
+ }
671
+ const perClientNow = countPerClient(clientId);
672
+ if (perClientNow >= cfg.maxPerClient) {
673
+ logEvent("task.rejected", {
674
+ reason: "max_per_client",
675
+ clientId,
676
+ perClientNow,
677
+ cap: cfg.maxPerClient
678
+ });
679
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this client");
680
+ }
681
+ const requestedTtl = taskParams.ttl;
682
+ const clampedTtl = requestedTtl === null ? cfg.maxKeepAliveMs : Math.max(
683
+ MIN_TASK_TTL_MS,
684
+ Math.min(requestedTtl ?? cfg.defaultTtlMs, cfg.maxKeepAliveMs)
685
+ );
686
+ const requestedPoll = taskParams.pollInterval ?? cfg.defaultPollFrequencyMs;
687
+ const clampedPoll = Math.max(cfg.defaultPollFrequencyMs, Math.floor(requestedPoll));
688
+ const task = await global.createTask(
689
+ { ttl: clampedTtl, pollInterval: clampedPoll, context: taskParams.context },
690
+ requestId,
691
+ request,
692
+ sessionId
693
+ );
694
+ 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?.();
701
+ logEvent("task.created", {
702
+ taskId: task.taskId,
703
+ clientId,
704
+ ttl: clampedTtl,
705
+ pollInterval: clampedPoll,
706
+ method: typeof request.method === "string" ? request.method : "unknown"
707
+ });
708
+ return task;
709
+ },
710
+ async getTask(taskId) {
711
+ return getOwned(taskId);
712
+ },
713
+ async storeTaskResult(taskId, status, result, sessionId) {
714
+ if (owners.get(taskId) !== clientId) {
715
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
716
+ }
717
+ logEvent("task.transition", { taskId, clientId, status });
718
+ await global.storeTaskResult(taskId, status, result, sessionId);
719
+ },
720
+ async getTaskResult(taskId, sessionId) {
721
+ if (owners.get(taskId) !== clientId) {
722
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
723
+ }
724
+ return global.getTaskResult(taskId, sessionId);
725
+ },
726
+ async updateTaskStatus(taskId, status, statusMessage, sessionId) {
727
+ if (owners.get(taskId) !== clientId) {
728
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
729
+ }
730
+ logEvent("task.transition", { taskId, clientId, status, statusMessage });
731
+ await global.updateTaskStatus(taskId, status, statusMessage, sessionId);
732
+ if (status === "cancelled") {
733
+ const ctrl = abortControllers.get(taskId);
734
+ if (ctrl && !ctrl.signal.aborted) ctrl.abort();
735
+ }
736
+ if (status === "completed" || status === "failed" || status === "cancelled") {
737
+ abortControllers.delete(taskId);
738
+ }
739
+ },
740
+ async listTasks(cursor, sessionId) {
741
+ const page = await global.listTasks(cursor, sessionId);
742
+ const filtered = page.tasks.filter((t) => owners.get(t.taskId) === clientId);
743
+ return page.nextCursor ? { tasks: filtered, nextCursor: page.nextCursor } : { tasks: filtered };
744
+ }
745
+ };
746
+ }
747
+
399
748
  // src/server/register-tool.ts
749
+ var READ_PREFIXES = ["search_", "filter_", "get_", "list_", "show_", "run_"];
750
+ var DESTRUCTIVE_NON_DELETE = /* @__PURE__ */ new Set(["remove_track", "remove_additional_party"]);
751
+ function isDestructive(name) {
752
+ return name.startsWith("delete_") || DESTRUCTIVE_NON_DELETE.has(name);
753
+ }
754
+ function inferAnnotations(name) {
755
+ if (READ_PREFIXES.some((p) => name.startsWith(p))) {
756
+ return { readOnlyHint: true };
757
+ }
758
+ if (isDestructive(name)) {
759
+ return { destructiveHint: true };
760
+ }
761
+ return void 0;
762
+ }
763
+ function argFieldNames(input) {
764
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
765
+ return Object.keys(input);
766
+ }
767
+ function emitToolCall(opts) {
768
+ logEvent("tool.call", {
769
+ tool: opts.tool,
770
+ ...opts.clientId ? { clientId: opts.clientId } : {},
771
+ argFields: opts.argFields,
772
+ durationMs: Date.now() - opts.startedAt,
773
+ outcome: opts.outcome,
774
+ ...opts.taskAugmented ? { taskAugmented: true } : {}
775
+ });
776
+ }
400
777
  function wrapAsText(result) {
401
778
  return {
402
779
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
@@ -404,10 +781,93 @@ function wrapAsText(result) {
404
781
  }
405
782
  function registerTool(server2, name, description, schema, handler) {
406
783
  const registerWithSchema = server2.registerTool.bind(server2);
407
- registerWithSchema(name, { description, inputSchema: schema }, async (input) => {
408
- const result = await handler(input);
409
- return wrapAsText(result);
410
- });
784
+ const annotations = inferAnnotations(name);
785
+ registerWithSchema(
786
+ name,
787
+ { description, inputSchema: schema, ...annotations ? { annotations } : {} },
788
+ async (input) => {
789
+ const startedAt = Date.now();
790
+ const argFields = argFieldNames(input);
791
+ const clientId = getRequestContext()?.clientId;
792
+ try {
793
+ const result = await handler(input);
794
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
795
+ return wrapAsText(result);
796
+ } catch (err) {
797
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
798
+ throw err;
799
+ }
800
+ }
801
+ );
802
+ }
803
+ function registerToolTask(server2, name, description, schema, handler) {
804
+ const registerWithSchema = server2.experimental.tasks.registerToolTask.bind(
805
+ server2.experimental.tasks
806
+ );
807
+ const annotations = inferAnnotations(name);
808
+ registerWithSchema(
809
+ name,
810
+ {
811
+ description,
812
+ inputSchema: schema,
813
+ execution: { taskSupport: "optional" },
814
+ ...annotations ? { annotations } : {}
815
+ },
816
+ {
817
+ createTask: async (input, extra) => {
818
+ const task = await extra.taskStore.createTask({
819
+ ttl: extra.taskRequestedTtl
820
+ });
821
+ const abortController = new AbortController();
822
+ registerAbortController(task.taskId, abortController);
823
+ const requestClientId = getRequestContext()?.clientId;
824
+ const argFields = argFieldNames(input);
825
+ void (async () => {
826
+ if (abortController.signal.aborted) return;
827
+ try {
828
+ await extra.taskStore.updateTaskStatus(task.taskId, "working");
829
+ } catch {
830
+ }
831
+ const handlerStart = Date.now();
832
+ let payload;
833
+ let outcome = "success";
834
+ try {
835
+ const result = await handler(input, {
836
+ signal: abortController.signal
837
+ });
838
+ payload = wrapAsText(result);
839
+ } catch (err) {
840
+ if (abortController.signal.aborted) return;
841
+ outcome = "error";
842
+ const message = err instanceof Error ? err.message : String(err);
843
+ payload = {
844
+ content: [{ type: "text", text: message }],
845
+ isError: true
846
+ };
847
+ }
848
+ emitToolCall({
849
+ tool: name,
850
+ clientId: requestClientId,
851
+ argFields,
852
+ startedAt: handlerStart,
853
+ outcome,
854
+ taskAugmented: true
855
+ });
856
+ if (abortController.signal.aborted) return;
857
+ try {
858
+ await extra.taskStore.storeTaskResult(task.taskId, "completed", payload);
859
+ } catch {
860
+ }
861
+ })();
862
+ return { task };
863
+ },
864
+ getTask: async (_input, extra) => extra.taskStore.getTask(extra.taskId),
865
+ getTaskResult: async (_input, extra) => {
866
+ const r = await extra.taskStore.getTaskResult(extra.taskId);
867
+ return r;
868
+ }
869
+ }
870
+ );
411
871
  }
412
872
 
413
873
  // src/tools/parties.ts
@@ -424,6 +884,98 @@ function confirmFlag() {
424
884
  return z.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
425
885
  }
426
886
 
887
+ // src/capsule/batch.ts
888
+ function chunk(arr, size) {
889
+ if (size <= 0) throw new Error("chunk size must be positive");
890
+ const out = [];
891
+ for (let i = 0; i < arr.length; i += size) {
892
+ out.push(arr.slice(i, i + size));
893
+ }
894
+ return out;
895
+ }
896
+ var DEFAULT_CONCURRENCY = 5;
897
+ var MAX_CONCURRENCY = 50;
898
+ function getBatchConcurrency() {
899
+ return Math.min(
900
+ readPositiveInt("CAPSULE_MCP_BATCH_CONCURRENCY", DEFAULT_CONCURRENCY),
901
+ MAX_CONCURRENCY
902
+ );
903
+ }
904
+ async function batchExecute(tool, items, action, options = {}) {
905
+ const concurrency = getBatchConcurrency();
906
+ const results = new Array(items.length);
907
+ const startedAt = Date.now();
908
+ const signal = options.signal;
909
+ let cursor = 0;
910
+ async function worker() {
911
+ while (true) {
912
+ const i = cursor;
913
+ cursor += 1;
914
+ if (i >= items.length) return;
915
+ if (signal?.aborted) {
916
+ results[i] = {
917
+ ok: false,
918
+ error: { message: "cancelled by tasks/cancel" }
919
+ };
920
+ continue;
921
+ }
922
+ try {
923
+ const result = await action(items[i], i);
924
+ results[i] = { ok: true, result };
925
+ } catch (err) {
926
+ results[i] = { ok: false, error: extractError(err) };
927
+ }
928
+ }
929
+ }
930
+ const workers = [];
931
+ for (let w = 0; w < Math.min(concurrency, items.length); w++) {
932
+ workers.push(worker());
933
+ }
934
+ await Promise.all(workers);
935
+ const succeeded = results.filter((r) => r.ok).length;
936
+ const failed = results.length - succeeded;
937
+ const summary = { total: results.length, succeeded, failed };
938
+ const failureReasons = logVerbose() ? topFailureReasons(results, 5) : [];
939
+ logEvent(
940
+ "batch.complete",
941
+ {
942
+ tool,
943
+ total: summary.total,
944
+ succeeded: summary.succeeded,
945
+ failed: summary.failed,
946
+ durationMs: Date.now() - startedAt,
947
+ concurrency,
948
+ ...failureReasons.length > 0 ? { failureReasons } : {}
949
+ },
950
+ { force: true }
951
+ );
952
+ return { results, summary };
953
+ }
954
+ function extractError(err) {
955
+ if (err instanceof Error) {
956
+ const maybeStatus = err.status;
957
+ return {
958
+ ...typeof maybeStatus === "number" ? { status: maybeStatus } : {},
959
+ message: err.message
960
+ };
961
+ }
962
+ return { message: String(err) };
963
+ }
964
+ function topFailureReasons(results, n) {
965
+ const counts = /* @__PURE__ */ new Map();
966
+ for (const r of results) {
967
+ if (r.ok) continue;
968
+ const key = `${r.error.status ?? "?"}::${r.error.message}`;
969
+ const existing = counts.get(key);
970
+ if (existing) {
971
+ existing.count += 1;
972
+ } else {
973
+ counts.set(key, { ...r.error, count: 1 });
974
+ }
975
+ }
976
+ return Array.from(counts.values()).sort((a, b) => b.count - a.count).slice(0, n);
977
+ }
978
+
427
979
  // src/capsule/idempotent.ts
428
980
  var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
429
981
  var isCapsuleTagNotFound = (err) => err instanceof CapsuleApiError && err.status === 422 && /tag not found/i.test(err.message);
@@ -562,14 +1114,26 @@ async function getParty(input) {
562
1114
  return data;
563
1115
  }
564
1116
  var getPartiesSchema = z3.object({
565
- ids: z3.array(z3.number().int().positive()).min(1).max(10).describe("Array of party IDs (1\u201310). Capsule caps batch fetches at 10."),
1117
+ ids: z3.array(z3.number().int().positive()).min(1).max(50).describe(
1118
+ "Array of party IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel. Result shape is identical regardless of input size."
1119
+ ),
566
1120
  embed: z3.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
567
1121
  });
568
1122
  async function getParties(input) {
569
- const { data } = await capsuleGet(`/parties/${input.ids.join(",")}`, {
570
- embed: input.embed
571
- });
572
- return data;
1123
+ const { ids, embed } = input;
1124
+ if (ids.length <= 10) {
1125
+ const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
1126
+ embed
1127
+ });
1128
+ return data;
1129
+ }
1130
+ const chunks = chunk(ids, 10);
1131
+ const responses = await Promise.all(
1132
+ chunks.map(
1133
+ (chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
1134
+ )
1135
+ );
1136
+ return { parties: responses.flatMap((r) => r.data.parties) };
573
1137
  }
574
1138
  var listPartyOpportunitiesSchema = z3.object({
575
1139
  partyId: z3.number().int().positive(),
@@ -653,6 +1217,14 @@ async function updateParty(input) {
653
1217
  if (mappedFields !== void 0) body["fields"] = mappedFields;
654
1218
  return capsulePut(`/parties/${id}`, { party: body });
655
1219
  }
1220
+ var batchUpdatePartySchema = z3.object({
1221
+ items: z3.array(updatePartySchema).min(1).max(50).describe(
1222
+ "Array of 1\u201350 update_party inputs. Each item is the same shape as a single update_party call \u2014 id is required, every other field is optional. Capped at 50 so a single tool call can't burn an outsized share of Capsule's hourly per-token rate budget (~4000 req/h)."
1223
+ )
1224
+ });
1225
+ async function batchUpdateParty(input, opts = {}) {
1226
+ return batchExecute("batch_update_party", input.items, (item) => updateParty(item), opts);
1227
+ }
656
1228
  var deletePartySchema = z3.object({
657
1229
  id: z3.number().int().positive(),
658
1230
  confirm: confirmFlag().describe(
@@ -855,15 +1427,29 @@ async function getOpportunity(input) {
855
1427
  return data;
856
1428
  }
857
1429
  var getOpportunitiesSchema = z4.object({
858
- ids: z4.array(z4.number().int().positive()).min(1).max(10).describe("Array of opportunity IDs (1\u201310). Capsule caps batch fetches at 10."),
1430
+ ids: z4.array(z4.number().int().positive()).min(1).max(50).describe(
1431
+ "Array of opportunity IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel."
1432
+ ),
859
1433
  embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
860
1434
  });
861
1435
  async function getOpportunities(input) {
862
- const { data } = await capsuleGet(
863
- `/opportunities/${input.ids.join(",")}`,
864
- { embed: input.embed }
1436
+ const { ids, embed } = input;
1437
+ if (ids.length <= 10) {
1438
+ const { data } = await capsuleGet(
1439
+ `/opportunities/${ids.join(",")}`,
1440
+ { embed }
1441
+ );
1442
+ return data;
1443
+ }
1444
+ const chunks = chunk(ids, 10);
1445
+ const responses = await Promise.all(
1446
+ chunks.map(
1447
+ (chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
1448
+ embed
1449
+ })
1450
+ )
865
1451
  );
866
- return data;
1452
+ return { opportunities: responses.flatMap((r) => r.data.opportunities) };
867
1453
  }
868
1454
  var createOpportunitySchema = z4.object({
869
1455
  name: z4.string().min(1),
@@ -877,16 +1463,20 @@ var createOpportunitySchema = z4.object({
877
1463
  probability: z4.number().int().min(0).max(100).optional(),
878
1464
  ownerId: z4.number().int().positive().optional().describe(
879
1465
  "Assign to user ID. Defaults to the API-token owner when omitted \u2014 note that opportunities do NOT inherit owner from the linked party, even though one might expect it. Once set, this connector cannot clear the owner back to null (use Capsule's web UI). Discover IDs via list_users."
1466
+ ),
1467
+ teamId: z4.number().int().positive().optional().describe(
1468
+ "Assign to team ID (discover via list_teams). Independent from `ownerId` \u2014 setting one does NOT clear the other on create. Three ownership shapes are valid: owner alone, team alone, or owner+team (the owner must be a member of the team; users can belong to multiple teams \u2014 422 'owner is not a member of the team' otherwise)."
880
1469
  )
881
1470
  });
882
1471
  async function createOpportunity(input) {
883
- const { partyId, milestoneId, ownerId, ...rest } = input;
1472
+ const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
884
1473
  const body = {
885
1474
  ...rest,
886
1475
  party: { id: partyId },
887
1476
  milestone: { id: milestoneId }
888
1477
  };
889
1478
  if (ownerId) body["owner"] = { id: ownerId };
1479
+ if (teamId) body["team"] = { id: teamId };
890
1480
  return capsulePost("/opportunities", { opportunity: body });
891
1481
  }
892
1482
  var updateOpportunitySchema = z4.object({
@@ -905,18 +1495,28 @@ var updateOpportunitySchema = z4.object({
905
1495
  "Reason the opportunity was lost. Only meaningful when transitioning to a Lost milestone \u2014 Capsule silently drops it for other milestones. Without this set, a connector-driven Lost-close leaves `lostReason: null`. Discover IDs via list_lostreasons."
906
1496
  ),
907
1497
  ownerId: z4.number().int().positive().optional().describe(
908
- "Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
1498
+ "Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that. When you supply `ownerId` and omit `teamId`, the connector fetches the opportunity's current team and includes it in the PUT body to preserve it across the owner change. Without this defensive read, Capsule's PUT would clear the existing team (see NOTES-ON-CAPSULE-API.md \xA727 \u2014 same asymmetric semantic as /kases). Supply `teamId` explicitly on the same call to change the team instead."
1499
+ ),
1500
+ teamId: z4.number().int().positive().nullable().optional().describe(
1501
+ "Reassign team: pass a team ID (discover via list_teams) to set, or `null` to unassign. Capsule preserves the existing owner across a team change (server-side), so `update_opportunity { teamId }` alone is safe \u2014 the owner is carried through. Owner must be a member of the new team or Capsule returns 422 'owner is not a member of the team'. Independent from `ownerId` \u2014 setting `teamId` does NOT clear the owner."
909
1502
  ),
910
1503
  fields: z4.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_opportunity"))
911
1504
  });
912
1505
  async function updateOpportunity(input) {
913
- const { id, milestoneId, ownerId, lostReasonId, fields, ...rest } = input;
1506
+ const { id, milestoneId, ownerId, teamId, lostReasonId, fields, ...rest } = input;
914
1507
  const body = {};
915
1508
  for (const [k, v] of Object.entries(rest)) {
916
1509
  if (v !== void 0) body[k] = v;
917
1510
  }
918
1511
  if (milestoneId) body["milestone"] = { id: milestoneId };
1512
+ let resolvedTeamId = teamId;
1513
+ if (ownerId !== void 0 && teamId === void 0) {
1514
+ const { data } = await capsuleGet(`/opportunities/${id}`);
1515
+ resolvedTeamId = data.opportunity?.team?.id ?? void 0;
1516
+ }
919
1517
  if (ownerId) body["owner"] = { id: ownerId };
1518
+ if (resolvedTeamId === null) body["team"] = null;
1519
+ else if (resolvedTeamId !== void 0) body["team"] = { id: resolvedTeamId };
920
1520
  if (lostReasonId) body["lostReason"] = { id: lostReasonId };
921
1521
  const mappedFields = mapFieldsForBody(fields);
922
1522
  if (mappedFields !== void 0) body["fields"] = mappedFields;
@@ -924,6 +1524,19 @@ async function updateOpportunity(input) {
924
1524
  opportunity: body
925
1525
  });
926
1526
  }
1527
+ var batchUpdateOpportunitySchema = z4.object({
1528
+ items: z4.array(updateOpportunitySchema).min(1).max(50).describe(
1529
+ "Array of 1\u201350 update_opportunity inputs. Each item is the same shape as a single update_opportunity call \u2014 id is required, every other field is optional. Capped at 50 so a single tool call can't burn an outsized share of Capsule's hourly per-token rate budget."
1530
+ )
1531
+ });
1532
+ async function batchUpdateOpportunity(input, opts = {}) {
1533
+ return batchExecute(
1534
+ "batch_update_opportunity",
1535
+ input.items,
1536
+ (item) => updateOpportunity(item),
1537
+ opts
1538
+ );
1539
+ }
927
1540
  var deleteOpportunitySchema = z4.object({
928
1541
  id: z4.number().int().positive(),
929
1542
  confirm: confirmFlag().describe(
@@ -969,14 +1582,26 @@ async function getProject(input) {
969
1582
  return data;
970
1583
  }
971
1584
  var getProjectsSchema = z5.object({
972
- ids: z5.array(z5.number().int().positive()).min(1).max(10).describe("Array of project IDs (1\u201310). Capsule caps batch fetches at 10."),
1585
+ ids: z5.array(z5.number().int().positive()).min(1).max(50).describe(
1586
+ "Array of project IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel."
1587
+ ),
973
1588
  embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
974
1589
  });
975
1590
  async function getProjects(input) {
976
- const { data } = await capsuleGet(`/kases/${input.ids.join(",")}`, {
977
- embed: input.embed
978
- });
979
- return data;
1591
+ const { ids, embed } = input;
1592
+ if (ids.length <= 10) {
1593
+ const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
1594
+ embed
1595
+ });
1596
+ return data;
1597
+ }
1598
+ const chunks = chunk(ids, 10);
1599
+ const responses = await Promise.all(
1600
+ chunks.map(
1601
+ (chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
1602
+ )
1603
+ );
1604
+ return { kases: responses.flatMap((r) => r.data.kases) };
980
1605
  }
981
1606
  var createProjectSchema = z5.object({
982
1607
  name: z5.string().min(1),
@@ -1104,11 +1729,21 @@ async function getTask(input) {
1104
1729
  return data;
1105
1730
  }
1106
1731
  var getTasksSchema = z6.object({
1107
- ids: z6.array(z6.number().int().positive()).min(1).max(10).describe("Array of task IDs (1\u201310). Capsule caps batch fetches at 10.")
1732
+ ids: z6.array(z6.number().int().positive()).min(1).max(50).describe(
1733
+ "Array of task IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel."
1734
+ )
1108
1735
  });
1109
1736
  async function getTasks(input) {
1110
- const { data } = await capsuleGet(`/tasks/${input.ids.join(",")}`);
1111
- return data;
1737
+ const { ids } = input;
1738
+ if (ids.length <= 10) {
1739
+ const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
1740
+ return data;
1741
+ }
1742
+ const chunks = chunk(ids, 10);
1743
+ const responses = await Promise.all(
1744
+ chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
1745
+ );
1746
+ return { tasks: responses.flatMap((r) => r.data.tasks) };
1112
1747
  }
1113
1748
  var createTaskSchema = z6.object({
1114
1749
  description: z6.string().min(1),
@@ -1168,6 +1803,14 @@ async function completeTask(input) {
1168
1803
  task: { status: "COMPLETED" }
1169
1804
  });
1170
1805
  }
1806
+ var batchCompleteTaskSchema = z6.object({
1807
+ ids: z6.array(z6.number().int().positive()).min(1).max(50).describe(
1808
+ "Array of 1\u201350 task ids to mark COMPLETED in parallel. Each id resolves to one PUT /tasks/{id}; failures (e.g. 404 for a deleted task) surface per-item in the result array, the rest still complete. Capped at 50."
1809
+ )
1810
+ });
1811
+ async function batchCompleteTask(input, opts = {}) {
1812
+ return batchExecute("batch_complete_task", input.ids, (id) => completeTask({ id }), opts);
1813
+ }
1171
1814
  var deleteTaskSchema = z6.object({
1172
1815
  id: z6.number().int().positive(),
1173
1816
  confirm: confirmFlag().describe(
@@ -1314,7 +1957,7 @@ var paginationFields = {
1314
1957
  };
1315
1958
  var listPipelinesSchema = z8.object({ ...paginationFields });
1316
1959
  async function listPipelines(input) {
1317
- const { data, nextPage } = await capsuleGet("/pipelines", {
1960
+ const { data, nextPage } = await capsuleGetCached("/pipelines", {
1318
1961
  page: input.page ?? 1,
1319
1962
  perPage: input.perPage ?? 100
1320
1963
  });
@@ -1325,7 +1968,7 @@ var listMilestonesSchema = z8.object({
1325
1968
  ...paginationFields
1326
1969
  });
1327
1970
  async function listMilestones(input) {
1328
- const { data, nextPage } = await capsuleGet(
1971
+ const { data, nextPage } = await capsuleGetCached(
1329
1972
  `/pipelines/${input.pipelineId}/milestones`,
1330
1973
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
1331
1974
  );
@@ -1340,7 +1983,7 @@ var paginationFields2 = {
1340
1983
  };
1341
1984
  var listBoardsSchema = z9.object({ ...paginationFields2 });
1342
1985
  async function listBoards(input) {
1343
- const { data, nextPage } = await capsuleGet("/boards", {
1986
+ const { data, nextPage } = await capsuleGetCached("/boards", {
1344
1987
  page: input.page ?? 1,
1345
1988
  perPage: input.perPage ?? 100
1346
1989
  });
@@ -1354,7 +1997,7 @@ var listStagesSchema = z9.object({
1354
1997
  });
1355
1998
  async function listStages(input) {
1356
1999
  const path = input.boardId !== void 0 ? `/boards/${input.boardId}/stages` : "/stages";
1357
- const { data, nextPage } = await capsuleGet(path, {
2000
+ const { data, nextPage } = await capsuleGetCached(path, {
1358
2001
  page: input.page ?? 1,
1359
2002
  perPage: input.perPage ?? 100
1360
2003
  });
@@ -1381,7 +2024,7 @@ var listTagsSchema = z10.object({
1381
2024
  });
1382
2025
  async function listTags(input) {
1383
2026
  const path = TAG_LIST_PATH[input.entity];
1384
- const { data, nextPage } = await capsuleGet(path, {
2027
+ const { data, nextPage } = await capsuleGetCached(path, {
1385
2028
  page: input.page ?? 1,
1386
2029
  perPage: input.perPage ?? 100
1387
2030
  });
@@ -1397,9 +2040,11 @@ var addTagSchema = z10.object({
1397
2040
  async function addTag(input) {
1398
2041
  const { entity, entityId, tagName } = input;
1399
2042
  const wrapper = ENTITY_TO_WRAPPER[entity];
1400
- return capsulePut(`/${entity}/${entityId}`, {
2043
+ const result = await capsulePut(`/${entity}/${entityId}`, {
1401
2044
  [wrapper]: { tags: [{ name: tagName }] }
1402
2045
  });
2046
+ invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
2047
+ return result;
1403
2048
  }
1404
2049
  var removeTagByIdSchema = z10.object({
1405
2050
  entity: TagEntity,
@@ -1411,17 +2056,17 @@ var removeTagByIdSchema = z10.object({
1411
2056
  async function removeTagById(input) {
1412
2057
  const { entity, entityId, tagId } = input;
1413
2058
  const wrapper = ENTITY_TO_WRAPPER[entity];
1414
- return idempotentWithResult(
2059
+ const result = await idempotentWithResult(
1415
2060
  () => capsulePut(`/${entity}/${entityId}`, {
1416
2061
  [wrapper]: { tags: [{ id: tagId, _delete: true }] }
1417
2062
  }),
1418
- (result) => ({
2063
+ (result2) => ({
1419
2064
  removed: true,
1420
2065
  alreadyRemoved: false,
1421
2066
  entity,
1422
2067
  entityId,
1423
2068
  tagId,
1424
- ...result
2069
+ ...result2
1425
2070
  }),
1426
2071
  () => ({ removed: true, alreadyRemoved: true, entity, entityId, tagId }),
1427
2072
  // Tag detach uses PUT with _delete: true and 422s with "tag not
@@ -1429,6 +2074,24 @@ async function removeTagById(input) {
1429
2074
  // 404. Other 422s with different wording still surface.
1430
2075
  isCapsuleTagNotFound
1431
2076
  );
2077
+ invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
2078
+ return result;
2079
+ }
2080
+ var batchAddTagSchema = z10.object({
2081
+ items: z10.array(addTagSchema).min(1).max(50).describe(
2082
+ "Array of 1\u201350 add_tag inputs. Useful for mass-tagging \u2014 e.g. 'tag these 20 contacts as RSAC26'. Each item is the same shape as a single add_tag call. The list_tags cache is invalidated for each affected entity type. Capped at 50."
2083
+ )
2084
+ });
2085
+ async function batchAddTag(input, opts = {}) {
2086
+ return batchExecute("batch_add_tag", input.items, (item) => addTag(item), opts);
2087
+ }
2088
+ var batchRemoveTagByIdSchema = z10.object({
2089
+ items: z10.array(removeTagByIdSchema).min(1).max(50).describe(
2090
+ "Array of 1\u201350 remove_tag_by_id inputs. Each item is the same shape as a single remove_tag_by_id call. Detaches the tag from each specified entity; the tag definition itself persists in the tenant. Capped at 50."
2091
+ )
2092
+ });
2093
+ async function batchRemoveTagById(input, opts = {}) {
2094
+ return batchExecute("batch_remove_tag_by_id", input.items, (item) => removeTagById(item), opts);
1432
2095
  }
1433
2096
 
1434
2097
  // src/tools/users.ts
@@ -1438,7 +2101,7 @@ var listUsersSchema = z11.object({
1438
2101
  perPage: z11.number().int().min(1).max(100).optional()
1439
2102
  });
1440
2103
  async function listUsers(input) {
1441
- const { data, nextPage } = await capsuleGet("/users", {
2104
+ const { data, nextPage } = await capsuleGetCached("/users", {
1442
2105
  page: input.page ?? 1,
1443
2106
  perPage: input.perPage ?? 100
1444
2107
  });
@@ -1507,7 +2170,7 @@ var paginationFields3 = {
1507
2170
  };
1508
2171
  var listTeamsSchema = z13.object({ ...paginationFields3 });
1509
2172
  async function listTeams(input) {
1510
- const { data, nextPage } = await capsuleGet("/teams", {
2173
+ const { data, nextPage } = await capsuleGetCached("/teams", {
1511
2174
  page: input.page ?? 1,
1512
2175
  perPage: input.perPage ?? 100
1513
2176
  });
@@ -1515,7 +2178,7 @@ async function listTeams(input) {
1515
2178
  }
1516
2179
  var listLostReasonsSchema = z13.object({ ...paginationFields3 });
1517
2180
  async function listLostReasons(input) {
1518
- const { data, nextPage } = await capsuleGet("/lostreasons", {
2181
+ const { data, nextPage } = await capsuleGetCached("/lostreasons", {
1519
2182
  page: input.page ?? 1,
1520
2183
  perPage: input.perPage ?? 100
1521
2184
  });
@@ -1523,20 +2186,23 @@ async function listLostReasons(input) {
1523
2186
  }
1524
2187
  var listActivityTypesSchema = z13.object({ ...paginationFields3 });
1525
2188
  async function listActivityTypes(input) {
1526
- const { data, nextPage } = await capsuleGet("/activitytypes", {
1527
- page: input.page ?? 1,
1528
- perPage: input.perPage ?? 100
1529
- });
2189
+ const { data, nextPage } = await capsuleGetCached(
2190
+ "/activitytypes",
2191
+ {
2192
+ page: input.page ?? 1,
2193
+ perPage: input.perPage ?? 100
2194
+ }
2195
+ );
1530
2196
  return { ...data, nextPage };
1531
2197
  }
1532
2198
  var getSiteSchema = z13.object({});
1533
2199
  async function getSite(_input) {
1534
- const { data } = await capsuleGet("/site");
2200
+ const { data } = await capsuleGetCached("/site");
1535
2201
  return data;
1536
2202
  }
1537
2203
  var listTrackDefinitionsSchema = z13.object({ ...paginationFields3 });
1538
2204
  async function listTrackDefinitions(input) {
1539
- const { data, nextPage } = await capsuleGet(
2205
+ const { data, nextPage } = await capsuleGetCached(
1540
2206
  "/trackdefinitions",
1541
2207
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
1542
2208
  );
@@ -1544,7 +2210,7 @@ async function listTrackDefinitions(input) {
1544
2210
  }
1545
2211
  var listCategoriesSchema = z13.object({ ...paginationFields3 });
1546
2212
  async function listCategories(input) {
1547
- const { data, nextPage } = await capsuleGet("/categories", {
2213
+ const { data, nextPage } = await capsuleGetCached("/categories", {
1548
2214
  page: input.page ?? 1,
1549
2215
  perPage: input.perPage ?? 100
1550
2216
  });
@@ -1552,7 +2218,7 @@ async function listCategories(input) {
1552
2218
  }
1553
2219
  var listGoalsSchema = z13.object({ ...paginationFields3 });
1554
2220
  async function listGoals(input) {
1555
- const { data, nextPage } = await capsuleGet("/goals", {
2221
+ const { data, nextPage } = await capsuleGetCached("/goals", {
1556
2222
  page: input.page ?? 1,
1557
2223
  perPage: input.perPage ?? 100
1558
2224
  });
@@ -1711,7 +2377,7 @@ var listCustomFieldsSchema = z16.object({
1711
2377
  entity: CustomFieldEntity
1712
2378
  });
1713
2379
  async function listCustomFields(input) {
1714
- const { data } = await capsuleGet(
2380
+ const { data } = await capsuleGetCached(
1715
2381
  `/${input.entity}/fields/definitions`
1716
2382
  );
1717
2383
  return data;
@@ -1721,7 +2387,7 @@ var getCustomFieldSchema = z16.object({
1721
2387
  fieldId: z16.number().int().positive().describe("Custom field definition id.")
1722
2388
  });
1723
2389
  async function getCustomField(input) {
1724
- const { data } = await capsuleGet(
2390
+ const { data } = await capsuleGetCached(
1725
2391
  `/${input.entity}/fields/definitions/${input.fieldId}`
1726
2392
  );
1727
2393
  return data;
@@ -1892,7 +2558,7 @@ var listSavedFiltersSchema = z19.object({
1892
2558
  entity: EntitySchema
1893
2559
  });
1894
2560
  async function listSavedFilters(input) {
1895
- const { data } = await capsuleGet(`/${input.entity}/filters`);
2561
+ const { data } = await capsuleGetCached(`/${input.entity}/filters`);
1896
2562
  return data;
1897
2563
  }
1898
2564
  var runSavedFilterSchema = z19.object({
@@ -1911,15 +2577,35 @@ async function runSavedFilter(input) {
1911
2577
  }
1912
2578
 
1913
2579
  // src/server.ts
1914
- function createCapsuleMcpServer() {
2580
+ function createCapsuleMcpServer(opts) {
1915
2581
  const readOnly = isReadOnly();
1916
- const server2 = new McpServer({
1917
- name: "capsulemcp",
1918
- version: "1.0.1",
1919
- description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
1920
- websiteUrl: "https://github.com/soil-dev/capsulemcp",
1921
- icons: ICONS
1922
- });
2582
+ const tasksCfg = getTasksConfig();
2583
+ const tasksWired = tasksCfg.enabled && !!opts?.clientId;
2584
+ const server2 = new McpServer(
2585
+ {
2586
+ name: "capsulemcp",
2587
+ version: "1.6.1",
2588
+ description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
2589
+ websiteUrl: "https://github.com/soil-dev/capsulemcp",
2590
+ icons: ICONS
2591
+ },
2592
+ tasksWired ? {
2593
+ // tasksWired guards clientId presence; narrow explicitly
2594
+ // for the type-checker rather than using `!`.
2595
+ taskStore: createScopedTaskStore(opts?.clientId ?? ""),
2596
+ capabilities: {
2597
+ tasks: {
2598
+ // The SDK's task capability schema uses {} for "present"
2599
+ // markers, not booleans — see ServerTasksCapabilitySchema
2600
+ // in @modelcontextprotocol/sdk types.ts.
2601
+ list: {},
2602
+ cancel: {},
2603
+ requests: { tools: { call: {} } }
2604
+ }
2605
+ }
2606
+ } : void 0
2607
+ );
2608
+ const registerBatchTool = tasksWired ? registerToolTask : (s, name, description, schema, handler) => registerTool(s, name, description, schema, (input) => handler(input, {}));
1923
2609
  registerTool(
1924
2610
  server2,
1925
2611
  "search_parties",
@@ -1937,14 +2623,14 @@ function createCapsuleMcpServer() {
1937
2623
  registerTool(
1938
2624
  server2,
1939
2625
  "get_party",
1940
- "Fetch a single party (person or organisation) by its numeric ID.",
2626
+ "Fetch a single party (person or organisation) by its numeric id. Returns the full record including type, name fields, emails, phones, addresses, websites, and any embedded tags or custom fields. Use embed='tags,fields' to include those in one round-trip. For batch fetches of up to 50 parties at once, use get_parties instead.",
1941
2627
  getPartySchema,
1942
2628
  getParty
1943
2629
  );
1944
2630
  registerTool(
1945
2631
  server2,
1946
2632
  "get_parties",
1947
- "Batch-fetch up to 10 parties by ID in a single call. Use this when Claude already knows several party IDs to avoid N round trips of get_party.",
2633
+ "Batch-fetch up to 50 parties by ID. For 1\u201310 ids this is a single Capsule round trip (native multi-id endpoint); for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged. Use this whenever Claude has several party IDs to avoid N sequential round trips of get_party.",
1948
2634
  getPartiesSchema,
1949
2635
  getParties
1950
2636
  );
@@ -2005,6 +2691,13 @@ function createCapsuleMcpServer() {
2005
2691
  updatePartySchema,
2006
2692
  updateParty
2007
2693
  );
2694
+ registerBatchTool(
2695
+ server2,
2696
+ "batch_update_party",
2697
+ "Update 1\u201350 parties in parallel. Same input shape as update_party but wrapped in an `items` array. Use this \u2014 not N sequential update_party calls \u2014 for any homogeneous multi-record write (mass owner reassignment, bulk metadata corrections, etc.). Capsule has no batch-write API, so the connector fans out parallel HTTP requests with a default concurrency cap of 5 (configurable via CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. Partial failures are possible \u2014 Capsule has no rollback, so successful items stay applied even if other items 4xx. Read the per-item result array to know which ones need follow-up.",
2698
+ batchUpdatePartySchema,
2699
+ batchUpdateParty
2700
+ );
2008
2701
  registerTool(
2009
2702
  server2,
2010
2703
  "delete_party",
@@ -2086,14 +2779,14 @@ function createCapsuleMcpServer() {
2086
2779
  registerTool(
2087
2780
  server2,
2088
2781
  "get_opportunity",
2089
- "Fetch a single opportunity by its numeric id. Returns the full record including value, milestone, owner, party, and any embedded tags/custom fields. Use embed='tags,fields' to include those in one round-trip. For batch fetches of up to 10 opportunities at once, use get_opportunities instead.",
2782
+ "Fetch a single opportunity by its numeric id. Returns the full record including value, milestone, owner, party, and any embedded tags/custom fields. Use embed='tags,fields' to include those in one round-trip. For batch fetches of up to 50 opportunities at once, use get_opportunities instead.",
2090
2783
  getOpportunitySchema,
2091
2784
  getOpportunity
2092
2785
  );
2093
2786
  registerTool(
2094
2787
  server2,
2095
2788
  "get_opportunities",
2096
- "Batch-fetch up to 10 opportunities by ID in a single call.",
2789
+ "Batch-fetch up to 50 opportunities by id. For 1\u201310 ids this is a single Capsule round trip (native multi-id endpoint); for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged. Returns each opportunity's full record (value, milestone, owner, party). For a single id, use get_opportunity instead.",
2097
2790
  getOpportunitiesSchema,
2098
2791
  getOpportunities
2099
2792
  );
@@ -2133,6 +2826,13 @@ function createCapsuleMcpServer() {
2133
2826
  updateOpportunitySchema,
2134
2827
  updateOpportunity
2135
2828
  );
2829
+ registerBatchTool(
2830
+ server2,
2831
+ "batch_update_opportunity",
2832
+ "Update 1\u201350 opportunities in parallel. Same input shape as update_opportunity but wrapped in an `items` array. Use this \u2014 not N sequential update_opportunity calls \u2014 for mass stage transitions (e.g. move a milestone batch to Won), owner reassignments, or value adjustments. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. Partial failures possible; Capsule has no rollback.",
2833
+ batchUpdateOpportunitySchema,
2834
+ batchUpdateOpportunity
2835
+ );
2136
2836
  registerTool(
2137
2837
  server2,
2138
2838
  "delete_opportunity",
@@ -2158,14 +2858,14 @@ function createCapsuleMcpServer() {
2158
2858
  registerTool(
2159
2859
  server2,
2160
2860
  "get_project",
2161
- "Fetch a single project (case) by its numeric ID.",
2861
+ "Fetch a single project (Capsule's term: 'case') by its numeric id. Returns the full record including name, description, status (OPEN/CLOSED), owner, stage, board, opportunityId (if linked), and timestamps. Use embed='tags,fields' to include attached tags and custom field values in one round-trip. For batch fetches of up to 50 projects at once, use get_projects instead. For the project's timeline (notes, captured emails, completed-task records) use list_project_entries.",
2162
2862
  getProjectSchema,
2163
2863
  getProject
2164
2864
  );
2165
2865
  registerTool(
2166
2866
  server2,
2167
2867
  "get_projects",
2168
- "Batch-fetch up to 10 projects (cases) by ID in a single call.",
2868
+ "Batch-fetch up to 50 projects (cases) by ID. For 1\u201310 ids this is a single Capsule round trip; for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged.",
2169
2869
  getProjectsSchema,
2170
2870
  getProjects
2171
2871
  );
@@ -2244,14 +2944,14 @@ function createCapsuleMcpServer() {
2244
2944
  registerTool(
2245
2945
  server2,
2246
2946
  "get_task",
2247
- "Fetch a single task by its numeric id. Returns the task's description, due date, owner, completion state, and the entity it's attached to (party / opportunity / project, if any \u2014 standalone tasks not tied to a record are also valid). For batch fetches of up to 10 tasks at once, use get_tasks instead.",
2947
+ "Fetch a single task by its numeric id. Returns the task's description, due date, owner, completion state, and the entity it's attached to (party / opportunity / project, if any \u2014 standalone tasks not tied to a record are also valid). For batch fetches of up to 50 tasks at once, use get_tasks instead.",
2248
2948
  getTaskSchema,
2249
2949
  getTask
2250
2950
  );
2251
2951
  registerTool(
2252
2952
  server2,
2253
2953
  "get_tasks",
2254
- "Batch-fetch up to 10 tasks by ID in a single call.",
2954
+ "Batch-fetch up to 50 tasks by ID. For 1\u201310 ids this is a single Capsule round trip; for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged.",
2255
2955
  getTasksSchema,
2256
2956
  getTasks
2257
2957
  );
@@ -2266,7 +2966,7 @@ function createCapsuleMcpServer() {
2266
2966
  registerTool(
2267
2967
  server2,
2268
2968
  "update_task",
2269
- "Update fields on an existing task. Only the fields you provide are changed. To mark a task done prefer complete_task.",
2969
+ "Update fields on an existing task: `description`, `dueOn`, `dueTime`, `detail`, `status` (OPEN or COMPLETED), and `ownerId`. Only the fields you provide are changed. To mark a task done, prefer the dedicated `complete_task` tool \u2014 it's idempotent (a no-op success on an already-completed task) and semantically clearer than `update_task status=COMPLETED`. Capsule rejects directly setting status=PENDING (which exists only internally for track-driven tasks); use OPEN or COMPLETED. Completed tasks remain fully editable \u2014 Capsule does not enforce closed-record immutability.",
2270
2970
  updateTaskSchema,
2271
2971
  updateTask
2272
2972
  );
@@ -2277,6 +2977,13 @@ function createCapsuleMcpServer() {
2277
2977
  completeTaskSchema,
2278
2978
  completeTask
2279
2979
  );
2980
+ registerBatchTool(
2981
+ server2,
2982
+ "batch_complete_task",
2983
+ "Mark 1\u201350 tasks COMPLETED in parallel. Pass `ids: [task_id, \u2026]`. Natural for end-of-week catchups, 'close all the follow-ups from this campaign', etc. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per id], summary: {total, succeeded, failed} }. A task that's already completed or deleted shows up as a per-item failure with the Capsule status; the rest still complete.",
2984
+ batchCompleteTaskSchema,
2985
+ batchCompleteTask
2986
+ );
2280
2987
  registerTool(
2281
2988
  server2,
2282
2989
  "delete_task",
@@ -2288,7 +2995,7 @@ function createCapsuleMcpServer() {
2288
2995
  registerTool(
2289
2996
  server2,
2290
2997
  "list_party_entries",
2291
- "List timeline entries (notes, captured emails, completed-task records) for a party. Use this to read the conversation history with a contact or organisation.",
2998
+ "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.",
2292
2999
  listPartyEntriesSchema,
2293
3000
  listPartyEntries
2294
3001
  );
@@ -2309,7 +3016,7 @@ function createCapsuleMcpServer() {
2309
3016
  registerTool(
2310
3017
  server2,
2311
3018
  "get_entry",
2312
- "Fetch a single timeline entry by its numeric ID. Returns full content (note body, email subject + body, etc.).",
3019
+ "Fetch a single timeline entry by its numeric id. Returns the full payload \u2014 for a note: the body text; for a captured email: subject, body, from/to, and timestamps; for a completed-task record: the original task fields. Useful when you have an entry id from one of the `list_*_entries` calls and want the full content. To modify the body or activity-type of an existing entry use `update_entry`; to delete one use `delete_entry`.",
2313
3020
  getEntrySchema,
2314
3021
  getEntry
2315
3022
  );
@@ -2324,6 +3031,10 @@ function createCapsuleMcpServer() {
2324
3031
  "get_attachment",
2325
3032
  "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.",
2326
3033
  getAttachmentSchema.shape,
3034
+ // get_attachment is read-only — downloads a binary, never mutates.
3035
+ // Mirrors the auto-inferred `readOnlyHint: true` that
3036
+ // `registerTool` applies to every other `get_*` tool.
3037
+ { readOnlyHint: true },
2327
3038
  async (input) => {
2328
3039
  const result = await getAttachment(input);
2329
3040
  if (result.truncated) {
@@ -2451,7 +3162,7 @@ function createCapsuleMcpServer() {
2451
3162
  registerTool(
2452
3163
  server2,
2453
3164
  "list_stages",
2454
- "List project stages. Without arguments returns every stage across every board (each carries a .board reference). Pass boardId to scope to one specific board.",
3165
+ "List project (case) stages. Without arguments returns every stage across every board (each entry carries a `.board` reference so you can tell them apart). Pass `boardId` to scope the result to one specific board's stages. Use this to discover the numeric `stage.id` that `create_project` / `update_project` consume \u2014 stage names alone won't do, Capsule resolves by id. For opportunity (deal) stages, use `list_pipelines` instead \u2014 opportunities don't have stages in the project sense.",
2455
3166
  listStagesSchema,
2456
3167
  listStages
2457
3168
  );
@@ -2465,7 +3176,7 @@ function createCapsuleMcpServer() {
2465
3176
  registerTool(
2466
3177
  server2,
2467
3178
  "list_lostreasons",
2468
- "List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor'). Useful for analysing closed-lost opportunities by reason.",
3179
+ "List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor', 'Price too high'). Returns each reason's id and name; the set is account-configured rather than a fixed enum, so call this to discover valid ids before referencing a lostReason in update_opportunity when closing a deal as lost. Useful for analysing closed-lost opportunities by reason.",
2469
3180
  listLostReasonsSchema,
2470
3181
  listLostReasons
2471
3182
  );
@@ -2479,7 +3190,7 @@ function createCapsuleMcpServer() {
2479
3190
  registerTool(
2480
3191
  server2,
2481
3192
  "list_categories",
2482
- "List configured entry/task categories (Call, Email, Meeting, Follow-up, etc.) with their colours. Used to label and filter timeline entries and tasks.",
3193
+ "List configured entry/task categories (Call, Email, Meeting, Follow-up, etc.) with their colours. Returns each category's id, name, and colour. The set is account-configured rather than a fixed enum \u2014 call this to discover valid category ids before referencing one in add_note or create_task. Used to label and filter timeline entries and tasks.",
2483
3194
  listCategoriesSchema,
2484
3195
  listCategories
2485
3196
  );
@@ -2554,6 +3265,20 @@ function createCapsuleMcpServer() {
2554
3265
  removeTagByIdSchema,
2555
3266
  removeTagById
2556
3267
  );
3268
+ registerBatchTool(
3269
+ server2,
3270
+ "batch_add_tag",
3271
+ "Attach tags to many entities in parallel \u2014 e.g. tag a list of 20 contacts as 'RSAC26' after a conference, or apply the 'Departed' tag to 10 people in a layoff batch. Pass `items: [{ entity, entityId, tagName }, ...]` (1\u201350 items). Each item is processed identically to a single add_tag call. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. The list_tags cache is invalidated for each affected entity type.",
3272
+ batchAddTagSchema,
3273
+ batchAddTag
3274
+ );
3275
+ registerBatchTool(
3276
+ server2,
3277
+ "batch_remove_tag_by_id",
3278
+ "Detach tags from many entities in parallel \u2014 cleanup counterpart to batch_add_tag. Pass `items: [{ entity, entityId, tagId }, ...]` (1\u201350 items). Each item is processed identically to a single remove_tag_by_id call (already-detached tags are reported as idempotent successes, not failures). Connector fans out parallel HTTP requests, default cap 5. Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }.",
3279
+ batchRemoveTagByIdSchema,
3280
+ batchRemoveTagById
3281
+ );
2557
3282
  }
2558
3283
  registerTool(
2559
3284
  server2,