capsulemcp 1.0.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,10 +286,30 @@ async function doFetch(url, options) {
167
286
  "Rate limit exceeded after one retry. Please slow down your requests."
168
287
  );
169
288
  }
289
+ emitCapsuleRequest(method, url, retried.res, Date.now() - startedAt, true);
170
290
  return retried;
171
291
  }
292
+ emitCapsuleRequest(method, url, first.res, Date.now() - startedAt, false);
172
293
  return first;
173
294
  }
295
+ function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
296
+ let path = "";
297
+ try {
298
+ path = redactPath(new URL(url).pathname);
299
+ } catch {
300
+ path = "?";
301
+ }
302
+ const lenHeader = res.headers.get("content-length");
303
+ const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
304
+ logEvent("capsule.request", {
305
+ method,
306
+ path,
307
+ status: res.status,
308
+ durationMs,
309
+ responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0,
310
+ ...retriedAfter429 ? { retriedAfter429: true } : {}
311
+ });
312
+ }
174
313
  async function throwForStatus(res) {
175
314
  if (res.status === 401) {
176
315
  const detail = await parseErrorBody(res);
@@ -210,6 +349,34 @@ async function capsuleGet(path, params) {
210
349
  cleanup();
211
350
  }
212
351
  }
352
+ async function capsuleGetCached(path, params) {
353
+ if (cacheDisabled()) return capsuleGet(path, params);
354
+ const key = cacheKey(path, params);
355
+ const lookup = cacheLookup(key);
356
+ if (lookup.hit) {
357
+ if (logVerbose()) {
358
+ logEvent("cache.hit", {
359
+ path: redactPath(path),
360
+ ...params ? { paramFields: Object.keys(params) } : {},
361
+ ageMs: lookup.ageMs
362
+ });
363
+ }
364
+ return lookup.result;
365
+ }
366
+ const fetchStart = Date.now();
367
+ const result = await capsuleGet(path, params);
368
+ const latencyMs = Date.now() - fetchStart;
369
+ cacheSet(key, result);
370
+ if (logVerbose()) {
371
+ logEvent("cache.miss", {
372
+ path: redactPath(path),
373
+ ...params ? { paramFields: Object.keys(params) } : {},
374
+ reason: lookup.reason,
375
+ latencyMs
376
+ });
377
+ }
378
+ return result;
379
+ }
213
380
  async function capsulePost(path, body) {
214
381
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
215
382
  const token = getToken();
@@ -396,7 +563,195 @@ var ICONS = [
396
563
  }
397
564
  ];
398
565
 
566
+ // src/tasks/store.ts
567
+ import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
568
+ import {
569
+ ErrorCode,
570
+ McpError
571
+ } from "@modelcontextprotocol/sdk/types.js";
572
+
573
+ // src/tasks/config.ts
574
+ var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
575
+ var DEFAULT_MAX_KEEP_ALIVE_MS = 15 * 60 * 1e3;
576
+ var MIN_TASK_TTL_MS = 1e3;
577
+ var DEFAULT_POLL_FREQUENCY_MS = 1500;
578
+ var MIN_POLL_FREQUENCY_MS = 500;
579
+ var DEFAULT_MAX_PER_CLIENT = 20;
580
+ var DEFAULT_MAX_TOTAL = 200;
581
+ function getTasksConfig() {
582
+ const enabled = readBool("MCP_TASKS_ENABLED");
583
+ const maxKeepAliveMs = Math.max(
584
+ readPositiveInt("MCP_TASKS_MAX_KEEP_ALIVE_MS", DEFAULT_MAX_KEEP_ALIVE_MS),
585
+ MIN_TASK_TTL_MS
586
+ );
587
+ const defaultTtlMs = Math.min(
588
+ Math.max(readPositiveInt("MCP_TASKS_DEFAULT_TTL_MS", DEFAULT_TTL_MS2), MIN_TASK_TTL_MS),
589
+ maxKeepAliveMs
590
+ );
591
+ const defaultPollFrequencyMs = Math.max(
592
+ readPositiveInt("MCP_TASKS_DEFAULT_POLL_FREQUENCY_MS", DEFAULT_POLL_FREQUENCY_MS),
593
+ MIN_POLL_FREQUENCY_MS
594
+ );
595
+ const maxPerClient = readPositiveInt("MCP_TASKS_MAX_PER_CLIENT", DEFAULT_MAX_PER_CLIENT);
596
+ const maxTotal = readPositiveInt("MCP_TASKS_MAX_TOTAL", DEFAULT_MAX_TOTAL);
597
+ return {
598
+ enabled,
599
+ defaultTtlMs,
600
+ maxKeepAliveMs,
601
+ defaultPollFrequencyMs,
602
+ maxPerClient,
603
+ maxTotal
604
+ };
605
+ }
606
+
607
+ // src/tasks/store.ts
608
+ var _globalStore = null;
609
+ function getGlobalStore() {
610
+ if (_globalStore === null) {
611
+ _globalStore = new InMemoryTaskStore();
612
+ }
613
+ return _globalStore;
614
+ }
615
+ var owners = /* @__PURE__ */ new Map();
616
+ var abortControllers = /* @__PURE__ */ new Map();
617
+ function registerAbortController(taskId, controller) {
618
+ abortControllers.set(taskId, controller);
619
+ }
620
+ function countPerClient(clientId) {
621
+ let n = 0;
622
+ for (const owner of owners.values()) {
623
+ if (owner === clientId) n++;
624
+ }
625
+ return n;
626
+ }
627
+ function createScopedTaskStore(clientId) {
628
+ if (!clientId) {
629
+ throw new Error("createScopedTaskStore: clientId is required");
630
+ }
631
+ const global = getGlobalStore();
632
+ async function getOwned(taskId) {
633
+ if (owners.get(taskId) !== clientId) return null;
634
+ return global.getTask(taskId);
635
+ }
636
+ return {
637
+ async createTask(taskParams, requestId, request, sessionId) {
638
+ const cfg = getTasksConfig();
639
+ const totalNow = owners.size;
640
+ if (totalNow >= cfg.maxTotal) {
641
+ logEvent("task.rejected", {
642
+ reason: "max_total",
643
+ clientId,
644
+ totalNow,
645
+ cap: cfg.maxTotal
646
+ });
647
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this server instance");
648
+ }
649
+ const perClientNow = countPerClient(clientId);
650
+ if (perClientNow >= cfg.maxPerClient) {
651
+ logEvent("task.rejected", {
652
+ reason: "max_per_client",
653
+ clientId,
654
+ perClientNow,
655
+ cap: cfg.maxPerClient
656
+ });
657
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this client");
658
+ }
659
+ const requestedTtl = taskParams.ttl;
660
+ const clampedTtl = requestedTtl === null ? cfg.maxKeepAliveMs : Math.max(
661
+ MIN_TASK_TTL_MS,
662
+ Math.min(requestedTtl ?? cfg.defaultTtlMs, cfg.maxKeepAliveMs)
663
+ );
664
+ const requestedPoll = taskParams.pollInterval ?? cfg.defaultPollFrequencyMs;
665
+ const clampedPoll = Math.max(cfg.defaultPollFrequencyMs, Math.floor(requestedPoll));
666
+ const task = await global.createTask(
667
+ { ttl: clampedTtl, pollInterval: clampedPoll, context: taskParams.context },
668
+ requestId,
669
+ request,
670
+ sessionId
671
+ );
672
+ owners.set(task.taskId, clientId);
673
+ const timer = setTimeout(() => {
674
+ owners.delete(task.taskId);
675
+ abortControllers.delete(task.taskId);
676
+ logEvent("task.evicted", { taskId: task.taskId, clientId, reason: "ttl" });
677
+ }, clampedTtl);
678
+ timer.unref?.();
679
+ logEvent("task.created", {
680
+ taskId: task.taskId,
681
+ clientId,
682
+ ttl: clampedTtl,
683
+ pollInterval: clampedPoll,
684
+ method: typeof request.method === "string" ? request.method : "unknown"
685
+ });
686
+ return task;
687
+ },
688
+ async getTask(taskId) {
689
+ return getOwned(taskId);
690
+ },
691
+ async storeTaskResult(taskId, status, result, sessionId) {
692
+ if (owners.get(taskId) !== clientId) {
693
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
694
+ }
695
+ logEvent("task.transition", { taskId, clientId, status });
696
+ await global.storeTaskResult(taskId, status, result, sessionId);
697
+ },
698
+ async getTaskResult(taskId, sessionId) {
699
+ if (owners.get(taskId) !== clientId) {
700
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
701
+ }
702
+ return global.getTaskResult(taskId, sessionId);
703
+ },
704
+ async updateTaskStatus(taskId, status, statusMessage, sessionId) {
705
+ if (owners.get(taskId) !== clientId) {
706
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
707
+ }
708
+ logEvent("task.transition", { taskId, clientId, status, statusMessage });
709
+ await global.updateTaskStatus(taskId, status, statusMessage, sessionId);
710
+ if (status === "cancelled") {
711
+ const ctrl = abortControllers.get(taskId);
712
+ if (ctrl && !ctrl.signal.aborted) ctrl.abort();
713
+ }
714
+ if (status === "completed" || status === "failed" || status === "cancelled") {
715
+ abortControllers.delete(taskId);
716
+ }
717
+ },
718
+ async listTasks(cursor, sessionId) {
719
+ const page = await global.listTasks(cursor, sessionId);
720
+ const filtered = page.tasks.filter((t) => owners.get(t.taskId) === clientId);
721
+ return page.nextCursor ? { tasks: filtered, nextCursor: page.nextCursor } : { tasks: filtered };
722
+ }
723
+ };
724
+ }
725
+
399
726
  // src/server/register-tool.ts
727
+ var READ_PREFIXES = ["search_", "filter_", "get_", "list_", "show_", "run_"];
728
+ var DESTRUCTIVE_NON_DELETE = /* @__PURE__ */ new Set(["remove_track", "remove_additional_party"]);
729
+ function isDestructive(name) {
730
+ return name.startsWith("delete_") || DESTRUCTIVE_NON_DELETE.has(name);
731
+ }
732
+ function inferAnnotations(name) {
733
+ if (READ_PREFIXES.some((p) => name.startsWith(p))) {
734
+ return { readOnlyHint: true };
735
+ }
736
+ if (isDestructive(name)) {
737
+ return { destructiveHint: true };
738
+ }
739
+ return void 0;
740
+ }
741
+ function argFieldNames(input) {
742
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
743
+ return Object.keys(input);
744
+ }
745
+ function emitToolCall(opts) {
746
+ logEvent("tool.call", {
747
+ tool: opts.tool,
748
+ ...opts.clientId ? { clientId: opts.clientId } : {},
749
+ argFields: opts.argFields,
750
+ durationMs: Date.now() - opts.startedAt,
751
+ outcome: opts.outcome,
752
+ ...opts.taskAugmented ? { taskAugmented: true } : {}
753
+ });
754
+ }
400
755
  function wrapAsText(result) {
401
756
  return {
402
757
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
@@ -404,10 +759,93 @@ function wrapAsText(result) {
404
759
  }
405
760
  function registerTool(server2, name, description, schema, handler) {
406
761
  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
- });
762
+ const annotations = inferAnnotations(name);
763
+ registerWithSchema(
764
+ name,
765
+ { description, inputSchema: schema, ...annotations ? { annotations } : {} },
766
+ async (input) => {
767
+ const startedAt = Date.now();
768
+ const argFields = argFieldNames(input);
769
+ const clientId = getRequestContext()?.clientId;
770
+ try {
771
+ const result = await handler(input);
772
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
773
+ return wrapAsText(result);
774
+ } catch (err) {
775
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
776
+ throw err;
777
+ }
778
+ }
779
+ );
780
+ }
781
+ function registerToolTask(server2, name, description, schema, handler) {
782
+ const registerWithSchema = server2.experimental.tasks.registerToolTask.bind(
783
+ server2.experimental.tasks
784
+ );
785
+ const annotations = inferAnnotations(name);
786
+ registerWithSchema(
787
+ name,
788
+ {
789
+ description,
790
+ inputSchema: schema,
791
+ execution: { taskSupport: "optional" },
792
+ ...annotations ? { annotations } : {}
793
+ },
794
+ {
795
+ createTask: async (input, extra) => {
796
+ const task = await extra.taskStore.createTask({
797
+ ttl: extra.taskRequestedTtl
798
+ });
799
+ const abortController = new AbortController();
800
+ registerAbortController(task.taskId, abortController);
801
+ const requestClientId = getRequestContext()?.clientId;
802
+ const argFields = argFieldNames(input);
803
+ void (async () => {
804
+ if (abortController.signal.aborted) return;
805
+ try {
806
+ await extra.taskStore.updateTaskStatus(task.taskId, "working");
807
+ } catch {
808
+ }
809
+ const handlerStart = Date.now();
810
+ let payload;
811
+ let outcome = "success";
812
+ try {
813
+ const result = await handler(input, {
814
+ signal: abortController.signal
815
+ });
816
+ payload = wrapAsText(result);
817
+ } catch (err) {
818
+ if (abortController.signal.aborted) return;
819
+ outcome = "error";
820
+ const message = err instanceof Error ? err.message : String(err);
821
+ payload = {
822
+ content: [{ type: "text", text: message }],
823
+ isError: true
824
+ };
825
+ }
826
+ emitToolCall({
827
+ tool: name,
828
+ clientId: requestClientId,
829
+ argFields,
830
+ startedAt: handlerStart,
831
+ outcome,
832
+ taskAugmented: true
833
+ });
834
+ if (abortController.signal.aborted) return;
835
+ try {
836
+ await extra.taskStore.storeTaskResult(task.taskId, "completed", payload);
837
+ } catch {
838
+ }
839
+ })();
840
+ return { task };
841
+ },
842
+ getTask: async (_input, extra) => extra.taskStore.getTask(extra.taskId),
843
+ getTaskResult: async (_input, extra) => {
844
+ const r = await extra.taskStore.getTaskResult(extra.taskId);
845
+ return r;
846
+ }
847
+ }
848
+ );
411
849
  }
412
850
 
413
851
  // src/tools/parties.ts
@@ -424,6 +862,98 @@ function confirmFlag() {
424
862
  return z.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
425
863
  }
426
864
 
865
+ // src/capsule/batch.ts
866
+ function chunk(arr, size) {
867
+ if (size <= 0) throw new Error("chunk size must be positive");
868
+ const out = [];
869
+ for (let i = 0; i < arr.length; i += size) {
870
+ out.push(arr.slice(i, i + size));
871
+ }
872
+ return out;
873
+ }
874
+ var DEFAULT_CONCURRENCY = 5;
875
+ var MAX_CONCURRENCY = 50;
876
+ function getBatchConcurrency() {
877
+ return Math.min(
878
+ readPositiveInt("CAPSULE_MCP_BATCH_CONCURRENCY", DEFAULT_CONCURRENCY),
879
+ MAX_CONCURRENCY
880
+ );
881
+ }
882
+ async function batchExecute(tool, items, action, options = {}) {
883
+ const concurrency = getBatchConcurrency();
884
+ const results = new Array(items.length);
885
+ const startedAt = Date.now();
886
+ const signal = options.signal;
887
+ let cursor = 0;
888
+ async function worker() {
889
+ while (true) {
890
+ const i = cursor;
891
+ cursor += 1;
892
+ if (i >= items.length) return;
893
+ if (signal?.aborted) {
894
+ results[i] = {
895
+ ok: false,
896
+ error: { message: "cancelled by tasks/cancel" }
897
+ };
898
+ continue;
899
+ }
900
+ try {
901
+ const result = await action(items[i], i);
902
+ results[i] = { ok: true, result };
903
+ } catch (err) {
904
+ results[i] = { ok: false, error: extractError(err) };
905
+ }
906
+ }
907
+ }
908
+ const workers = [];
909
+ for (let w = 0; w < Math.min(concurrency, items.length); w++) {
910
+ workers.push(worker());
911
+ }
912
+ await Promise.all(workers);
913
+ const succeeded = results.filter((r) => r.ok).length;
914
+ const failed = results.length - succeeded;
915
+ const summary = { total: results.length, succeeded, failed };
916
+ const failureReasons = logVerbose() ? topFailureReasons(results, 5) : [];
917
+ logEvent(
918
+ "batch.complete",
919
+ {
920
+ tool,
921
+ total: summary.total,
922
+ succeeded: summary.succeeded,
923
+ failed: summary.failed,
924
+ durationMs: Date.now() - startedAt,
925
+ concurrency,
926
+ ...failureReasons.length > 0 ? { failureReasons } : {}
927
+ },
928
+ { force: true }
929
+ );
930
+ return { results, summary };
931
+ }
932
+ function extractError(err) {
933
+ if (err instanceof Error) {
934
+ const maybeStatus = err.status;
935
+ return {
936
+ ...typeof maybeStatus === "number" ? { status: maybeStatus } : {},
937
+ message: err.message
938
+ };
939
+ }
940
+ return { message: String(err) };
941
+ }
942
+ function topFailureReasons(results, n) {
943
+ const counts = /* @__PURE__ */ new Map();
944
+ for (const r of results) {
945
+ if (r.ok) continue;
946
+ const key = `${r.error.status ?? "?"}::${r.error.message}`;
947
+ const existing = counts.get(key);
948
+ if (existing) {
949
+ existing.count += 1;
950
+ } else {
951
+ counts.set(key, { ...r.error, count: 1 });
952
+ }
953
+ }
954
+ return Array.from(counts.values()).sort((a, b) => b.count - a.count).slice(0, n);
955
+ }
956
+
427
957
  // src/capsule/idempotent.ts
428
958
  var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
429
959
  var isCapsuleTagNotFound = (err) => err instanceof CapsuleApiError && err.status === 422 && /tag not found/i.test(err.message);
@@ -562,14 +1092,26 @@ async function getParty(input) {
562
1092
  return data;
563
1093
  }
564
1094
  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."),
1095
+ ids: z3.array(z3.number().int().positive()).min(1).max(50).describe(
1096
+ "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."
1097
+ ),
566
1098
  embed: z3.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
567
1099
  });
