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/http.js CHANGED
@@ -1,5 +1,146 @@
1
1
  // src/capsule/client.ts
2
2
  import { fetch } from "undici";
3
+
4
+ // src/env.ts
5
+ function readBool(name) {
6
+ const raw = process.env[name]?.toLowerCase();
7
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
8
+ }
9
+ function readPositiveInt(name, fallback, min = 1) {
10
+ const raw = process.env[name];
11
+ if (raw === void 0 || raw === "") return fallback;
12
+ const n = Number(raw);
13
+ if (!Number.isFinite(n) || n < min) return fallback;
14
+ return Math.floor(n);
15
+ }
16
+
17
+ // src/log.ts
18
+ import { AsyncLocalStorage } from "async_hooks";
19
+ function logVerbose() {
20
+ return readBool("CAPSULE_MCP_LOG_VERBOSE");
21
+ }
22
+ var chainHandlers = {
23
+ "tool.call": (ctx, f) => {
24
+ if (typeof f["tool"] === "string") ctx.tools.push(f["tool"]);
25
+ },
26
+ "capsule.request": (ctx) => {
27
+ ctx.capsuleCalls += 1;
28
+ },
29
+ // Cache-hit events feed the aggregate so the chain stat is right
30
+ // even on tools whose Capsule calls all hit the cache.
31
+ "cache.hit": (ctx) => {
32
+ ctx.cacheHits += 1;
33
+ }
34
+ };
35
+ function logEvent(event, fields, opts = {}) {
36
+ const ctx = requestContext.getStore();
37
+ if (ctx) chainHandlers[event]?.(ctx, fields);
38
+ if (!opts.force && !logVerbose()) return;
39
+ process.stderr.write(
40
+ `${JSON.stringify({ event, ...fields, timestamp: (/* @__PURE__ */ new Date()).toISOString() })}
41
+ `
42
+ );
43
+ }
44
+ function redactPath(path) {
45
+ const noQuery = path.split("?")[0] ?? path;
46
+ return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id");
47
+ }
48
+ var requestContext = new AsyncLocalStorage();
49
+ function withRequestContext(initial, fn) {
50
+ const ctx = {
51
+ ...initial,
52
+ tools: [],
53
+ capsuleCalls: 0,
54
+ cacheHits: 0,
55
+ startedAt: Date.now()
56
+ };
57
+ return requestContext.run(ctx, async () => {
58
+ try {
59
+ return await fn();
60
+ } finally {
61
+ logEvent("tool.chain", {
62
+ ...ctx.clientId ? { clientId: ctx.clientId } : {},
63
+ tools: ctx.tools,
64
+ toolCount: ctx.tools.length,
65
+ capsuleCalls: ctx.capsuleCalls,
66
+ cacheHits: ctx.cacheHits,
67
+ durationMs: Date.now() - ctx.startedAt
68
+ });
69
+ }
70
+ });
71
+ }
72
+ function getRequestContext() {
73
+ return requestContext.getStore();
74
+ }
75
+
76
+ // src/capsule/cache.ts
77
+ var cache = /* @__PURE__ */ new Map();
78
+ var MAX_ENTRIES = 64;
79
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
80
+ function getCacheTtlMs() {
81
+ return readPositiveInt("CAPSULE_MCP_CACHE_TTL_MS", DEFAULT_TTL_MS, 0);
82
+ }
83
+ function explicitlyDisabled() {
84
+ return readBool("CAPSULE_MCP_CACHE_DISABLED");
85
+ }
86
+ function cacheDisabled() {
87
+ return explicitlyDisabled() || getCacheTtlMs() === 0;
88
+ }
89
+ function cacheKey(path, params) {
90
+ if (!params) return `GET ${path}`;
91
+ const entries = Object.entries(params).filter(([, v]) => v !== void 0);
92
+ if (entries.length === 0) return `GET ${path}`;
93
+ entries.sort(([a], [b]) => a.localeCompare(b));
94
+ const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
95
+ return `GET ${path}?${qs}`;
96
+ }
97
+ function cacheLookup(key) {
98
+ const entry = cache.get(key);
99
+ if (!entry) return { hit: false, reason: "empty" };
100
+ const now = Date.now();
101
+ if (entry.expiresAt < now) {
102
+ cache.delete(key);
103
+ return { hit: false, reason: "expired" };
104
+ }
105
+ return { hit: true, result: entry.result, ageMs: now - entry.storedAt };
106
+ }
107
+ function cacheSet(key, result) {
108
+ if (cacheDisabled()) return;
109
+ const ttl = getCacheTtlMs();
110
+ while (cache.size >= MAX_ENTRIES) {
111
+ const oldest = cache.keys().next().value;
112
+ if (oldest === void 0) break;
113
+ cache.delete(oldest);
114
+ const evictedKey = `GET ${redactPath(oldest.replace(/^GET /, ""))}`;
115
+ logEvent("cache.evict", { evictedKey, cacheSize: cache.size, reason: "cap" });
116
+ }
117
+ const now = Date.now();
118
+ cache.set(key, {
119
+ result,
120
+ storedAt: now,
121
+ expiresAt: now + ttl
122
+ });
123
+ }
124
+ function invalidateByPrefix(pathPrefix, trigger) {
125
+ const needle = `GET ${pathPrefix}`;
126
+ let droppedCount = 0;
127
+ for (const k of cache.keys()) {
128
+ if (k === needle || k.startsWith(`${needle}?`) || k.startsWith(`${needle}/`)) {
129
+ cache.delete(k);
130
+ droppedCount++;
131
+ }
132
+ }
133
+ if (droppedCount > 0) {
134
+ logEvent("cache.invalidate", {
135
+ prefix: pathPrefix,
136
+ droppedCount,
137
+ cacheSize: cache.size,
138
+ ...trigger ? { trigger } : {}
139
+ });
140
+ }
141
+ }
142
+
143
+ // src/capsule/client.ts
3
144
  var DEFAULT_BASE_URL = "https://api.capsulecrm.com/api/v2";
4
145
  function baseUrl() {
5
146
  const override = process.env["CAPSULE_API_BASE_URL"];
@@ -19,8 +160,7 @@ function baseUrl() {
19
160
  return override;
20
161
  }
21
162
  function isReadOnly() {
22
- const v = process.env["CAPSULE_MCP_READONLY"]?.toLowerCase();
23
- return v === "1" || v === "true" || v === "yes";
163
+ return readBool("CAPSULE_MCP_READONLY");
24
164
  }
25
165
  var CapsuleReadOnlyError = class extends Error {
26
166
  constructor(method) {
@@ -149,6 +289,8 @@ async function fetchWithTimeout(url, options) {
149
289
  }
150
290
  }
151
291
  async function doFetch(url, options) {
292
+ const startedAt = Date.now();
293
+ const method = options?.method ?? "GET";
152
294
  const first = await fetchWithTimeout(url, options);
153
295
  if (first.res.status === 429) {
154
296
  const delay = parseRateLimitDelay(first.res);
@@ -162,9 +304,40 @@ async function doFetch(url, options) {
162
304
  "Rate limit exceeded after one retry. Please slow down your requests."
163
305
  );
164
306
  }
165
- return retried;
307
+ return { ...retried, startedAt, method, url, retriedAfter429: true };
166
308
  }
167
- return first;
309
+ return { ...first, startedAt, method, url, retriedAfter429: false };
310
+ }
311
+ async function consumeBody(start, body) {
312
+ try {
313
+ return await body();
314
+ } finally {
315
+ emitCapsuleRequest(
316
+ start.method,
317
+ start.url,
318
+ start.res,
319
+ Date.now() - start.startedAt,
320
+ start.retriedAfter429
321
+ );
322
+ }
323
+ }
324
+ function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
325
+ let path = "";
326
+ try {
327
+ path = redactPath(new URL(url).pathname);
328
+ } catch {
329
+ path = "?";
330
+ }
331
+ const lenHeader = res.headers.get("content-length");
332
+ const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
333
+ logEvent("capsule.request", {
334
+ method,
335
+ path,
336
+ status: res.status,
337
+ durationMs,
338
+ responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0,
339
+ ...retriedAfter429 ? { retriedAfter429: true } : {}
340
+ });
168
341
  }
169
342
  async function throwForStatus(res) {
170
343
  if (res.status === 401) {
@@ -196,136 +369,173 @@ function buildUrl(path, params) {
196
369
  async function capsuleGet(path, params) {
197
370
  const token = getToken();
198
371
  const url = buildUrl(path, params);
199
- const { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
372
+ const start = await doFetch(url, { headers: baseHeaders(token) });
200
373
  try {
201
- const data = await handleResponse(res);
202
- const nextPage = parseNextPage(res.headers.get("Link"));
203
- return { data, nextPage };
374
+ return await consumeBody(start, async () => {
375
+ const data = await handleResponse(start.res);
376
+ const nextPage = parseNextPage(start.res.headers.get("Link"));
377
+ return { data, nextPage };
378
+ });
204
379
  } finally {
205
- cleanup();
380
+ start.cleanup();
381
+ }
382
+ }
383
+ async function capsuleGetCached(path, params) {
384
+ if (cacheDisabled()) return capsuleGet(path, params);
385
+ const key = cacheKey(path, params);
386
+ const lookup = cacheLookup(key);
387
+ if (lookup.hit) {
388
+ if (logVerbose()) {
389
+ logEvent("cache.hit", {
390
+ path: redactPath(path),
391
+ ...params ? { paramFields: Object.keys(params) } : {},
392
+ ageMs: lookup.ageMs
393
+ });
394
+ }
395
+ return lookup.result;
396
+ }
397
+ const fetchStart = Date.now();
398
+ const result = await capsuleGet(path, params);
399
+ const latencyMs = Date.now() - fetchStart;
400
+ cacheSet(key, result);
401
+ if (logVerbose()) {
402
+ logEvent("cache.miss", {
403
+ path: redactPath(path),
404
+ ...params ? { paramFields: Object.keys(params) } : {},
405
+ reason: lookup.reason,
406
+ latencyMs
407
+ });
206
408
  }
409
+ return result;
207
410
  }
208
411
  async function capsulePost(path, body) {
209
412
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
210
413
  const token = getToken();
211
414
  const url = buildUrl(path);
212
- const { res, cleanup } = await doFetch(url, {
415
+ const start = await doFetch(url, {
213
416
  method: "POST",
214
417
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
215
418
  body: JSON.stringify(body)
216
419
  });
217
420
  try {
218
- return await handleResponse(res);
421
+ return await consumeBody(start, () => handleResponse(start.res));
219
422
  } finally {
220
- cleanup();
423
+ start.cleanup();
221
424
  }
222
425
  }
223
426
  async function capsulePostNoContent(path) {
224
427
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
225
428
  const token = getToken();
226
429
  const url = buildUrl(path);
227
- const { res, cleanup } = await doFetch(url, {
430
+ const start = await doFetch(url, {
228
431
  method: "POST",
229
432
  headers: baseHeaders(token)
230
433
  });
231
434
  try {
232
- if (res.status === 204) return;
233
- await throwForStatus(res);
234
- await mapAbort(res.text());
435
+ await consumeBody(start, async () => {
436
+ if (start.res.status === 204) return;
437
+ await throwForStatus(start.res);
438
+ await mapAbort(start.res.text());
439
+ });
235
440
  } finally {
236
- cleanup();
441
+ start.cleanup();
237
442
  }
238
443
  }
239
444
  async function capsuleSearch(path, body, params) {
240
445
  const token = getToken();
241
446
  const url = buildUrl(path, params);
242
- const { res, cleanup } = await doFetch(url, {
447
+ const start = await doFetch(url, {
243
448
  method: "POST",
244
449
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
245
450
  body: JSON.stringify(body)
246
451
  });
247
452
  try {
248
- const data = await handleResponse(res);
249
- const nextPage = parseNextPage(res.headers.get("Link"));
250
- return { data, nextPage };
453
+ return await consumeBody(start, async () => {
454
+ const data = await handleResponse(start.res);
455
+ const nextPage = parseNextPage(start.res.headers.get("Link"));
456
+ return { data, nextPage };
457
+ });
251
458
  } finally {
252
- cleanup();
459
+ start.cleanup();
253
460
  }
254
461
  }
255
462
  async function capsulePut(path, body) {
256
463
  if (isReadOnly()) throw new CapsuleReadOnlyError("PUT");
257
464
  const token = getToken();
258
465
  const url = buildUrl(path);
259
- const { res, cleanup } = await doFetch(url, {
466
+ const start = await doFetch(url, {
260
467
  method: "PUT",
261
468
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
262
469
  body: JSON.stringify(body)
263
470
  });
264
471
  try {
265
- return await handleResponse(res);
472
+ return await consumeBody(start, () => handleResponse(start.res));
266
473
  } finally {
267
- cleanup();
474
+ start.cleanup();
268
475
  }
269
476
  }
270
477
  async function capsuleGetBinary(path, maxBytes) {
271
478
  const token = getToken();
272
479
  const url = buildUrl(path);
273
- const { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
480
+ const start = await doFetch(url, { headers: baseHeaders(token) });
274
481
  try {
275
- await throwForStatus(res);
276
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
277
- const declared = res.headers.get("Content-Length");
278
- const declaredBytes = declared ? Number(declared) : NaN;
279
- if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
280
- if (res.body) await res.body.cancel().catch(() => {
281
- });
282
- return {
283
- contentType,
284
- buffer: Buffer.alloc(0),
285
- truncated: true,
286
- sizeBytes: declaredBytes
287
- };
288
- }
289
- if (maxBytes !== void 0 && res.body) {
290
- const reader = res.body.getReader();
291
- const chunks = [];
292
- let total = 0;
293
- let truncated = false;
294
- while (true) {
295
- const { done, value } = await mapAbort(reader.read());
296
- if (done) break;
297
- total += value.byteLength;
298
- if (total > maxBytes) {
299
- truncated = true;
300
- await reader.cancel().catch(() => {
301
- });
302
- break;
303
- }
304
- chunks.push(value);
305
- }
306
- if (truncated) {
482
+ return await consumeBody(start, async () => {
483
+ const res = start.res;
484
+ await throwForStatus(res);
485
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
486
+ const declared = res.headers.get("Content-Length");
487
+ const declaredBytes = declared ? Number(declared) : NaN;
488
+ if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
489
+ if (res.body) await res.body.cancel().catch(() => {
490
+ });
307
491
  return {
308
492
  contentType,
309
493
  buffer: Buffer.alloc(0),
310
494
  truncated: true,
311
- sizeBytes: total
495
+ sizeBytes: declaredBytes
312
496
  };
313
497
  }
314
- const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
315
- return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
316
- }
317
- const arrayBuffer = await mapAbort(res.arrayBuffer());
318
- const buffer = Buffer.from(arrayBuffer);
319
- return { contentType, buffer, sizeBytes: buffer.length };
498
+ if (maxBytes !== void 0 && res.body) {
499
+ const reader = res.body.getReader();
500
+ const chunks = [];
501
+ let total = 0;
502
+ let truncated = false;
503
+ while (true) {
504
+ const { done, value } = await mapAbort(reader.read());
505
+ if (done) break;
506
+ total += value.byteLength;
507
+ if (total > maxBytes) {
508
+ truncated = true;
509
+ await reader.cancel().catch(() => {
510
+ });
511
+ break;
512
+ }
513
+ chunks.push(value);
514
+ }
515
+ if (truncated) {
516
+ return {
517
+ contentType,
518
+ buffer: Buffer.alloc(0),
519
+ truncated: true,
520
+ sizeBytes: total
521
+ };
522
+ }
523
+ const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
524
+ return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
525
+ }
526
+ const arrayBuffer = await mapAbort(res.arrayBuffer());
527
+ const buffer = Buffer.from(arrayBuffer);
528
+ return { contentType, buffer, sizeBytes: buffer.length };
529
+ });
320
530
  } finally {
321
- cleanup();
531
+ start.cleanup();
322
532
  }
323
533
  }
324
534
  async function capsulePostBinary(path, body, contentType, filename) {
325
535
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
326
536
  const token = getToken();
327
537
  const url = buildUrl(path);
328
- const { res, cleanup } = await doFetch(url, {
538
+ const start = await doFetch(url, {
329
539
  method: "POST",
330
540
  headers: {
331
541
  ...baseHeaders(token),
@@ -336,25 +546,27 @@ async function capsulePostBinary(path, body, contentType, filename) {
336
546
  body
337
547
  });
338
548
  try {
339
- return await handleResponse(res);
549
+ return await consumeBody(start, () => handleResponse(start.res));
340
550
  } finally {
341
- cleanup();
551
+ start.cleanup();
342
552
  }
343
553
  }
344
554
  async function capsuleDelete(path) {
345
555
  if (isReadOnly()) throw new CapsuleReadOnlyError("DELETE");
346
556
  const token = getToken();
347
557
  const url = buildUrl(path);
348
- const { res, cleanup } = await doFetch(url, {
558
+ const start = await doFetch(url, {
349
559
  method: "DELETE",
350
560
  headers: baseHeaders(token)
351
561
  });
352
562
  try {
353
- if (res.status === 204) return;
354
- await throwForStatus(res);
355
- await mapAbort(res.text());
563
+ await consumeBody(start, async () => {
564
+ if (start.res.status === 204) return;
565
+ await throwForStatus(start.res);
566
+ await mapAbort(start.res.text());
567
+ });
356
568
  } finally {
357
- cleanup();
569
+ start.cleanup();
358
570
  }
359
571
  }
360
572
 
@@ -834,7 +1046,7 @@ function resolveBaseConfig(env = process.env) {
834
1046
  // src/http/app.ts
835
1047
  import { createHash as createHash2, timingSafeEqual as timingSafeEqual3 } from "crypto";
836
1048
  import express from "express";
837
- import { rateLimit } from "express-rate-limit";
1049
+ import { ipKeyGenerator, rateLimit } from "express-rate-limit";
838
1050
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
839
1051
  import {
840
1052
  mcpAuthRouter,
@@ -876,7 +1088,195 @@ var ICONS = [
876
1088
  }
877
1089
  ];
878
1090
 
1091
+ // src/tasks/store.ts
1092
+ import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
1093
+ import {
1094
+ ErrorCode,
1095
+ McpError
1096
+ } from "@modelcontextprotocol/sdk/types.js";
1097
+
1098
+ // src/tasks/config.ts
1099
+ var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1100
+ var DEFAULT_MAX_KEEP_ALIVE_MS = 15 * 60 * 1e3;
1101
+ var MIN_TASK_TTL_MS = 1e3;
1102
+ var DEFAULT_POLL_FREQUENCY_MS = 1500;
1103
+ var MIN_POLL_FREQUENCY_MS = 500;
1104
+ var DEFAULT_MAX_PER_CLIENT = 20;
1105
+ var DEFAULT_MAX_TOTAL = 200;
1106
+ function getTasksConfig() {
1107
+ const enabled = readBool("MCP_TASKS_ENABLED");
1108
+ const maxKeepAliveMs = Math.max(
1109
+ readPositiveInt("MCP_TASKS_MAX_KEEP_ALIVE_MS", DEFAULT_MAX_KEEP_ALIVE_MS),
1110
+ MIN_TASK_TTL_MS
1111
+ );
1112
+ const defaultTtlMs = Math.min(
1113
+ Math.max(readPositiveInt("MCP_TASKS_DEFAULT_TTL_MS", DEFAULT_TTL_MS2), MIN_TASK_TTL_MS),
1114
+ maxKeepAliveMs
1115
+ );
1116
+ const defaultPollFrequencyMs = Math.max(
1117
+ readPositiveInt("MCP_TASKS_DEFAULT_POLL_FREQUENCY_MS", DEFAULT_POLL_FREQUENCY_MS),
1118
+ MIN_POLL_FREQUENCY_MS
1119
+ );
1120
+ const maxPerClient = readPositiveInt("MCP_TASKS_MAX_PER_CLIENT", DEFAULT_MAX_PER_CLIENT);
1121
+ const maxTotal = readPositiveInt("MCP_TASKS_MAX_TOTAL", DEFAULT_MAX_TOTAL);
1122
+ return {
1123
+ enabled,
1124
+ defaultTtlMs,
1125
+ maxKeepAliveMs,
1126
+ defaultPollFrequencyMs,
1127
+ maxPerClient,
1128
+ maxTotal
1129
+ };
1130
+ }
1131
+
1132
+ // src/tasks/store.ts
1133
+ var _globalStore = null;
1134
+ function getGlobalStore() {
1135
+ if (_globalStore === null) {
1136
+ _globalStore = new InMemoryTaskStore();
1137
+ }
1138
+ return _globalStore;
1139
+ }
1140
+ var owners = /* @__PURE__ */ new Map();
1141
+ var abortControllers = /* @__PURE__ */ new Map();
1142
+ function registerAbortController(taskId, controller) {
1143
+ abortControllers.set(taskId, controller);
1144
+ }
1145
+ function countPerClient(clientId) {
1146
+ let n = 0;
1147
+ for (const owner of owners.values()) {
1148
+ if (owner === clientId) n++;
1149
+ }
1150
+ return n;
1151
+ }
1152
+ function createScopedTaskStore(clientId) {
1153
+ if (!clientId) {
1154
+ throw new Error("createScopedTaskStore: clientId is required");
1155
+ }
1156
+ const global = getGlobalStore();
1157
+ async function getOwned(taskId) {
1158
+ if (owners.get(taskId) !== clientId) return null;
1159
+ return global.getTask(taskId);
1160
+ }
1161
+ return {
1162
+ async createTask(taskParams, requestId, request, sessionId) {
1163
+ const cfg = getTasksConfig();
1164
+ const totalNow = owners.size;
1165
+ if (totalNow >= cfg.maxTotal) {
1166
+ logEvent("task.rejected", {
1167
+ reason: "max_total",
1168
+ clientId,
1169
+ totalNow,
1170
+ cap: cfg.maxTotal
1171
+ });
1172
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this server instance");
1173
+ }
1174
+ const perClientNow = countPerClient(clientId);
1175
+ if (perClientNow >= cfg.maxPerClient) {
1176
+ logEvent("task.rejected", {
1177
+ reason: "max_per_client",
1178
+ clientId,
1179
+ perClientNow,
1180
+ cap: cfg.maxPerClient
1181
+ });
1182
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this client");
1183
+ }
1184
+ const requestedTtl = taskParams.ttl;
1185
+ const clampedTtl = requestedTtl === null ? cfg.maxKeepAliveMs : Math.max(
1186
+ MIN_TASK_TTL_MS,
1187
+ Math.min(requestedTtl ?? cfg.defaultTtlMs, cfg.maxKeepAliveMs)
1188
+ );
1189
+ const requestedPoll = taskParams.pollInterval ?? cfg.defaultPollFrequencyMs;
1190
+ const clampedPoll = Math.max(cfg.defaultPollFrequencyMs, Math.floor(requestedPoll));
1191
+ const task = await global.createTask(
1192
+ { ttl: clampedTtl, pollInterval: clampedPoll, context: taskParams.context },
1193
+ requestId,
1194
+ request,
1195
+ sessionId
1196
+ );
1197
+ owners.set(task.taskId, clientId);
1198
+ const timer = setTimeout(() => {
1199
+ owners.delete(task.taskId);
1200
+ abortControllers.delete(task.taskId);
1201
+ logEvent("task.evicted", { taskId: task.taskId, clientId, reason: "ttl" });
1202
+ }, clampedTtl);
1203
+ timer.unref?.();
1204
+ logEvent("task.created", {
1205
+ taskId: task.taskId,
1206
+ clientId,
1207
+ ttl: clampedTtl,
1208
+ pollInterval: clampedPoll,
1209
+ method: typeof request.method === "string" ? request.method : "unknown"
1210
+ });
1211
+ return task;
1212
+ },
1213
+ async getTask(taskId) {
1214
+ return getOwned(taskId);
1215
+ },
1216
+ async storeTaskResult(taskId, status, result, sessionId) {
1217
+ if (owners.get(taskId) !== clientId) {
1218
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
1219
+ }
1220
+ logEvent("task.transition", { taskId, clientId, status });
1221
+ await global.storeTaskResult(taskId, status, result, sessionId);
1222
+ },
1223
+ async getTaskResult(taskId, sessionId) {
1224
+ if (owners.get(taskId) !== clientId) {
1225
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
1226
+ }
1227
+ return global.getTaskResult(taskId, sessionId);
1228
+ },
1229
+ async updateTaskStatus(taskId, status, statusMessage, sessionId) {
1230
+ if (owners.get(taskId) !== clientId) {
1231
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
1232
+ }
1233
+ logEvent("task.transition", { taskId, clientId, status, statusMessage });
1234
+ await global.updateTaskStatus(taskId, status, statusMessage, sessionId);
1235
+ if (status === "cancelled") {
1236
+ const ctrl = abortControllers.get(taskId);
1237
+ if (ctrl && !ctrl.signal.aborted) ctrl.abort();
1238
+ }
1239
+ if (status === "completed" || status === "failed" || status === "cancelled") {
1240
+ abortControllers.delete(taskId);
1241
+ }
1242
+ },
1243
+ async listTasks(cursor, sessionId) {
1244
+ const page = await global.listTasks(cursor, sessionId);
1245
+ const filtered = page.tasks.filter((t) => owners.get(t.taskId) === clientId);
1246
+ return page.nextCursor ? { tasks: filtered, nextCursor: page.nextCursor } : { tasks: filtered };
1247
+ }
1248
+ };
1249
+ }
1250
+
879
1251
  // src/server/register-tool.ts
1252
+ var READ_PREFIXES = ["search_", "filter_", "get_", "list_", "show_", "run_"];
1253
+ var DESTRUCTIVE_NON_DELETE = /* @__PURE__ */ new Set(["remove_track", "remove_additional_party"]);
1254
+ function isDestructive(name) {
1255
+ return name.startsWith("delete_") || DESTRUCTIVE_NON_DELETE.has(name);
1256
+ }
1257
+ function inferAnnotations(name) {
1258
+ if (READ_PREFIXES.some((p) => name.startsWith(p))) {
1259
+ return { readOnlyHint: true };
1260
+ }
1261
+ if (isDestructive(name)) {
1262
+ return { destructiveHint: true };
1263
+ }
1264
+ return void 0;
1265
+ }
1266
+ function argFieldNames(input) {
1267
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
1268
+ return Object.keys(input);
1269
+ }
1270
+ function emitToolCall(opts) {
1271
+ logEvent("tool.call", {
1272
+ tool: opts.tool,
1273
+ ...opts.clientId ? { clientId: opts.clientId } : {},
1274
+ argFields: opts.argFields,
1275
+ durationMs: Date.now() - opts.startedAt,
1276
+ outcome: opts.outcome,
1277
+ ...opts.taskAugmented ? { taskAugmented: true } : {}
1278
+ });
1279
+ }
880
1280
  function wrapAsText(result) {
881
1281
  return {
882
1282
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
@@ -884,10 +1284,93 @@ function wrapAsText(result) {
884
1284
  }
885
1285
  function registerTool(server, name, description, schema, handler) {
886
1286
  const registerWithSchema = server.registerTool.bind(server);
887
- registerWithSchema(name, { description, inputSchema: schema }, async (input) => {
888
- const result = await handler(input);
889
- return wrapAsText(result);
890
- });
1287
+ const annotations = inferAnnotations(name);
1288
+ registerWithSchema(
1289
+ name,
1290
+ { description, inputSchema: schema, ...annotations ? { annotations } : {} },
1291
+ async (input) => {
1292
+ const startedAt = Date.now();
1293
+ const argFields = argFieldNames(input);
1294
+ const clientId = getRequestContext()?.clientId;
1295
+ try {
1296
+ const result = await handler(input);
1297
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
1298
+ return wrapAsText(result);
1299
+ } catch (err) {
1300
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
1301
+ throw err;
1302
+ }
1303
+ }
1304
+ );
1305
+ }
1306
+ function registerToolTask(server, name, description, schema, handler) {
1307
+ const registerWithSchema = server.experimental.tasks.registerToolTask.bind(
1308
+ server.experimental.tasks
1309
+ );
1310
+ const annotations = inferAnnotations(name);
1311
+ registerWithSchema(
1312
+ name,
1313
+ {
1314
+ description,
1315
+ inputSchema: schema,
1316
+ execution: { taskSupport: "optional" },
1317
+ ...annotations ? { annotations } : {}
1318
+ },
1319
+ {
1320
+ createTask: async (input, extra) => {
1321
+ const task = await extra.taskStore.createTask({
1322
+ ttl: extra.taskRequestedTtl
1323
+ });
1324
+ const abortController = new AbortController();
1325
+ registerAbortController(task.taskId, abortController);
1326
+ const requestClientId = getRequestContext()?.clientId;
1327
+ const argFields = argFieldNames(input);
1328
+ void (async () => {
1329
+ if (abortController.signal.aborted) return;
1330
+ try {
1331
+ await extra.taskStore.updateTaskStatus(task.taskId, "working");
1332
+ } catch {
1333
+ }
1334
+ const handlerStart = Date.now();
1335
+ let payload;
1336
+ let outcome = "success";
1337
+ try {
1338
+ const result = await handler(input, {
1339
+ signal: abortController.signal
1340
+ });
1341
+ payload = wrapAsText(result);
1342
+ } catch (err) {
1343
+ if (abortController.signal.aborted) return;
1344
+ outcome = "error";
1345
+ const message = err instanceof Error ? err.message : String(err);
1346
+ payload = {
1347
+ content: [{ type: "text", text: message }],
1348
+ isError: true
1349
+ };
1350
+ }
1351
+ emitToolCall({
1352
+ tool: name,
1353
+ clientId: requestClientId,
1354
+ argFields,
1355
+ startedAt: handlerStart,
1356
+ outcome,
1357
+ taskAugmented: true
1358
+ });
1359
+ if (abortController.signal.aborted) return;
1360
+ try {
1361
+ await extra.taskStore.storeTaskResult(task.taskId, "completed", payload);
1362
+ } catch {
1363
+ }
1364
+ })();
1365
+ return { task };
1366
+ },
1367
+ getTask: async (_input, extra) => extra.taskStore.getTask(extra.taskId),
1368
+ getTaskResult: async (_input, extra) => {
1369
+ const r = await extra.taskStore.getTaskResult(extra.taskId);
1370
+ return r;
1371
+ }
1372
+ }
1373
+ );
891
1374
  }
892
1375
 
893
1376
  // src/tools/parties.ts
@@ -904,6 +1387,98 @@ function confirmFlag() {
904
1387
  return z2.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
905
1388
  }
906
1389
 
1390
+ // src/capsule/batch.ts
1391
+ function chunk(arr, size) {
1392
+ if (size <= 0) throw new Error("chunk size must be positive");
1393
+ const out = [];
1394
+ for (let i = 0; i < arr.length; i += size) {
1395
+ out.push(arr.slice(i, i + size));
1396
+ }
1397
+ return out;
1398
+ }
1399
+ var DEFAULT_CONCURRENCY = 5;
1400
+ var MAX_CONCURRENCY = 50;
1401
+ function getBatchConcurrency() {
1402
+ return Math.min(
1403
+ readPositiveInt("CAPSULE_MCP_BATCH_CONCURRENCY", DEFAULT_CONCURRENCY),
1404
+ MAX_CONCURRENCY
1405
+ );
1406
+ }
1407
+ async function batchExecute(tool, items, action, options = {}) {
1408
+ const concurrency = getBatchConcurrency();
1409
+ const results = new Array(items.length);
1410
+ const startedAt = Date.now();
1411
+ const signal = options.signal;
1412
+ let cursor = 0;
1413
+ async function worker() {
1414
+ while (true) {
1415
+ const i = cursor;
1416
+ cursor += 1;
1417
+ if (i >= items.length) return;
1418
+ if (signal?.aborted) {
1419
+ results[i] = {
1420
+ ok: false,
1421
+ error: { message: "cancelled by tasks/cancel" }
1422
+ };
1423
+ continue;
1424
+ }
1425
+ try {
1426
+ const result = await action(items[i], i);
1427
+ results[i] = { ok: true, result };
1428
+ } catch (err) {
1429
+ results[i] = { ok: false, error: extractError(err) };
1430
+ }
1431
+ }
1432
+ }
1433
+ const workers = [];
1434
+ for (let w = 0; w < Math.min(concurrency, items.length); w++) {
1435
+ workers.push(worker());
1436
+ }
1437
+ await Promise.all(workers);
1438
+ const succeeded = results.filter((r) => r.ok).length;
1439
+ const failed = results.length - succeeded;
1440
+ const summary = { total: results.length, succeeded, failed };
1441
+ const failureReasons = logVerbose() ? topFailureReasons(results, 5) : [];
1442
+ logEvent(
1443
+ "batch.complete",
1444
+ {
1445
+ tool,
1446
+ total: summary.total,
1447
+ succeeded: summary.succeeded,
1448
+ failed: summary.failed,
1449
+ durationMs: Date.now() - startedAt,
1450
+ concurrency,
1451
+ ...failureReasons.length > 0 ? { failureReasons } : {}
1452
+ },
1453
+ { force: true }
1454
+ );
1455
+ return { results, summary };
1456
+ }
1457
+ function extractError(err) {
1458
+ if (err instanceof Error) {
1459
+ const maybeStatus = err.status;
1460
+ return {
1461
+ ...typeof maybeStatus === "number" ? { status: maybeStatus } : {},
1462
+ message: err.message
1463
+ };
1464
+ }
1465
+ return { message: String(err) };
1466
+ }
1467
+ function topFailureReasons(results, n) {
1468
+ const counts = /* @__PURE__ */ new Map();
1469
+ for (const r of results) {
1470
+ if (r.ok) continue;
1471
+ const key = `${r.error.status ?? "?"}::${r.error.message}`;
1472
+ const existing = counts.get(key);
1473
+ if (existing) {
1474
+ existing.count += 1;
1475
+ } else {
1476
+ counts.set(key, { ...r.error, count: 1 });
1477
+ }
1478
+ }
1479
+ return Array.from(counts.values()).sort((a, b) => b.count - a.count).slice(0, n);
1480
+ }
1481
+
907
1482
  // src/capsule/idempotent.ts
908
1483
  var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
909
1484
  var isCapsuleTagNotFound = (err) => err instanceof CapsuleApiError && err.status === 422 && /tag not found/i.test(err.message);
@@ -1042,14 +1617,26 @@ async function getParty(input) {
1042
1617
  return data;
1043
1618
  }
1044
1619
  var getPartiesSchema = z4.object({
1045
- ids: z4.array(z4.number().int().positive()).min(1).max(10).describe("Array of party IDs (1\u201310). Capsule caps batch fetches at 10."),
1620
+ ids: z4.array(z4.number().int().positive()).min(1).max(50).describe(
1621
+ "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."
1622
+ ),
1046
1623
  embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1047
1624
  });
1048
1625
  async function getParties(input) {
1049
- const { data } = await capsuleGet(`/parties/${input.ids.join(",")}`, {
1050
- embed: input.embed
1051
- });
1052
- return data;
1626
+ const { ids, embed } = input;
1627
+ if (ids.length <= 10) {
1628
+ const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
1629
+ embed
1630
+ });
1631
+ return data;
1632
+ }
1633
+ const chunks = chunk(ids, 10);
1634
+ const responses = await Promise.all(
1635
+ chunks.map(
1636
+ (chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
1637
+ )
1638
+ );
1639
+ return { parties: responses.flatMap((r) => r.data.parties) };
1053
1640
  }
1054
1641
  var listPartyOpportunitiesSchema = z4.object({
1055
1642
  partyId: z4.number().int().positive(),
@@ -1133,6 +1720,14 @@ async function updateParty(input) {
1133
1720
  if (mappedFields !== void 0) body["fields"] = mappedFields;
1134
1721
  return capsulePut(`/parties/${id}`, { party: body });
1135
1722
  }
1723
+ var batchUpdatePartySchema = z4.object({
1724
+ items: z4.array(updatePartySchema).min(1).max(50).describe(
1725
+ "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)."
1726
+ )
1727
+ });
1728
+ async function batchUpdateParty(input, opts = {}) {
1729
+ return batchExecute("batch_update_party", input.items, (item) => updateParty(item), opts);
1730
+ }
1136
1731
  var deletePartySchema = z4.object({
1137
1732
  id: z4.number().int().positive(),
1138
1733
  confirm: confirmFlag().describe(
@@ -1335,15 +1930,29 @@ async function getOpportunity(input) {
1335
1930
  return data;
1336
1931
  }
1337
1932
  var getOpportunitiesSchema = z5.object({
1338
- ids: z5.array(z5.number().int().positive()).min(1).max(10).describe("Array of opportunity IDs (1\u201310). Capsule caps batch fetches at 10."),
1933
+ ids: z5.array(z5.number().int().positive()).min(1).max(50).describe(
1934
+ "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."
1935
+ ),
1339
1936
  embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1340
1937
  });
1341
1938
  async function getOpportunities(input) {
1342
- const { data } = await capsuleGet(
1343
- `/opportunities/${input.ids.join(",")}`,
1344
- { embed: input.embed }
1939
+ const { ids, embed } = input;
1940
+ if (ids.length <= 10) {
1941
+ const { data } = await capsuleGet(
1942
+ `/opportunities/${ids.join(",")}`,
1943
+ { embed }
1944
+ );
1945
+ return data;
1946
+ }
1947
+ const chunks = chunk(ids, 10);
1948
+ const responses = await Promise.all(
1949
+ chunks.map(
1950
+ (chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
1951
+ embed
1952
+ })
1953
+ )
1345
1954
  );
1346
- return data;
1955
+ return { opportunities: responses.flatMap((r) => r.data.opportunities) };
1347
1956
  }
1348
1957
  var createOpportunitySchema = z5.object({
1349
1958
  name: z5.string().min(1),
@@ -1357,16 +1966,20 @@ var createOpportunitySchema = z5.object({
1357
1966
  probability: z5.number().int().min(0).max(100).optional(),
1358
1967
  ownerId: z5.number().int().positive().optional().describe(
1359
1968
  "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."
1969
+ ),
1970
+ teamId: z5.number().int().positive().optional().describe(
1971
+ "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)."
1360
1972
  )
1361
1973
  });
1362
1974
  async function createOpportunity(input) {
1363
- const { partyId, milestoneId, ownerId, ...rest } = input;
1975
+ const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
1364
1976
  const body = {
1365
1977
  ...rest,
1366
1978
  party: { id: partyId },
1367
1979
  milestone: { id: milestoneId }
1368
1980
  };
1369
1981
  if (ownerId) body["owner"] = { id: ownerId };
1982
+ if (teamId) body["team"] = { id: teamId };
1370
1983
  return capsulePost("/opportunities", { opportunity: body });
1371
1984
  }
1372
1985
  var updateOpportunitySchema = z5.object({
@@ -1385,18 +1998,28 @@ var updateOpportunitySchema = z5.object({
1385
1998
  "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."
1386
1999
  ),
1387
2000
  ownerId: z5.number().int().positive().optional().describe(
1388
- "Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
2001
+ "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."
2002
+ ),
2003
+ teamId: z5.number().int().positive().nullable().optional().describe(
2004
+ "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."
1389
2005
  ),
1390
2006
  fields: z5.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_opportunity"))
1391
2007
  });
1392
2008
  async function updateOpportunity(input) {
1393
- const { id, milestoneId, ownerId, lostReasonId, fields, ...rest } = input;
2009
+ const { id, milestoneId, ownerId, teamId, lostReasonId, fields, ...rest } = input;
1394
2010
  const body = {};
1395
2011
  for (const [k, v] of Object.entries(rest)) {
1396
2012
  if (v !== void 0) body[k] = v;
1397
2013
  }
1398
2014
  if (milestoneId) body["milestone"] = { id: milestoneId };
2015
+ let resolvedTeamId = teamId;
2016
+ if (ownerId !== void 0 && teamId === void 0) {
2017
+ const { data } = await capsuleGet(`/opportunities/${id}`);
2018
+ resolvedTeamId = data.opportunity?.team?.id ?? void 0;
2019
+ }
1399
2020
  if (ownerId) body["owner"] = { id: ownerId };
2021
+ if (resolvedTeamId === null) body["team"] = null;
2022
+ else if (resolvedTeamId !== void 0) body["team"] = { id: resolvedTeamId };
1400
2023
  if (lostReasonId) body["lostReason"] = { id: lostReasonId };
1401
2024
  const mappedFields = mapFieldsForBody(fields);
1402
2025
  if (mappedFields !== void 0) body["fields"] = mappedFields;
@@ -1404,6 +2027,19 @@ async function updateOpportunity(input) {
1404
2027
  opportunity: body
1405
2028
  });
1406
2029
  }
2030
+ var batchUpdateOpportunitySchema = z5.object({
2031
+ items: z5.array(updateOpportunitySchema).min(1).max(50).describe(
2032
+ "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."
2033
+ )
2034
+ });
2035
+ async function batchUpdateOpportunity(input, opts = {}) {
2036
+ return batchExecute(
2037
+ "batch_update_opportunity",
2038
+ input.items,
2039
+ (item) => updateOpportunity(item),
2040
+ opts
2041
+ );
2042
+ }
1407
2043
  var deleteOpportunitySchema = z5.object({
1408
2044
  id: z5.number().int().positive(),
1409
2045
  confirm: confirmFlag().describe(
@@ -1449,14 +2085,26 @@ async function getProject(input) {
1449
2085
  return data;
1450
2086
  }
1451
2087
  var getProjectsSchema = z6.object({
1452
- ids: z6.array(z6.number().int().positive()).min(1).max(10).describe("Array of project IDs (1\u201310). Capsule caps batch fetches at 10."),
2088
+ ids: z6.array(z6.number().int().positive()).min(1).max(50).describe(
2089
+ "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."
2090
+ ),
1453
2091
  embed: z6.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1454
2092
  });
1455
2093
  async function getProjects(input) {
1456
- const { data } = await capsuleGet(`/kases/${input.ids.join(",")}`, {
1457
- embed: input.embed
1458
- });
1459
- return data;
2094
+ const { ids, embed } = input;
2095
+ if (ids.length <= 10) {
2096
+ const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
2097
+ embed
2098
+ });
2099
+ return data;
2100
+ }
2101
+ const chunks = chunk(ids, 10);
2102
+ const responses = await Promise.all(
2103
+ chunks.map(
2104
+ (chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
2105
+ )
2106
+ );
2107
+ return { kases: responses.flatMap((r) => r.data.kases) };
1460
2108
  }
1461
2109
  var createProjectSchema = z6.object({
1462
2110
  name: z6.string().min(1),
@@ -1584,11 +2232,21 @@ async function getTask(input) {
1584
2232
  return data;
1585
2233
  }
1586
2234
  var getTasksSchema = z7.object({
1587
- ids: z7.array(z7.number().int().positive()).min(1).max(10).describe("Array of task IDs (1\u201310). Capsule caps batch fetches at 10.")
2235
+ ids: z7.array(z7.number().int().positive()).min(1).max(50).describe(
2236
+ "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."
2237
+ )
1588
2238
  });
1589
2239
  async function getTasks(input) {
1590
- const { data } = await capsuleGet(`/tasks/${input.ids.join(",")}`);
1591
- return data;
2240
+ const { ids } = input;
2241
+ if (ids.length <= 10) {
2242
+ const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
2243
+ return data;
2244
+ }
2245
+ const chunks = chunk(ids, 10);
2246
+ const responses = await Promise.all(
2247
+ chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
2248
+ );
2249
+ return { tasks: responses.flatMap((r) => r.data.tasks) };
1592
2250
  }
1593
2251
  var createTaskSchema = z7.object({
1594
2252
  description: z7.string().min(1),
@@ -1648,6 +2306,14 @@ async function completeTask(input) {
1648
2306
  task: { status: "COMPLETED" }
1649
2307
  });
1650
2308
  }
2309
+ var batchCompleteTaskSchema = z7.object({
2310
+ ids: z7.array(z7.number().int().positive()).min(1).max(50).describe(
2311
+ "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."
2312
+ )
2313
+ });
2314
+ async function batchCompleteTask(input, opts = {}) {
2315
+ return batchExecute("batch_complete_task", input.ids, (id) => completeTask({ id }), opts);
2316
+ }
1651
2317
  var deleteTaskSchema = z7.object({
1652
2318
  id: z7.number().int().positive(),
1653
2319
  confirm: confirmFlag().describe(
@@ -1794,7 +2460,7 @@ var paginationFields = {
1794
2460
  };
1795
2461
  var listPipelinesSchema = z9.object({ ...paginationFields });
1796
2462
  async function listPipelines(input) {
1797
- const { data, nextPage } = await capsuleGet("/pipelines", {
2463
+ const { data, nextPage } = await capsuleGetCached("/pipelines", {
1798
2464
  page: input.page ?? 1,
1799
2465
  perPage: input.perPage ?? 100
1800
2466
  });
@@ -1805,7 +2471,7 @@ var listMilestonesSchema = z9.object({
1805
2471
  ...paginationFields
1806
2472
  });
1807
2473
  async function listMilestones(input) {
1808
- const { data, nextPage } = await capsuleGet(
2474
+ const { data, nextPage } = await capsuleGetCached(
1809
2475
  `/pipelines/${input.pipelineId}/milestones`,
1810
2476
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
1811
2477
  );
@@ -1820,7 +2486,7 @@ var paginationFields2 = {
1820
2486
  };
1821
2487
  var listBoardsSchema = z10.object({ ...paginationFields2 });
1822
2488
  async function listBoards(input) {
1823
- const { data, nextPage } = await capsuleGet("/boards", {
2489
+ const { data, nextPage } = await capsuleGetCached("/boards", {
1824
2490
  page: input.page ?? 1,
1825
2491
  perPage: input.perPage ?? 100
1826
2492
  });
@@ -1834,7 +2500,7 @@ var listStagesSchema = z10.object({
1834
2500
  });
1835
2501
  async function listStages(input) {
1836
2502
  const path = input.boardId !== void 0 ? `/boards/${input.boardId}/stages` : "/stages";
1837
- const { data, nextPage } = await capsuleGet(path, {
2503
+ const { data, nextPage } = await capsuleGetCached(path, {
1838
2504
  page: input.page ?? 1,
1839
2505
  perPage: input.perPage ?? 100
1840
2506
  });
@@ -1861,7 +2527,7 @@ var listTagsSchema = z11.object({
1861
2527
  });
1862
2528
  async function listTags(input) {
1863
2529
  const path = TAG_LIST_PATH[input.entity];
1864
- const { data, nextPage } = await capsuleGet(path, {
2530
+ const { data, nextPage } = await capsuleGetCached(path, {
1865
2531
  page: input.page ?? 1,
1866
2532
  perPage: input.perPage ?? 100
1867
2533
  });
@@ -1877,9 +2543,11 @@ var addTagSchema = z11.object({
1877
2543
  async function addTag(input) {
1878
2544
  const { entity, entityId, tagName } = input;
1879
2545
  const wrapper = ENTITY_TO_WRAPPER[entity];
1880
- return capsulePut(`/${entity}/${entityId}`, {
2546
+ const result = await capsulePut(`/${entity}/${entityId}`, {
1881
2547
  [wrapper]: { tags: [{ name: tagName }] }
1882
2548
  });
2549
+ invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
2550
+ return result;
1883
2551
  }
1884
2552
  var removeTagByIdSchema = z11.object({
1885
2553
  entity: TagEntity,
@@ -1891,17 +2559,17 @@ var removeTagByIdSchema = z11.object({
1891
2559
  async function removeTagById(input) {
1892
2560
  const { entity, entityId, tagId } = input;
1893
2561
  const wrapper = ENTITY_TO_WRAPPER[entity];
1894
- return idempotentWithResult(
2562
+ const result = await idempotentWithResult(
1895
2563
  () => capsulePut(`/${entity}/${entityId}`, {
1896
2564
  [wrapper]: { tags: [{ id: tagId, _delete: true }] }
1897
2565
  }),
1898
- (result) => ({
2566
+ (result2) => ({
1899
2567
  removed: true,
1900
2568
  alreadyRemoved: false,
1901
2569
  entity,
1902
2570
  entityId,
1903
2571
  tagId,
1904
- ...result
2572
+ ...result2
1905
2573
  }),
1906
2574
  () => ({ removed: true, alreadyRemoved: true, entity, entityId, tagId }),
1907
2575
  // Tag detach uses PUT with _delete: true and 422s with "tag not
@@ -1909,6 +2577,24 @@ async function removeTagById(input) {
1909
2577
  // 404. Other 422s with different wording still surface.
1910
2578
  isCapsuleTagNotFound
1911
2579
  );
2580
+ invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
2581
+ return result;
2582
+ }
2583
+ var batchAddTagSchema = z11.object({
2584
+ items: z11.array(addTagSchema).min(1).max(50).describe(
2585
+ "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."
2586
+ )
2587
+ });
2588
+ async function batchAddTag(input, opts = {}) {
2589
+ return batchExecute("batch_add_tag", input.items, (item) => addTag(item), opts);
2590
+ }
2591
+ var batchRemoveTagByIdSchema = z11.object({
2592
+ items: z11.array(removeTagByIdSchema).min(1).max(50).describe(
2593
+ "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."
2594
+ )
2595
+ });
2596
+ async function batchRemoveTagById(input, opts = {}) {
2597
+ return batchExecute("batch_remove_tag_by_id", input.items, (item) => removeTagById(item), opts);
1912
2598
  }
1913
2599
 
1914
2600
  // src/tools/users.ts
@@ -1918,7 +2604,7 @@ var listUsersSchema = z12.object({
1918
2604
  perPage: z12.number().int().min(1).max(100).optional()
1919
2605
  });
1920
2606
  async function listUsers(input) {
1921
- const { data, nextPage } = await capsuleGet("/users", {
2607
+ const { data, nextPage } = await capsuleGetCached("/users", {
1922
2608
  page: input.page ?? 1,
1923
2609
  perPage: input.perPage ?? 100
1924
2610
  });
@@ -1987,7 +2673,7 @@ var paginationFields3 = {
1987
2673
  };
1988
2674
  var listTeamsSchema = z14.object({ ...paginationFields3 });
1989
2675
  async function listTeams(input) {
1990
- const { data, nextPage } = await capsuleGet("/teams", {
2676
+ const { data, nextPage } = await capsuleGetCached("/teams", {
1991
2677
  page: input.page ?? 1,
1992
2678
  perPage: input.perPage ?? 100
1993
2679
  });
@@ -1995,7 +2681,7 @@ async function listTeams(input) {
1995
2681
  }
1996
2682
  var listLostReasonsSchema = z14.object({ ...paginationFields3 });
1997
2683
  async function listLostReasons(input) {
1998
- const { data, nextPage } = await capsuleGet("/lostreasons", {
2684
+ const { data, nextPage } = await capsuleGetCached("/lostreasons", {
1999
2685
  page: input.page ?? 1,
2000
2686
  perPage: input.perPage ?? 100
2001
2687
  });
@@ -2003,20 +2689,23 @@ async function listLostReasons(input) {
2003
2689
  }
2004
2690
  var listActivityTypesSchema = z14.object({ ...paginationFields3 });
2005
2691
  async function listActivityTypes(input) {
2006
- const { data, nextPage } = await capsuleGet("/activitytypes", {
2007
- page: input.page ?? 1,
2008
- perPage: input.perPage ?? 100
2009
- });
2692
+ const { data, nextPage } = await capsuleGetCached(
2693
+ "/activitytypes",
2694
+ {
2695
+ page: input.page ?? 1,
2696
+ perPage: input.perPage ?? 100
2697
+ }
2698
+ );
2010
2699
  return { ...data, nextPage };
2011
2700
  }
2012
2701
  var getSiteSchema = z14.object({});
2013
2702
  async function getSite(_input) {
2014
- const { data } = await capsuleGet("/site");
2703
+ const { data } = await capsuleGetCached("/site");
2015
2704
  return data;
2016
2705
  }
2017
2706
  var listTrackDefinitionsSchema = z14.object({ ...paginationFields3 });
2018
2707
  async function listTrackDefinitions(input) {
2019
- const { data, nextPage } = await capsuleGet(
2708
+ const { data, nextPage } = await capsuleGetCached(
2020
2709
  "/trackdefinitions",
2021
2710
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
2022
2711
  );
@@ -2024,7 +2713,7 @@ async function listTrackDefinitions(input) {
2024
2713
  }
2025
2714
  var listCategoriesSchema = z14.object({ ...paginationFields3 });
2026
2715
  async function listCategories(input) {
2027
- const { data, nextPage } = await capsuleGet("/categories", {
2716
+ const { data, nextPage } = await capsuleGetCached("/categories", {
2028
2717
  page: input.page ?? 1,
2029
2718
  perPage: input.perPage ?? 100
2030
2719
  });
@@ -2032,7 +2721,7 @@ async function listCategories(input) {
2032
2721
  }
2033
2722
  var listGoalsSchema = z14.object({ ...paginationFields3 });
2034
2723
  async function listGoals(input) {
2035
- const { data, nextPage } = await capsuleGet("/goals", {
2724
+ const { data, nextPage } = await capsuleGetCached("/goals", {
2036
2725
  page: input.page ?? 1,
2037
2726
  perPage: input.perPage ?? 100
2038
2727
  });
@@ -2191,7 +2880,7 @@ var listCustomFieldsSchema = z17.object({
2191
2880
  entity: CustomFieldEntity
2192
2881
  });
2193
2882
  async function listCustomFields(input) {
2194
- const { data } = await capsuleGet(
2883
+ const { data } = await capsuleGetCached(
2195
2884
  `/${input.entity}/fields/definitions`
2196
2885
  );
2197
2886
  return data;
@@ -2201,7 +2890,7 @@ var getCustomFieldSchema = z17.object({
2201
2890
  fieldId: z17.number().int().positive().describe("Custom field definition id.")
2202
2891
  });
2203
2892
  async function getCustomField(input) {
2204
- const { data } = await capsuleGet(
2893
+ const { data } = await capsuleGetCached(
2205
2894
  `/${input.entity}/fields/definitions/${input.fieldId}`
2206
2895
  );
2207
2896
  return data;
@@ -2372,7 +3061,7 @@ var listSavedFiltersSchema = z20.object({
2372
3061
  entity: EntitySchema
2373
3062
  });
2374
3063
  async function listSavedFilters(input) {
2375
- const { data } = await capsuleGet(`/${input.entity}/filters`);
3064
+ const { data } = await capsuleGetCached(`/${input.entity}/filters`);
2376
3065
  return data;
2377
3066
  }
2378
3067
  var runSavedFilterSchema = z20.object({
@@ -2391,15 +3080,35 @@ async function runSavedFilter(input) {
2391
3080
  }
2392
3081
 
2393
3082
  // src/server.ts
2394
- function createCapsuleMcpServer() {
3083
+ function createCapsuleMcpServer(opts) {
2395
3084
  const readOnly = isReadOnly();
2396
- const server = new McpServer({
2397
- name: "capsulemcp",
2398
- version: "1.0.1",
2399
- description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
2400
- websiteUrl: "https://github.com/soil-dev/capsulemcp",
2401
- icons: ICONS
2402
- });
3085
+ const tasksCfg = getTasksConfig();
3086
+ const tasksWired = tasksCfg.enabled && !!opts?.clientId;
3087
+ const server = new McpServer(
3088
+ {
3089
+ name: "capsulemcp",
3090
+ version: "1.6.1",
3091
+ description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
3092
+ websiteUrl: "https://github.com/soil-dev/capsulemcp",
3093
+ icons: ICONS
3094
+ },
3095
+ tasksWired ? {
3096
+ // tasksWired guards clientId presence; narrow explicitly
3097
+ // for the type-checker rather than using `!`.
3098
+ taskStore: createScopedTaskStore(opts?.clientId ?? ""),
3099
+ capabilities: {
3100
+ tasks: {
3101
+ // The SDK's task capability schema uses {} for "present"
3102
+ // markers, not booleans — see ServerTasksCapabilitySchema
3103
+ // in @modelcontextprotocol/sdk types.ts.
3104
+ list: {},
3105
+ cancel: {},
3106
+ requests: { tools: { call: {} } }
3107
+ }
3108
+ }
3109
+ } : void 0
3110
+ );
3111
+ const registerBatchTool = tasksWired ? registerToolTask : (s, name, description, schema, handler) => registerTool(s, name, description, schema, (input) => handler(input, {}));
2403
3112
  registerTool(
2404
3113
  server,
2405
3114
  "search_parties",
@@ -2417,14 +3126,14 @@ function createCapsuleMcpServer() {
2417
3126
  registerTool(
2418
3127
  server,
2419
3128
  "get_party",
2420
- "Fetch a single party (person or organisation) by its numeric ID.",
3129
+ "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.",
2421
3130
  getPartySchema,
2422
3131
  getParty
2423
3132
  );
2424
3133
  registerTool(
2425
3134
  server,
2426
3135
  "get_parties",
2427
- "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.",
3136
+ "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.",
2428
3137
  getPartiesSchema,
2429
3138
  getParties
2430
3139
  );
@@ -2485,6 +3194,13 @@ function createCapsuleMcpServer() {
2485
3194
  updatePartySchema,
2486
3195
  updateParty
2487
3196
  );
3197
+ registerBatchTool(
3198
+ server,
3199
+ "batch_update_party",
3200
+ "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.",
3201
+ batchUpdatePartySchema,
3202
+ batchUpdateParty
3203
+ );
2488
3204
  registerTool(
2489
3205
  server,
2490
3206
  "delete_party",
@@ -2566,14 +3282,14 @@ function createCapsuleMcpServer() {
2566
3282
  registerTool(
2567
3283
  server,
2568
3284
  "get_opportunity",
2569
- "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.",
3285
+ "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.",
2570
3286
  getOpportunitySchema,
2571
3287
  getOpportunity
2572
3288
  );
2573
3289
  registerTool(
2574
3290
  server,
2575
3291
  "get_opportunities",
2576
- "Batch-fetch up to 10 opportunities by ID in a single call.",
3292
+ "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.",
2577
3293
  getOpportunitiesSchema,
2578
3294
  getOpportunities
2579
3295
  );
@@ -2613,6 +3329,13 @@ function createCapsuleMcpServer() {
2613
3329
  updateOpportunitySchema,
2614
3330
  updateOpportunity
2615
3331
  );
3332
+ registerBatchTool(
3333
+ server,
3334
+ "batch_update_opportunity",
3335
+ "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.",
3336
+ batchUpdateOpportunitySchema,
3337
+ batchUpdateOpportunity
3338
+ );
2616
3339
  registerTool(
2617
3340
  server,
2618
3341
  "delete_opportunity",
@@ -2638,14 +3361,14 @@ function createCapsuleMcpServer() {
2638
3361
  registerTool(
2639
3362
  server,
2640
3363
  "get_project",
2641
- "Fetch a single project (case) by its numeric ID.",
3364
+ "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.",
2642
3365
  getProjectSchema,
2643
3366
  getProject
2644
3367
  );
2645
3368
  registerTool(
2646
3369
  server,
2647
3370
  "get_projects",
2648
- "Batch-fetch up to 10 projects (cases) by ID in a single call.",
3371
+ "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.",
2649
3372
  getProjectsSchema,
2650
3373
  getProjects
2651
3374
  );
@@ -2724,14 +3447,14 @@ function createCapsuleMcpServer() {
2724
3447
  registerTool(
2725
3448
  server,
2726
3449
  "get_task",
2727
- "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.",
3450
+ "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.",
2728
3451
  getTaskSchema,
2729
3452
  getTask
2730
3453
  );
2731
3454
  registerTool(
2732
3455
  server,
2733
3456
  "get_tasks",
2734
- "Batch-fetch up to 10 tasks by ID in a single call.",
3457
+ "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.",
2735
3458
  getTasksSchema,
2736
3459
  getTasks
2737
3460
  );
@@ -2746,7 +3469,7 @@ function createCapsuleMcpServer() {
2746
3469
  registerTool(
2747
3470
  server,
2748
3471
  "update_task",
2749
- "Update fields on an existing task. Only the fields you provide are changed. To mark a task done prefer complete_task.",
3472
+ "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.",
2750
3473
  updateTaskSchema,
2751
3474
  updateTask
2752
3475
  );
@@ -2757,6 +3480,13 @@ function createCapsuleMcpServer() {
2757
3480
  completeTaskSchema,
2758
3481
  completeTask
2759
3482
  );
3483
+ registerBatchTool(
3484
+ server,
3485
+ "batch_complete_task",
3486
+ "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.",
3487
+ batchCompleteTaskSchema,
3488
+ batchCompleteTask
3489
+ );
2760
3490
  registerTool(
2761
3491
  server,
2762
3492
  "delete_task",
@@ -2768,7 +3498,7 @@ function createCapsuleMcpServer() {
2768
3498
  registerTool(
2769
3499
  server,
2770
3500
  "list_party_entries",
2771
- "List timeline entries (notes, captured emails, completed-task records) for a party. Use this to read the conversation history with a contact or organisation.",
3501
+ "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.",
2772
3502
  listPartyEntriesSchema,
2773
3503
  listPartyEntries
2774
3504
  );
@@ -2789,7 +3519,7 @@ function createCapsuleMcpServer() {
2789
3519
  registerTool(
2790
3520
  server,
2791
3521
  "get_entry",
2792
- "Fetch a single timeline entry by its numeric ID. Returns full content (note body, email subject + body, etc.).",
3522
+ "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`.",
2793
3523
  getEntrySchema,
2794
3524
  getEntry
2795
3525
  );
@@ -2804,6 +3534,10 @@ function createCapsuleMcpServer() {
2804
3534
  "get_attachment",
2805
3535
  "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.",
2806
3536
  getAttachmentSchema.shape,
3537
+ // get_attachment is read-only — downloads a binary, never mutates.
3538
+ // Mirrors the auto-inferred `readOnlyHint: true` that
3539
+ // `registerTool` applies to every other `get_*` tool.
3540
+ { readOnlyHint: true },
2807
3541
  async (input) => {
2808
3542
  const result = await getAttachment(input);
2809
3543
  if (result.truncated) {
@@ -2931,7 +3665,7 @@ function createCapsuleMcpServer() {
2931
3665
  registerTool(
2932
3666
  server,
2933
3667
  "list_stages",
2934
- "List project stages. Without arguments returns every stage across every board (each carries a .board reference). Pass boardId to scope to one specific board.",
3668
+ "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.",
2935
3669
  listStagesSchema,
2936
3670
  listStages
2937
3671
  );
@@ -2945,7 +3679,7 @@ function createCapsuleMcpServer() {
2945
3679
  registerTool(
2946
3680
  server,
2947
3681
  "list_lostreasons",
2948
- "List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor'). Useful for analysing closed-lost opportunities by reason.",
3682
+ "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.",
2949
3683
  listLostReasonsSchema,
2950
3684
  listLostReasons
2951
3685
  );
@@ -2959,7 +3693,7 @@ function createCapsuleMcpServer() {
2959
3693
  registerTool(
2960
3694
  server,
2961
3695
  "list_categories",
2962
- "List configured entry/task categories (Call, Email, Meeting, Follow-up, etc.) with their colours. Used to label and filter timeline entries and tasks.",
3696
+ "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.",
2963
3697
  listCategoriesSchema,
2964
3698
  listCategories
2965
3699
  );
@@ -3034,6 +3768,20 @@ function createCapsuleMcpServer() {
3034
3768
  removeTagByIdSchema,
3035
3769
  removeTagById
3036
3770
  );
3771
+ registerBatchTool(
3772
+ server,
3773
+ "batch_add_tag",
3774
+ "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.",
3775
+ batchAddTagSchema,
3776
+ batchAddTag
3777
+ );
3778
+ registerBatchTool(
3779
+ server,
3780
+ "batch_remove_tag_by_id",
3781
+ "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} }.",
3782
+ batchRemoveTagByIdSchema,
3783
+ batchRemoveTagById
3784
+ );
3037
3785
  }
3038
3786
  registerTool(
3039
3787
  server,
@@ -3059,6 +3807,20 @@ function secretDigest(value) {
3059
3807
  function timingSafeSecretEqual(provided, expected) {
3060
3808
  return timingSafeEqual3(secretDigest(provided), secretDigest(expected));
3061
3809
  }
3810
+ var DEFAULT_MCP_RATE_LIMIT_WINDOW_MS = 6e4;
3811
+ var DEFAULT_MCP_RATE_LIMIT_MAX = 600;
3812
+ var MAX_MEMORY_STORE_WINDOW_MS = 2 ** 31 - 1;
3813
+ function resolveMcpRateLimitConfig() {
3814
+ const windowMs = Math.min(
3815
+ readPositiveInt("MCP_HTTP_RATE_LIMIT_WINDOW_MS", DEFAULT_MCP_RATE_LIMIT_WINDOW_MS),
3816
+ MAX_MEMORY_STORE_WINDOW_MS
3817
+ );
3818
+ return {
3819
+ windowMs,
3820
+ limit: readPositiveInt("MCP_HTTP_RATE_LIMIT_MAX", DEFAULT_MCP_RATE_LIMIT_MAX),
3821
+ disabled: process.env["MCP_HTTP_RATE_LIMIT_DISABLED"] === "1"
3822
+ };
3823
+ }
3062
3824
  function createApp(opts) {
3063
3825
  const { oauthProvider: oauthProvider2, issuerUrl: issuerUrl2, jsonLimit: jsonLimit2, allowedOrigins } = opts;
3064
3826
  const resourceName = opts.resourceName ?? "Capsule CRM MCP";
@@ -3150,9 +3912,11 @@ function createApp(opts) {
3150
3912
  }
3151
3913
  next();
3152
3914
  };
3153
- const rateLimitWindowMs = Number(process.env["MCP_HTTP_RATE_LIMIT_WINDOW_MS"]) || 6e4;
3154
- const rateLimitMax = Number(process.env["MCP_HTTP_RATE_LIMIT_MAX"]) || 600;
3155
- const rateLimitDisabled = process.env["MCP_HTTP_RATE_LIMIT_DISABLED"] === "1";
3915
+ const {
3916
+ windowMs: rateLimitWindowMs,
3917
+ limit: rateLimitMax,
3918
+ disabled: rateLimitDisabled
3919
+ } = resolveMcpRateLimitConfig();
3156
3920
  const mcpRateLimit = rateLimit({
3157
3921
  windowMs: rateLimitWindowMs,
3158
3922
  limit: rateLimitMax,
@@ -3160,7 +3924,8 @@ function createApp(opts) {
3160
3924
  legacyHeaders: false,
3161
3925
  keyGenerator: (req) => {
3162
3926
  const clientId = req.auth?.clientId;
3163
- return clientId ?? req.ip ?? "unknown";
3927
+ if (clientId) return clientId;
3928
+ return ipKeyGenerator(req.ip ?? "");
3164
3929
  },
3165
3930
  skip: () => rateLimitDisabled,
3166
3931
  handler: (_req, res) => {
@@ -3198,14 +3963,17 @@ function createApp(opts) {
3198
3963
  express.json({ limit: jsonLimit2 }),
3199
3964
  async (req, res) => {
3200
3965
  try {
3201
- const server = createCapsuleMcpServer();
3966
+ const clientId = req.auth?.clientId;
3967
+ const server = createCapsuleMcpServer({ clientId });
3202
3968
  const transport = new StreamableHTTPServerTransport({});
3203
3969
  res.on("close", () => {
3204
3970
  void transport.close();
3205
3971
  void server.close();
3206
3972
  });
3207
- await server.connect(transport);
3208
- await transport.handleRequest(req, res, req.body);
3973
+ await withRequestContext({ clientId }, async () => {
3974
+ await server.connect(transport);
3975
+ await transport.handleRequest(req, res, req.body);
3976
+ });
3209
3977
  } catch (err) {
3210
3978
  const name = err instanceof Error ? err.name : typeof err;
3211
3979
  const status = err && typeof err === "object" && "status" in err ? Number(err.status) : void 0;