568
1100
  async function getParties(input) {
569
- const { data } = await capsuleGet(`/parties/${input.ids.join(",")}`, {
570
- embed: input.embed
571
- });
572
- return data;
1101
+ const { ids, embed } = input;
1102
+ if (ids.length <= 10) {
1103
+ const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
1104
+ embed
1105
+ });
1106
+ return data;
1107
+ }
1108
+ const chunks = chunk(ids, 10);
1109
+ const responses = await Promise.all(
1110
+ chunks.map(
1111
+ (chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
1112
+ )
1113
+ );
1114
+ return { parties: responses.flatMap((r) => r.data.parties) };
573
1115
  }
574
1116
  var listPartyOpportunitiesSchema = z3.object({
575
1117
  partyId: z3.number().int().positive(),
@@ -653,6 +1195,14 @@ async function updateParty(input) {
653
1195
  if (mappedFields !== void 0) body["fields"] = mappedFields;
654
1196
  return capsulePut(`/parties/${id}`, { party: body });
655
1197
  }
1198
+ var batchUpdatePartySchema = z3.object({
1199
+ items: z3.array(updatePartySchema).min(1).max(50).describe(
1200
+ "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)."
1201
+ )
1202
+ });
1203
+ async function batchUpdateParty(input, opts = {}) {
1204
+ return batchExecute("batch_update_party", input.items, (item) => updateParty(item), opts);
1205
+ }
656
1206
  var deletePartySchema = z3.object({
657
1207
  id: z3.number().int().positive(),
658
1208
  confirm: confirmFlag().describe(
@@ -855,15 +1405,29 @@ async function getOpportunity(input) {
855
1405
  return data;
856
1406
  }
857
1407
  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."),
1408
+ ids: z4.array(z4.number().int().positive()).min(1).max(50).describe(
1409
+ "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."
1410
+ ),
859
1411
  embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
860
1412
  });
861
1413
  async function getOpportunities(input) {
862
- const { data } = await capsuleGet(
863
- `/opportunities/${input.ids.join(",")}`,
864
- { embed: input.embed }
1414
+ const { ids, embed } = input;
1415
+ if (ids.length <= 10) {
1416
+ const { data } = await capsuleGet(
1417
+ `/opportunities/${ids.join(",")}`,
1418
+ { embed }
1419
+ );
1420
+ return data;
1421
+ }
1422
+ const chunks = chunk(ids, 10);
1423
+ const responses = await Promise.all(
1424
+ chunks.map(
1425
+ (chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
1426
+ embed
1427
+ })
1428
+ )
865
1429
  );
866
- return data;
1430
+ return { opportunities: responses.flatMap((r) => r.data.opportunities) };
867
1431
  }
868
1432
  var createOpportunitySchema = z4.object({
869
1433
  name: z4.string().min(1),
@@ -924,6 +1488,19 @@ async function updateOpportunity(input) {
924
1488
  opportunity: body
925
1489
  });
926
1490
  }
1491
+ var batchUpdateOpportunitySchema = z4.object({
1492
+ items: z4.array(updateOpportunitySchema).min(1).max(50).describe(
1493
+ "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."
1494
+ )
1495
+ });
1496
+ async function batchUpdateOpportunity(input, opts = {}) {
1497
+ return batchExecute(
1498
+ "batch_update_opportunity",
1499
+ input.items,
1500
+ (item) => updateOpportunity(item),
1501
+ opts
1502
+ );
1503
+ }
927
1504
  var deleteOpportunitySchema = z4.object({
928
1505
  id: z4.number().int().positive(),
929
1506
  confirm: confirmFlag().describe(
@@ -969,14 +1546,26 @@ async function getProject(input) {
969
1546
  return data;
970
1547
  }
971
1548
  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."),
1549
+ ids: z5.array(z5.number().int().positive()).min(1).max(50).describe(
1550
+ "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."
1551
+ ),
973
1552
  embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
974
1553
  });
975
1554
  async function getProjects(input) {
976
- const { data } = await capsuleGet(`/kases/${input.ids.join(",")}`, {
977
- embed: input.embed
978
- });
979
- return data;
1555
+ const { ids, embed } = input;
1556
+ if (ids.length <= 10) {
1557
+ const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
1558
+ embed
1559
+ });
1560
+ return data;
1561
+ }
1562
+ const chunks = chunk(ids, 10);
1563
+ const responses = await Promise.all(
1564
+ chunks.map(
1565
+ (chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
1566
+ )
1567
+ );
1568
+ return { kases: responses.flatMap((r) => r.data.kases) };
980
1569
  }
981
1570
  var createProjectSchema = z5.object({
982
1571
  name: z5.string().min(1),
@@ -1104,11 +1693,21 @@ async function getTask(input) {
1104
1693
  return data;
1105
1694
  }
1106
1695
  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.")
1696
+ ids: z6.array(z6.number().int().positive()).min(1).max(50).describe(
1697
+ "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."
1698
+ )
1108
1699
  });
1109
1700
  async function getTasks(input) {
1110
- const { data } = await capsuleGet(`/tasks/${input.ids.join(",")}`);
1111
- return data;
1701
+ const { ids } = input;
1702
+ if (ids.length <= 10) {
1703
+ const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
1704
+ return data;
1705
+ }
1706
+ const chunks = chunk(ids, 10);
1707
+ const responses = await Promise.all(
1708
+ chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
1709
+ );
1710
+ return { tasks: responses.flatMap((r) => r.data.tasks) };
1112
1711
  }
1113
1712
  var createTaskSchema = z6.object({
1114
1713
  description: z6.string().min(1),
@@ -1168,6 +1767,14 @@ async function completeTask(input) {
1168
1767
  task: { status: "COMPLETED" }
1169
1768
  });
1170
1769
  }
1770
+ var batchCompleteTaskSchema = z6.object({
1771
+ ids: z6.array(z6.number().int().positive()).min(1).max(50).describe(
1772
+ "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."
1773
+ )
1774
+ });
1775
+ async function batchCompleteTask(input, opts = {}) {
1776
+ return batchExecute("batch_complete_task", input.ids, (id) => completeTask({ id }), opts);
1777
+ }
1171
1778
  var deleteTaskSchema = z6.object({
1172
1779
  id: z6.number().int().positive(),
1173
1780
  confirm: confirmFlag().describe(
@@ -1314,7 +1921,7 @@ var paginationFields = {
1314
1921
  };
1315
1922
  var listPipelinesSchema = z8.object({ ...paginationFields });
1316
1923
  async function listPipelines(input) {
1317
- const { data, nextPage } = await capsuleGet("/pipelines", {
1924
+ const { data, nextPage } = await capsuleGetCached("/pipelines", {
1318
1925
  page: input.page ?? 1,
1319
1926
  perPage: input.perPage ?? 100
1320
1927
  });
@@ -1325,7 +1932,7 @@ var listMilestonesSchema = z8.object({
1325
1932
  ...paginationFields
1326
1933
  });
1327
1934
  async function listMilestones(input) {
1328
- const { data, nextPage } = await capsuleGet(
1935
+ const { data, nextPage } = await capsuleGetCached(
1329
1936
  `/pipelines/${input.pipelineId}/milestones`,
1330
1937
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
1331
1938
  );
@@ -1340,7 +1947,7 @@ var paginationFields2 = {
1340
1947
  };
1341
1948
  var listBoardsSchema = z9.object({ ...paginationFields2 });
1342
1949
  async function listBoards(input) {
1343
- const { data, nextPage } = await capsuleGet("/boards", {
1950
+ const { data, nextPage } = await capsuleGetCached("/boards", {
1344
1951
  page: input.page ?? 1,
1345
1952
  perPage: input.perPage ?? 100
1346
1953
  });
@@ -1354,7 +1961,7 @@ var listStagesSchema = z9.object({
1354
1961
  });
1355
1962
  async function listStages(input) {
1356
1963
  const path = input.boardId !== void 0 ? `/boards/${input.boardId}/stages` : "/stages";
1357
- const { data, nextPage } = await capsuleGet(path, {
1964
+ const { data, nextPage } = await capsuleGetCached(path, {
1358
1965
  page: input.page ?? 1,
1359
1966
  perPage: input.perPage ?? 100
1360
1967
  });
@@ -1381,7 +1988,7 @@ var listTagsSchema = z10.object({
1381
1988
  });
1382
1989
  async function listTags(input) {
1383
1990
  const path = TAG_LIST_PATH[input.entity];
1384
- const { data, nextPage } = await capsuleGet(path, {
1991
+ const { data, nextPage } = await capsuleGetCached(path, {
1385
1992
  page: input.page ?? 1,
1386
1993
  perPage: input.perPage ?? 100
1387
1994
  });
@@ -1397,9 +2004,11 @@ var addTagSchema = z10.object({
1397
2004
  async function addTag(input) {
1398
2005
  const { entity, entityId, tagName } = input;
1399
2006
  const wrapper = ENTITY_TO_WRAPPER[entity];
1400
- return capsulePut(`/${entity}/${entityId}`, {
2007
+ const result = await capsulePut(`/${entity}/${entityId}`, {
1401
2008
  [wrapper]: { tags: [{ name: tagName }] }
1402
2009
  });
2010
+ invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
2011
+ return result;
1403
2012
  }
1404
2013
  var removeTagByIdSchema = z10.object({
1405
2014
  entity: TagEntity,
@@ -1411,17 +2020,17 @@ var removeTagByIdSchema = z10.object({
1411
2020
  async function removeTagById(input) {
1412
2021
  const { entity, entityId, tagId } = input;
1413
2022
  const wrapper = ENTITY_TO_WRAPPER[entity];
1414
- return idempotentWithResult(
2023
+ const result = await idempotentWithResult(
1415
2024
  () => capsulePut(`/${entity}/${entityId}`, {
1416
2025
  [wrapper]: { tags: [{ id: tagId, _delete: true }] }
1417
2026
  }),
1418
- (result) => ({
2027
+ (result2) => ({
1419
2028
  removed: true,
1420
2029
  alreadyRemoved: false,
1421
2030
  entity,
1422
2031
  entityId,
1423
2032
  tagId,
1424
- ...result
2033
+ ...result2
1425
2034
  }),
1426
2035
  () => ({ removed: true, alreadyRemoved: true, entity, entityId, tagId }),
1427
2036
  // Tag detach uses PUT with _delete: true and 422s with "tag not
@@ -1429,6 +2038,24 @@ async function removeTagById(input) {
1429
2038
  // 404. Other 422s with different wording still surface.
1430
2039
  isCapsuleTagNotFound
1431
2040
  );
2041
+ invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
2042
+ return result;
2043
+ }
2044
+ var batchAddTagSchema = z10.object({
2045
+ items: z10.array(addTagSchema).min(1).max(50).describe(
2046
+ "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."
2047
+ )
2048
+ });
2049
+ async function batchAddTag(input, opts = {}) {
2050
+ return batchExecute("batch_add_tag", input.items, (item) => addTag(item), opts);
2051
+ }
2052
+ var batchRemoveTagByIdSchema = z10.object({
2053
+ items: z10.array(removeTagByIdSchema).min(1).max(50).describe(
2054
+ "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."
2055
+ )
2056
+ });
2057
+ async function batchRemoveTagById(input, opts = {}) {
2058
+ return batchExecute("batch_remove_tag_by_id", input.items, (item) => removeTagById(item), opts);
1432
2059
  }
1433
2060
 
1434
2061
  // src/tools/users.ts
@@ -1438,7 +2065,7 @@ var listUsersSchema = z11.object({
1438
2065
  perPage: z11.number().int().min(1).max(100).optional()
1439
2066
  });
1440
2067
  async function listUsers(input) {
1441
- const { data, nextPage } = await capsuleGet("/users", {
2068
+ const { data, nextPage } = await capsuleGetCached("/users", {
1442
2069
  page: input.page ?? 1,
1443
2070
  perPage: input.perPage ?? 100
1444
2071
  });
@@ -1507,7 +2134,7 @@ var paginationFields3 = {
1507
2134
  };
1508
2135
  var listTeamsSchema = z13.object({ ...paginationFields3 });
1509
2136
  async function listTeams(input) {
1510
- const { data, nextPage } = await capsuleGet("/teams", {
2137
+ const { data, nextPage } = await capsuleGetCached("/teams", {
1511
2138
  page: input.page ?? 1,
1512
2139
  perPage: input.perPage ?? 100
1513
2140
  });
@@ -1515,7 +2142,7 @@ async function listTeams(input) {
1515
2142
  }
1516
2143
  var listLostReasonsSchema = z13.object({ ...paginationFields3 });
1517
2144
  async function listLostReasons(input) {
1518
- const { data, nextPage } = await capsuleGet("/lostreasons", {
2145
+ const { data, nextPage } = await capsuleGetCached("/lostreasons", {
1519
2146
  page: input.page ?? 1,
1520
2147
  perPage: input.perPage ?? 100
1521
2148
  });
@@ -1523,20 +2150,23 @@ async function listLostReasons(input) {
1523
2150
  }
1524
2151
  var listActivityTypesSchema = z13.object({ ...paginationFields3 });
1525
2152
  async function listActivityTypes(input) {
1526
- const { data, nextPage } = await capsuleGet("/activitytypes", {
1527
- page: input.page ?? 1,
1528
- perPage: input.perPage ?? 100
1529
- });
2153
+ const { data, nextPage } = await capsuleGetCached(
2154
+ "/activitytypes",
2155
+ {
2156
+ page: input.page ?? 1,
2157
+ perPage: input.perPage ?? 100
2158
+ }
2159
+ );
1530
2160
  return { ...data, nextPage };
1531
2161
  }
1532
2162
  var getSiteSchema = z13.object({});
1533
2163
  async function getSite(_input) {
1534
- const { data } = await capsuleGet("/site");
2164
+ const { data } = await capsuleGetCached("/site");
1535
2165
  return data;
1536
2166
  }
1537
2167
  var listTrackDefinitionsSchema = z13.object({ ...paginationFields3 });
1538
2168
  async function listTrackDefinitions(input) {
1539
- const { data, nextPage } = await capsuleGet(
2169
+ const { data, nextPage } = await capsuleGetCached(
1540
2170
  "/trackdefinitions",
1541
2171
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
1542
2172
  );
@@ -1544,7 +2174,7 @@ async function listTrackDefinitions(input) {
1544
2174
  }
1545
2175
  var listCategoriesSchema = z13.object({ ...paginationFields3 });
1546
2176
  async function listCategories(input) {
1547
- const { data, nextPage } = await capsuleGet("/categories", {
2177
+ const { data, nextPage } = await capsuleGetCached("/categories", {
1548
2178
  page: input.page ?? 1,
1549
2179
  perPage: input.perPage ?? 100
1550
2180
  });
@@ -1552,7 +2182,7 @@ async function listCategories(input) {
1552
2182
  }
1553
2183
  var listGoalsSchema = z13.object({ ...paginationFields3 });
1554
2184
  async function listGoals(input) {
1555
- const { data, nextPage } = await capsuleGet("/goals", {
2185
+ const { data, nextPage } = await capsuleGetCached("/goals", {
1556
2186
  page: input.page ?? 1,
1557
2187
  perPage: input.perPage ?? 100
1558
2188
  });
@@ -1711,7 +2341,7 @@ var listCustomFieldsSchema = z16.object({
1711
2341
  entity: CustomFieldEntity
1712
2342
  });
1713
2343
  async function listCustomFields(input) {
1714
- const { data } = await capsuleGet(
2344
+ const { data } = await capsuleGetCached(
1715
2345
  `/${input.entity}/fields/definitions`
1716
2346
  );
1717
2347
  return data;
@@ -1721,7 +2351,7 @@ var getCustomFieldSchema = z16.object({
1721
2351
  fieldId: z16.number().int().positive().describe("Custom field definition id.")
1722
2352
  });
1723
2353
  async function getCustomField(input) {
1724
- const { data } = await capsuleGet(
2354
+ const { data } = await capsuleGetCached(
1725
2355
  `/${input.entity}/fields/definitions/${input.fieldId}`
1726
2356
  );
1727
2357
  return data;
@@ -1892,7 +2522,7 @@ var listSavedFiltersSchema = z19.object({
1892
2522
  entity: EntitySchema
1893
2523
  });
1894
2524
  async function listSavedFilters(input) {
1895
- const { data } = await capsuleGet(`/${input.entity}/filters`);
2525
+ const { data } = await capsuleGetCached(`/${input.entity}/filters`);
1896
2526
  return data;
1897
2527
  }
1898
2528
  var runSavedFilterSchema = z19.object({
@@ -1911,15 +2541,35 @@ async function runSavedFilter(input) {
1911
2541
  }
1912
2542
 
1913
2543
  // src/server.ts
1914
- function createCapsuleMcpServer() {
2544
+ function createCapsuleMcpServer(opts) {
1915
2545
  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
- });
2546
+ const tasksCfg = getTasksConfig();
2547
+ const tasksWired = tasksCfg.enabled && !!opts?.clientId;
2548
+ const server2 = new McpServer(
2549
+ {
2550
+ name: "capsulemcp",
2551
+ version: "1.6.0",
2552
+ description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
2553
+ websiteUrl: "https://github.com/soil-dev/capsulemcp",
2554
+ icons: ICONS
2555
+ },
2556
+ tasksWired ? {
2557
+ // tasksWired guards clientId presence; narrow explicitly
2558
+ // for the type-checker rather than using `!`.
2559
+ taskStore: createScopedTaskStore(opts?.clientId ?? ""),
2560
+ capabilities: {
2561
+ tasks: {
2562
+ // The SDK's task capability schema uses {} for "present"
2563
+ // markers, not booleans — see ServerTasksCapabilitySchema
2564
+ // in @modelcontextprotocol/sdk types.ts.
2565
+ list: {},
2566
+ cancel: {},
2567
+ requests: { tools: { call: {} } }
2568
+ }
2569
+ }
2570
+ } : void 0
2571
+ );
2572
+ const registerBatchTool = tasksWired ? registerToolTask : (s, name, description, schema, handler) => registerTool(s, name, description, schema, (input) => handler(input, {}));
1923
2573
  registerTool(
1924
2574
  server2,
1925
2575
  "search_parties",
@@ -1937,14 +2587,14 @@ function createCapsuleMcpServer() {
1937
2587
  registerTool(
1938
2588
  server2,
1939
2589
  "get_party",
1940
- "Fetch a single party (person or organisation) by its numeric ID.",
2590
+ "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
2591
  getPartySchema,
1942
2592
  getParty
1943
2593
  );
1944
2594
  registerTool(
1945
2595
  server2,
1946
2596
  "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.",
2597
+ "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
2598
  getPartiesSchema,
1949
2599
  getParties
1950
2600
  );
@@ -2005,6 +2655,13 @@ function createCapsuleMcpServer() {
2005
2655
  updatePartySchema,
2006
2656
  updateParty
2007
2657
  );
2658
+ registerBatchTool(
2659
+ server2,
2660
+ "batch_update_party",
2661
+ "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.",
2662
+ batchUpdatePartySchema,
2663
+ batchUpdateParty
2664
+ );
2008
2665
  registerTool(
2009
2666
  server2,
2010
2667
  "delete_party",
@@ -2086,14 +2743,14 @@ function createCapsuleMcpServer() {
2086
2743
  registerTool(
2087
2744
  server2,
2088
2745
  "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.",
2746
+ "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
2747
  getOpportunitySchema,
2091
2748
  getOpportunity
2092
2749
  );
2093
2750
  registerTool(
2094
2751
  server2,
2095
2752
  "get_opportunities",
2096
- "Batch-fetch up to 10 opportunities by ID in a single call.",
2753
+ "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
2754
  getOpportunitiesSchema,
2098
2755
  getOpportunities
2099
2756
  );
@@ -2133,6 +2790,13 @@ function createCapsuleMcpServer() {
2133
2790
  updateOpportunitySchema,
2134
2791
  updateOpportunity
2135
2792
  );
2793
+ registerBatchTool(
2794
+ server2,
2795
+ "batch_update_opportunity",
2796
+ "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.",
2797
+ batchUpdateOpportunitySchema,
2798
+ batchUpdateOpportunity
2799
+ );
2136
2800
  registerTool(
2137
2801
  server2,
2138
2802
  "delete_opportunity",
@@ -2158,14 +2822,14 @@ function createCapsuleMcpServer() {
2158
2822
  registerTool(
2159
2823
  server2,
2160
2824
  "get_project",
2161
- "Fetch a single project (case) by its numeric ID.",
2825
+ "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
2826
  getProjectSchema,
2163
2827
  getProject
2164
2828
  );
2165
2829
  registerTool(
2166
2830
  server2,
2167
2831
  "get_projects",
2168
- "Batch-fetch up to 10 projects (cases) by ID in a single call.",
2832
+ "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
2833
  getProjectsSchema,
2170
2834
  getProjects
2171
2835
  );
@@ -2244,14 +2908,14 @@ function createCapsuleMcpServer() {
2244
2908
  registerTool(
2245
2909
  server2,
2246
2910
  "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.",
2911
+ "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
2912
  getTaskSchema,
2249
2913
  getTask
2250
2914
  );
2251
2915
  registerTool(
2252
2916
  server2,
2253
2917
  "get_tasks",
2254
- "Batch-fetch up to 10 tasks by ID in a single call.",
2918
+ "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
2919
  getTasksSchema,
2256
2920
  getTasks
2257
2921
  );
@@ -2266,7 +2930,7 @@ function createCapsuleMcpServer() {
2266
2930
  registerTool(
2267
2931
  server2,
2268
2932
  "update_task",
2269
- "Update fields on an existing task. Only the fields you provide are changed. To mark a task done prefer complete_task.",
2933
+ "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
2934
  updateTaskSchema,
2271
2935
  updateTask
2272
2936
  );
@@ -2277,6 +2941,13 @@ function createCapsuleMcpServer() {
2277
2941
  completeTaskSchema,
2278
2942
  completeTask
2279
2943
  );
2944
+ registerBatchTool(
2945
+ server2,
2946
+ "batch_complete_task",
2947
+ "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.",
2948
+ batchCompleteTaskSchema,
2949
+ batchCompleteTask
2950
+ );
2280
2951
  registerTool(
2281
2952
  server2,
2282
2953
  "delete_task",
@@ -2288,7 +2959,7 @@ function createCapsuleMcpServer() {
2288
2959
  registerTool(
2289
2960
  server2,
2290
2961
  "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.",
2962
+ "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
2963
  listPartyEntriesSchema,
2293
2964
  listPartyEntries
2294
2965
  );
@@ -2309,7 +2980,7 @@ function createCapsuleMcpServer() {
2309
2980
  registerTool(
2310
2981
  server2,
2311
2982
  "get_entry",
2312
- "Fetch a single timeline entry by its numeric ID. Returns full content (note body, email subject + body, etc.).",
2983
+ "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
2984
  getEntrySchema,
2314
2985
  getEntry
2315
2986
  );
@@ -2324,6 +2995,10 @@ function createCapsuleMcpServer() {
2324
2995
  "get_attachment",
2325
2996
  "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
2997
  getAttachmentSchema.shape,
2998
+ // get_attachment is read-only — downloads a binary, never mutates.
2999
+ // Mirrors the auto-inferred `readOnlyHint: true` that
3000
+ // `registerTool` applies to every other `get_*` tool.
3001
+ { readOnlyHint: true },
2327
3002
  async (input) => {
2328
3003
  const result = await getAttachment(input);
2329
3004
  if (result.truncated) {
@@ -2451,7 +3126,7 @@ function createCapsuleMcpServer() {
2451
3126
  registerTool(
2452
3127
  server2,
2453
3128
  "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.",
3129
+ "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
3130
  listStagesSchema,
2456
3131
  listStages
2457
3132
  );
@@ -2465,7 +3140,7 @@ function createCapsuleMcpServer() {
2465
3140
  registerTool(
2466
3141
  server2,
2467
3142
  "list_lostreasons",
2468
- "List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor'). Useful for analysing closed-lost opportunities by reason.",
3143
+ "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
3144
  listLostReasonsSchema,
2470
3145
  listLostReasons
2471
3146
  );
@@ -2479,7 +3154,7 @@ function createCapsuleMcpServer() {
2479
3154
  registerTool(
2480
3155
  server2,
2481
3156
  "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.",
3157
+ "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
3158
  listCategoriesSchema,
2484
3159
  listCategories
2485
3160
  );
@@ -2554,6 +3229,20 @@ function createCapsuleMcpServer() {
2554
3229
  removeTagByIdSchema,
2555
3230
  removeTagById
2556
3231
  );
3232
+ registerBatchTool(
3233
+ server2,
3234
+ "batch_add_tag",
3235
+ "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.",
3236
+ batchAddTagSchema,
3237
+ batchAddTag
3238
+ );
3239
+ registerBatchTool(
3240
+ server2,
3241
+ "batch_remove_tag_by_id",
3242
+ "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} }.",
3243
+ batchRemoveTagByIdSchema,
3244
+ batchRemoveTagById
3245
+ );
2557
3246
  }
2558
3247
  registerTool(
2559
3248
  server2,