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/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,10 +304,30 @@ async function doFetch(url, options) {
162
304
  "Rate limit exceeded after one retry. Please slow down your requests."
163
305
  );
164
306
  }
307
+ emitCapsuleRequest(method, url, retried.res, Date.now() - startedAt, true);
165
308
  return retried;
166
309
  }
310
+ emitCapsuleRequest(method, url, first.res, Date.now() - startedAt, false);
167
311
  return first;
168
312
  }
313
+ function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
314
+ let path = "";
315
+ try {
316
+ path = redactPath(new URL(url).pathname);
317
+ } catch {
318
+ path = "?";
319
+ }
320
+ const lenHeader = res.headers.get("content-length");
321
+ const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
322
+ logEvent("capsule.request", {
323
+ method,
324
+ path,
325
+ status: res.status,
326
+ durationMs,
327
+ responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0,
328
+ ...retriedAfter429 ? { retriedAfter429: true } : {}
329
+ });
330
+ }
169
331
  async function throwForStatus(res) {
170
332
  if (res.status === 401) {
171
333
  const detail = await parseErrorBody(res);
@@ -205,6 +367,34 @@ async function capsuleGet(path, params) {
205
367
  cleanup();
206
368
  }
207
369
  }
370
+ async function capsuleGetCached(path, params) {
371
+ if (cacheDisabled()) return capsuleGet(path, params);
372
+ const key = cacheKey(path, params);
373
+ const lookup = cacheLookup(key);
374
+ if (lookup.hit) {
375
+ if (logVerbose()) {
376
+ logEvent("cache.hit", {
377
+ path: redactPath(path),
378
+ ...params ? { paramFields: Object.keys(params) } : {},
379
+ ageMs: lookup.ageMs
380
+ });
381
+ }
382
+ return lookup.result;
383
+ }
384
+ const fetchStart = Date.now();
385
+ const result = await capsuleGet(path, params);
386
+ const latencyMs = Date.now() - fetchStart;
387
+ cacheSet(key, result);
388
+ if (logVerbose()) {
389
+ logEvent("cache.miss", {
390
+ path: redactPath(path),
391
+ ...params ? { paramFields: Object.keys(params) } : {},
392
+ reason: lookup.reason,
393
+ latencyMs
394
+ });
395
+ }
396
+ return result;
397
+ }
208
398
  async function capsulePost(path, body) {
209
399
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
210
400
  const token = getToken();
@@ -834,7 +1024,7 @@ function resolveBaseConfig(env = process.env) {
834
1024
  // src/http/app.ts
835
1025
  import { createHash as createHash2, timingSafeEqual as timingSafeEqual3 } from "crypto";
836
1026
  import express from "express";
837
- import { rateLimit } from "express-rate-limit";
1027
+ import { ipKeyGenerator, rateLimit } from "express-rate-limit";
838
1028
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
839
1029
  import {
840
1030
  mcpAuthRouter,
@@ -876,7 +1066,195 @@ var ICONS = [
876
1066
  }
877
1067
  ];
878
1068
 
1069
+ // src/tasks/store.ts
1070
+ import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
1071
+ import {
1072
+ ErrorCode,
1073
+ McpError
1074
+ } from "@modelcontextprotocol/sdk/types.js";
1075
+
1076
+ // src/tasks/config.ts
1077
+ var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1078
+ var DEFAULT_MAX_KEEP_ALIVE_MS = 15 * 60 * 1e3;
1079
+ var MIN_TASK_TTL_MS = 1e3;
1080
+ var DEFAULT_POLL_FREQUENCY_MS = 1500;
1081
+ var MIN_POLL_FREQUENCY_MS = 500;
1082
+ var DEFAULT_MAX_PER_CLIENT = 20;
1083
+ var DEFAULT_MAX_TOTAL = 200;
1084
+ function getTasksConfig() {
1085
+ const enabled = readBool("MCP_TASKS_ENABLED");
1086
+ const maxKeepAliveMs = Math.max(
1087
+ readPositiveInt("MCP_TASKS_MAX_KEEP_ALIVE_MS", DEFAULT_MAX_KEEP_ALIVE_MS),
1088
+ MIN_TASK_TTL_MS
1089
+ );
1090
+ const defaultTtlMs = Math.min(
1091
+ Math.max(readPositiveInt("MCP_TASKS_DEFAULT_TTL_MS", DEFAULT_TTL_MS2), MIN_TASK_TTL_MS),
1092
+ maxKeepAliveMs
1093
+ );
1094
+ const defaultPollFrequencyMs = Math.max(
1095
+ readPositiveInt("MCP_TASKS_DEFAULT_POLL_FREQUENCY_MS", DEFAULT_POLL_FREQUENCY_MS),
1096
+ MIN_POLL_FREQUENCY_MS
1097
+ );
1098
+ const maxPerClient = readPositiveInt("MCP_TASKS_MAX_PER_CLIENT", DEFAULT_MAX_PER_CLIENT);
1099
+ const maxTotal = readPositiveInt("MCP_TASKS_MAX_TOTAL", DEFAULT_MAX_TOTAL);
1100
+ return {
1101
+ enabled,
1102
+ defaultTtlMs,
1103
+ maxKeepAliveMs,
1104
+ defaultPollFrequencyMs,
1105
+ maxPerClient,
1106
+ maxTotal
1107
+ };
1108
+ }
1109
+
1110
+ // src/tasks/store.ts
1111
+ var _globalStore = null;
1112
+ function getGlobalStore() {
1113
+ if (_globalStore === null) {
1114
+ _globalStore = new InMemoryTaskStore();
1115
+ }
1116
+ return _globalStore;
1117
+ }
1118
+ var owners = /* @__PURE__ */ new Map();
1119
+ var abortControllers = /* @__PURE__ */ new Map();
1120
+ function registerAbortController(taskId, controller) {
1121
+ abortControllers.set(taskId, controller);
1122
+ }
1123
+ function countPerClient(clientId) {
1124
+ let n = 0;
1125
+ for (const owner of owners.values()) {
1126
+ if (owner === clientId) n++;
1127
+ }
1128
+ return n;
1129
+ }
1130
+ function createScopedTaskStore(clientId) {
1131
+ if (!clientId) {
1132
+ throw new Error("createScopedTaskStore: clientId is required");
1133
+ }
1134
+ const global = getGlobalStore();
1135
+ async function getOwned(taskId) {
1136
+ if (owners.get(taskId) !== clientId) return null;
1137
+ return global.getTask(taskId);
1138
+ }
1139
+ return {
1140
+ async createTask(taskParams, requestId, request, sessionId) {
1141
+ const cfg = getTasksConfig();
1142
+ const totalNow = owners.size;
1143
+ if (totalNow >= cfg.maxTotal) {
1144
+ logEvent("task.rejected", {
1145
+ reason: "max_total",
1146
+ clientId,
1147
+ totalNow,
1148
+ cap: cfg.maxTotal
1149
+ });
1150
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this server instance");
1151
+ }
1152
+ const perClientNow = countPerClient(clientId);
1153
+ if (perClientNow >= cfg.maxPerClient) {
1154
+ logEvent("task.rejected", {
1155
+ reason: "max_per_client",
1156
+ clientId,
1157
+ perClientNow,
1158
+ cap: cfg.maxPerClient
1159
+ });
1160
+ throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this client");
1161
+ }
1162
+ const requestedTtl = taskParams.ttl;
1163
+ const clampedTtl = requestedTtl === null ? cfg.maxKeepAliveMs : Math.max(
1164
+ MIN_TASK_TTL_MS,
1165
+ Math.min(requestedTtl ?? cfg.defaultTtlMs, cfg.maxKeepAliveMs)
1166
+ );
1167
+ const requestedPoll = taskParams.pollInterval ?? cfg.defaultPollFrequencyMs;
1168
+ const clampedPoll = Math.max(cfg.defaultPollFrequencyMs, Math.floor(requestedPoll));
1169
+ const task = await global.createTask(
1170
+ { ttl: clampedTtl, pollInterval: clampedPoll, context: taskParams.context },
1171
+ requestId,
1172
+ request,
1173
+ sessionId
1174
+ );
1175
+ owners.set(task.taskId, clientId);
1176
+ const timer = setTimeout(() => {
1177
+ owners.delete(task.taskId);
1178
+ abortControllers.delete(task.taskId);
1179
+ logEvent("task.evicted", { taskId: task.taskId, clientId, reason: "ttl" });
1180
+ }, clampedTtl);
1181
+ timer.unref?.();
1182
+ logEvent("task.created", {
1183
+ taskId: task.taskId,
1184
+ clientId,
1185
+ ttl: clampedTtl,
1186
+ pollInterval: clampedPoll,
1187
+ method: typeof request.method === "string" ? request.method : "unknown"
1188
+ });
1189
+ return task;
1190
+ },
1191
+ async getTask(taskId) {
1192
+ return getOwned(taskId);
1193
+ },
1194
+ async storeTaskResult(taskId, status, result, sessionId) {
1195
+ if (owners.get(taskId) !== clientId) {
1196
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
1197
+ }
1198
+ logEvent("task.transition", { taskId, clientId, status });
1199
+ await global.storeTaskResult(taskId, status, result, sessionId);
1200
+ },
1201
+ async getTaskResult(taskId, sessionId) {
1202
+ if (owners.get(taskId) !== clientId) {
1203
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
1204
+ }
1205
+ return global.getTaskResult(taskId, sessionId);
1206
+ },
1207
+ async updateTaskStatus(taskId, status, statusMessage, sessionId) {
1208
+ if (owners.get(taskId) !== clientId) {
1209
+ throw new McpError(ErrorCode.InvalidParams, "Task not found");
1210
+ }
1211
+ logEvent("task.transition", { taskId, clientId, status, statusMessage });
1212
+ await global.updateTaskStatus(taskId, status, statusMessage, sessionId);
1213
+ if (status === "cancelled") {
1214
+ const ctrl = abortControllers.get(taskId);
1215
+ if (ctrl && !ctrl.signal.aborted) ctrl.abort();
1216
+ }
1217
+ if (status === "completed" || status === "failed" || status === "cancelled") {
1218
+ abortControllers.delete(taskId);
1219
+ }
1220
+ },
1221
+ async listTasks(cursor, sessionId) {
1222
+ const page = await global.listTasks(cursor, sessionId);
1223
+ const filtered = page.tasks.filter((t) => owners.get(t.taskId) === clientId);
1224
+ return page.nextCursor ? { tasks: filtered, nextCursor: page.nextCursor } : { tasks: filtered };
1225
+ }
1226
+ };
1227
+ }
1228
+
879
1229
  // src/server/register-tool.ts
1230
+ var READ_PREFIXES = ["search_", "filter_", "get_", "list_", "show_", "run_"];
1231
+ var DESTRUCTIVE_NON_DELETE = /* @__PURE__ */ new Set(["remove_track", "remove_additional_party"]);
1232
+ function isDestructive(name) {
1233
+ return name.startsWith("delete_") || DESTRUCTIVE_NON_DELETE.has(name);
1234
+ }
1235
+ function inferAnnotations(name) {
1236
+ if (READ_PREFIXES.some((p) => name.startsWith(p))) {
1237
+ return { readOnlyHint: true };
1238
+ }
1239
+ if (isDestructive(name)) {
1240
+ return { destructiveHint: true };
1241
+ }
1242
+ return void 0;
1243
+ }
1244
+ function argFieldNames(input) {
1245
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
1246
+ return Object.keys(input);
1247
+ }
1248
+ function emitToolCall(opts) {
1249
+ logEvent("tool.call", {
1250
+ tool: opts.tool,
1251
+ ...opts.clientId ? { clientId: opts.clientId } : {},
1252
+ argFields: opts.argFields,
1253
+ durationMs: Date.now() - opts.startedAt,
1254
+ outcome: opts.outcome,
1255
+ ...opts.taskAugmented ? { taskAugmented: true } : {}
1256
+ });
1257
+ }
880
1258
  function wrapAsText(result) {
881
1259
  return {
882
1260
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
@@ -884,10 +1262,93 @@ function wrapAsText(result) {
884
1262
  }
885
1263
  function registerTool(server, name, description, schema, handler) {
886
1264
  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
- });
1265
+ const annotations = inferAnnotations(name);
1266
+ registerWithSchema(
1267
+ name,
1268
+ { description, inputSchema: schema, ...annotations ? { annotations } : {} },
1269
+ async (input) => {
1270
+ const startedAt = Date.now();
1271
+ const argFields = argFieldNames(input);
1272
+ const clientId = getRequestContext()?.clientId;
1273
+ try {
1274
+ const result = await handler(input);
1275
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
1276
+ return wrapAsText(result);
1277
+ } catch (err) {
1278
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
1279
+ throw err;
1280
+ }
1281
+ }
1282
+ );
1283
+ }
1284
+ function registerToolTask(server, name, description, schema, handler) {
1285
+ const registerWithSchema = server.experimental.tasks.registerToolTask.bind(
1286
+ server.experimental.tasks
1287
+ );
1288
+ const annotations = inferAnnotations(name);
1289
+ registerWithSchema(
1290
+ name,
1291
+ {
1292
+ description,
1293
+ inputSchema: schema,
1294
+ execution: { taskSupport: "optional" },
1295
+ ...annotations ? { annotations } : {}
1296
+ },
1297
+ {
1298
+ createTask: async (input, extra) => {
1299
+ const task = await extra.taskStore.createTask({
1300
+ ttl: extra.taskRequestedTtl
1301
+ });
1302
+ const abortController = new AbortController();
1303
+ registerAbortController(task.taskId, abortController);
1304
+ const requestClientId = getRequestContext()?.clientId;
1305
+ const argFields = argFieldNames(input);
1306
+ void (async () => {
1307
+ if (abortController.signal.aborted) return;
1308
+ try {
1309
+ await extra.taskStore.updateTaskStatus(task.taskId, "working");
1310
+ } catch {
1311
+ }
1312
+ const handlerStart = Date.now();
1313
+ let payload;
1314
+ let outcome = "success";
1315
+ try {
1316
+ const result = await handler(input, {
1317
+ signal: abortController.signal
1318
+ });
1319
+ payload = wrapAsText(result);
1320
+ } catch (err) {
1321
+ if (abortController.signal.aborted) return;
1322
+ outcome = "error";
1323
+ const message = err instanceof Error ? err.message : String(err);
1324
+ payload = {
1325
+ content: [{ type: "text", text: message }],
1326
+ isError: true
1327
+ };
1328
+ }
1329
+ emitToolCall({
1330
+ tool: name,
1331
+ clientId: requestClientId,
1332
+ argFields,
1333
+ startedAt: handlerStart,
1334
+ outcome,
1335
+ taskAugmented: true
1336
+ });
1337
+ if (abortController.signal.aborted) return;
1338
+ try {
1339
+ await extra.taskStore.storeTaskResult(task.taskId, "completed", payload);
1340
+ } catch {
1341
+ }
1342
+ })();
1343
+ return { task };
1344
+ },
1345
+ getTask: async (_input, extra) => extra.taskStore.getTask(extra.taskId),
1346
+ getTaskResult: async (_input, extra) => {
1347
+ const r = await extra.taskStore.getTaskResult(extra.taskId);
1348
+ return r;
1349
+ }
1350
+ }
1351
+ );
891
1352
  }
892
1353
 
893
1354
  // src/tools/parties.ts
@@ -904,6 +1365,98 @@ function confirmFlag() {
904
1365
  return z2.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
905
1366
  }
906
1367
 
1368
+ // src/capsule/batch.ts
1369
+ function chunk(arr, size) {
1370
+ if (size <= 0) throw new Error("chunk size must be positive");
1371
+ const out = [];
1372
+ for (let i = 0; i < arr.length; i += size) {
1373
+ out.push(arr.slice(i, i + size));
1374
+ }
1375
+ return out;
1376
+ }
1377
+ var DEFAULT_CONCURRENCY = 5;
1378
+ var MAX_CONCURRENCY = 50;
1379
+ function getBatchConcurrency() {
1380
+ return Math.min(
1381
+ readPositiveInt("CAPSULE_MCP_BATCH_CONCURRENCY", DEFAULT_CONCURRENCY),
1382
+ MAX_CONCURRENCY
1383
+ );
1384
+ }
1385
+ async function batchExecute(tool, items, action, options = {}) {
1386
+ const concurrency = getBatchConcurrency();
1387
+ const results = new Array(items.length);
1388
+ const startedAt = Date.now();
1389
+ const signal = options.signal;
1390
+ let cursor = 0;
1391
+ async function worker() {
1392
+ while (true) {
1393
+ const i = cursor;
1394
+ cursor += 1;
1395
+ if (i >= items.length) return;
1396
+ if (signal?.aborted) {
1397
+ results[i] = {
1398
+ ok: false,
1399
+ error: { message: "cancelled by tasks/cancel" }
1400
+ };
1401
+ continue;
1402
+ }
1403
+ try {
1404
+ const result = await action(items[i], i);
1405
+ results[i] = { ok: true, result };
1406
+ } catch (err) {
1407
+ results[i] = { ok: false, error: extractError(err) };
1408
+ }
1409
+ }
1410
+ }
1411
+ const workers = [];
1412
+ for (let w = 0; w < Math.min(concurrency, items.length); w++) {
1413
+ workers.push(worker());
1414
+ }
1415
+ await Promise.all(workers);
1416
+ const succeeded = results.filter((r) => r.ok).length;
1417
+ const failed = results.length - succeeded;
1418
+ const summary = { total: results.length, succeeded, failed };
1419
+ const failureReasons = logVerbose() ? topFailureReasons(results, 5) : [];
1420
+ logEvent(
1421
+ "batch.complete",
1422
+ {
1423
+ tool,
1424
+ total: summary.total,
1425
+ succeeded: summary.succeeded,
1426
+ failed: summary.failed,
1427
+ durationMs: Date.now() - startedAt,
1428
+ concurrency,
1429
+ ...failureReasons.length > 0 ? { failureReasons } : {}
1430
+ },
1431
+ { force: true }
1432
+ );
1433
+ return { results, summary };
1434
+ }
1435
+ function extractError(err) {
1436
+ if (err instanceof Error) {
1437
+ const maybeStatus = err.status;
1438
+ return {
1439
+ ...typeof maybeStatus === "number" ? { status: maybeStatus } : {},
1440
+ message: err.message
1441
+ };
1442
+ }
1443
+ return { message: String(err) };
1444
+ }
1445
+ function topFailureReasons(results, n) {
1446
+ const counts = /* @__PURE__ */ new Map();
1447
+ for (const r of results) {
1448
+ if (r.ok) continue;
1449
+ const key = `${r.error.status ?? "?"}::${r.error.message}`;
1450
+ const existing = counts.get(key);
1451
+ if (existing) {
1452
+ existing.count += 1;
1453
+ } else {
1454
+ counts.set(key, { ...r.error, count: 1 });
1455
+ }
1456
+ }
1457
+ return Array.from(counts.values()).sort((a, b) => b.count - a.count).slice(0, n);
1458
+ }
1459
+
907
1460
  // src/capsule/idempotent.ts
908
1461
  var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
909
1462
  var isCapsuleTagNotFound = (err) => err instanceof CapsuleApiError && err.status === 422 && /tag not found/i.test(err.message);
@@ -1042,14 +1595,26 @@ async function getParty(input) {
1042
1595
  return data;
1043
1596
  }
1044
1597
  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."),
1598
+ ids: z4.array(z4.number().int().positive()).min(1).max(50).describe(
1599
+ "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."
1600
+ ),
1046
1601
  embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1047
1602
  });
1048
1603
  async function getParties(input) {
1049
- const { data } = await capsuleGet(`/parties/${input.ids.join(",")}`, {
1050
- embed: input.embed
1051
- });
1052
- return data;
1604
+ const { ids, embed } = input;
1605
+ if (ids.length <= 10) {
1606
+ const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
1607
+ embed
1608
+ });
1609
+ return data;
1610
+ }
1611
+ const chunks = chunk(ids, 10);
1612
+ const responses = await Promise.all(
1613
+ chunks.map(
1614
+ (chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
1615
+ )
1616
+ );
1617
+ return { parties: responses.flatMap((r) => r.data.parties) };
1053
1618
  }
1054
1619
  var listPartyOpportunitiesSchema = z4.object({
1055
1620
  partyId: z4.number().int().positive(),
@@ -1133,6 +1698,14 @@ async function updateParty(input) {
1133
1698
  if (mappedFields !== void 0) body["fields"] = mappedFields;
1134
1699
  return capsulePut(`/parties/${id}`, { party: body });
1135
1700
  }
1701
+ var batchUpdatePartySchema = z4.object({
1702
+ items: z4.array(updatePartySchema).min(1).max(50).describe(
1703
+ "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)."
1704
+ )
1705
+ });
1706
+ async function batchUpdateParty(input, opts = {}) {
1707
+ return batchExecute("batch_update_party", input.items, (item) => updateParty(item), opts);
1708
+ }
1136
1709
  var deletePartySchema = z4.object({
1137
1710
  id: z4.number().int().positive(),
1138
1711
  confirm: confirmFlag().describe(
@@ -1335,15 +1908,29 @@ async function getOpportunity(input) {
1335
1908
  return data;
1336
1909
  }
1337
1910
  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."),
1911
+ ids: z5.array(z5.number().int().positive()).min(1).max(50).describe(
1912
+ "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."
1913
+ ),
1339
1914
  embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1340
1915
  });
1341
1916
  async function getOpportunities(input) {
1342
- const { data } = await capsuleGet(
1343
- `/opportunities/${input.ids.join(",")}`,
1344
- { embed: input.embed }
1917
+ const { ids, embed } = input;
1918
+ if (ids.length <= 10) {
1919
+ const { data } = await capsuleGet(
1920
+ `/opportunities/${ids.join(",")}`,
1921
+ { embed }
1922
+ );
1923
+ return data;
1924
+ }
1925
+ const chunks = chunk(ids, 10);
1926
+ const responses = await Promise.all(
1927
+ chunks.map(
1928
+ (chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
1929
+ embed
1930
+ })
1931
+ )
1345
1932
  );
1346
- return data;
1933
+ return { opportunities: responses.flatMap((r) => r.data.opportunities) };
1347
1934
  }
1348
1935
  var createOpportunitySchema = z5.object({
1349
1936
  name: z5.string().min(1),
@@ -1404,6 +1991,19 @@ async function updateOpportunity(input) {
1404
1991
  opportunity: body
1405
1992
  });
1406
1993
  }
1994
+ var batchUpdateOpportunitySchema = z5.object({
1995
+ items: z5.array(updateOpportunitySchema).min(1).max(50).describe(
1996
+ "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."
1997
+ )
1998
+ });
1999
+ async function batchUpdateOpportunity(input, opts = {}) {
2000
+ return batchExecute(
2001
+ "batch_update_opportunity",
2002
+ input.items,
2003
+ (item) => updateOpportunity(item),
2004
+ opts
2005
+ );
2006
+ }
1407
2007
  var deleteOpportunitySchema = z5.object({
1408
2008
  id: z5.number().int().positive(),
1409
2009
  confirm: confirmFlag().describe(
@@ -1449,14 +2049,26 @@ async function getProject(input) {
1449
2049
  return data;
1450
2050
  }
1451
2051
  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."),
2052
+ ids: z6.array(z6.number().int().positive()).min(1).max(50).describe(
2053
+ "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."
2054
+ ),
1453
2055
  embed: z6.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1454
2056
  });
1455
2057
  async function getProjects(input) {
1456
- const { data } = await capsuleGet(`/kases/${input.ids.join(",")}`, {
1457
- embed: input.embed
1458
- });
1459
- return data;
2058
+ const { ids, embed } = input;
2059
+ if (ids.length <= 10) {
2060
+ const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
2061
+ embed
2062
+ });
2063
+ return data;
2064
+ }
2065
+ const chunks = chunk(ids, 10);
2066
+ const responses = await Promise.all(
2067
+ chunks.map(
2068
+ (chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
2069
+ )
2070
+ );
2071
+ return { kases: responses.flatMap((r) => r.data.kases) };
1460
2072
  }
1461
2073
  var createProjectSchema = z6.object({
1462
2074
  name: z6.string().min(1),
@@ -1584,11 +2196,21 @@ async function getTask(input) {
1584
2196
  return data;
1585
2197
  }
1586
2198
  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.")
2199
+ ids: z7.array(z7.number().int().positive()).min(1).max(50).describe(
2200
+ "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."
2201
+ )
1588
2202
  });
1589
2203
  async function getTasks(input) {
1590
- const { data } = await capsuleGet(`/tasks/${input.ids.join(",")}`);
1591
- return data;
2204
+ const { ids } = input;
2205
+ if (ids.length <= 10) {
2206
+ const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
2207
+ return data;
2208
+ }
2209
+ const chunks = chunk(ids, 10);
2210
+ const responses = await Promise.all(
2211
+ chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
2212
+ );
2213
+ return { tasks: responses.flatMap((r) => r.data.tasks) };
1592
2214
  }
1593
2215
  var createTaskSchema = z7.object({
1594
2216
  description: z7.string().min(1),
@@ -1648,6 +2270,14 @@ async function completeTask(input) {
1648
2270
  task: { status: "COMPLETED" }
1649
2271
  });
1650
2272
  }
2273
+ var batchCompleteTaskSchema = z7.object({
2274
+ ids: z7.array(z7.number().int().positive()).min(1).max(50).describe(
2275
+ "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."
2276
+ )
2277
+ });
2278
+ async function batchCompleteTask(input, opts = {}) {
2279
+ return batchExecute("batch_complete_task", input.ids, (id) => completeTask({ id }), opts);
2280
+ }
1651
2281
  var deleteTaskSchema = z7.object({
1652
2282
  id: z7.number().int().positive(),
1653
2283
  confirm: confirmFlag().describe(
@@ -1794,7 +2424,7 @@ var paginationFields = {
1794
2424
  };
1795
2425
  var listPipelinesSchema = z9.object({ ...paginationFields });
1796
2426
  async function listPipelines(input) {
1797
- const { data, nextPage } = await capsuleGet("/pipelines", {
2427
+ const { data, nextPage } = await capsuleGetCached("/pipelines", {
1798
2428
  page: input.page ?? 1,
1799
2429
  perPage: input.perPage ?? 100
1800
2430
  });
@@ -1805,7 +2435,7 @@ var listMilestonesSchema = z9.object({
1805
2435
  ...paginationFields
1806
2436
  });
1807
2437
  async function listMilestones(input) {
1808
- const { data, nextPage } = await capsuleGet(
2438
+ const { data, nextPage } = await capsuleGetCached(
1809
2439
  `/pipelines/${input.pipelineId}/milestones`,
1810
2440
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
1811
2441
  );
@@ -1820,7 +2450,7 @@ var paginationFields2 = {
1820
2450
  };
1821
2451
  var listBoardsSchema = z10.object({ ...paginationFields2 });
1822
2452
  async function listBoards(input) {
1823
- const { data, nextPage } = await capsuleGet("/boards", {
2453
+ const { data, nextPage } = await capsuleGetCached("/boards", {
1824
2454
  page: input.page ?? 1,
1825
2455
  perPage: input.perPage ?? 100
1826
2456
  });
@@ -1834,7 +2464,7 @@ var listStagesSchema = z10.object({
1834
2464
  });
1835
2465
  async function listStages(input) {
1836
2466
  const path = input.boardId !== void 0 ? `/boards/${input.boardId}/stages` : "/stages";
1837
- const { data, nextPage } = await capsuleGet(path, {
2467
+ const { data, nextPage } = await capsuleGetCached(path, {
1838
2468
  page: input.page ?? 1,
1839
2469
  perPage: input.perPage ?? 100
1840
2470
  });
@@ -1861,7 +2491,7 @@ var listTagsSchema = z11.object({
1861
2491
  });
1862
2492
  async function listTags(input) {
1863
2493
  const path = TAG_LIST_PATH[input.entity];
1864
- const { data, nextPage } = await capsuleGet(path, {
2494
+ const { data, nextPage } = await capsuleGetCached(path, {
1865
2495
  page: input.page ?? 1,
1866
2496
  perPage: input.perPage ?? 100
1867
2497
  });
@@ -1877,9 +2507,11 @@ var addTagSchema = z11.object({
1877
2507
  async function addTag(input) {
1878
2508
  const { entity, entityId, tagName } = input;
1879
2509
  const wrapper = ENTITY_TO_WRAPPER[entity];
1880
- return capsulePut(`/${entity}/${entityId}`, {
2510
+ const result = await capsulePut(`/${entity}/${entityId}`, {
1881
2511
  [wrapper]: { tags: [{ name: tagName }] }
1882
2512
  });
2513
+ invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
2514
+ return result;
1883
2515
  }
1884
2516
  var removeTagByIdSchema = z11.object({
1885
2517
  entity: TagEntity,
@@ -1891,17 +2523,17 @@ var removeTagByIdSchema = z11.object({
1891
2523
  async function removeTagById(input) {
1892
2524
  const { entity, entityId, tagId } = input;
1893
2525
  const wrapper = ENTITY_TO_WRAPPER[entity];
1894
- return idempotentWithResult(
2526
+ const result = await idempotentWithResult(
1895
2527
  () => capsulePut(`/${entity}/${entityId}`, {
1896
2528
  [wrapper]: { tags: [{ id: tagId, _delete: true }] }
1897
2529
  }),
1898
- (result) => ({
2530
+ (result2) => ({
1899
2531
  removed: true,
1900
2532
  alreadyRemoved: false,
1901
2533
  entity,
1902
2534
  entityId,
1903
2535
  tagId,
1904
- ...result
2536
+ ...result2
1905
2537
  }),
1906
2538
  () => ({ removed: true, alreadyRemoved: true, entity, entityId, tagId }),
1907
2539
  // Tag detach uses PUT with _delete: true and 422s with "tag not
@@ -1909,6 +2541,24 @@ async function removeTagById(input) {
1909
2541
  // 404. Other 422s with different wording still surface.
1910
2542
  isCapsuleTagNotFound
1911
2543
  );
2544
+ invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
2545
+ return result;
2546
+ }
2547
+ var batchAddTagSchema = z11.object({
2548
+ items: z11.array(addTagSchema).min(1).max(50).describe(
2549
+ "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."
2550
+ )
2551
+ });
2552
+ async function batchAddTag(input, opts = {}) {
2553
+ return batchExecute("batch_add_tag", input.items, (item) => addTag(item), opts);
2554
+ }
2555
+ var batchRemoveTagByIdSchema = z11.object({
2556
+ items: z11.array(removeTagByIdSchema).min(1).max(50).describe(
2557
+ "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."
2558
+ )
2559
+ });
2560
+ async function batchRemoveTagById(input, opts = {}) {
2561
+ return batchExecute("batch_remove_tag_by_id", input.items, (item) => removeTagById(item), opts);
1912
2562
  }
1913
2563
 
1914
2564
  // src/tools/users.ts
@@ -1918,7 +2568,7 @@ var listUsersSchema = z12.object({
1918
2568
  perPage: z12.number().int().min(1).max(100).optional()
1919
2569
  });
1920
2570
  async function listUsers(input) {
1921
- const { data, nextPage } = await capsuleGet("/users", {
2571
+ const { data, nextPage } = await capsuleGetCached("/users", {
1922
2572
  page: input.page ?? 1,
1923
2573
  perPage: input.perPage ?? 100
1924
2574
  });
@@ -1987,7 +2637,7 @@ var paginationFields3 = {
1987
2637
  };
1988
2638
  var listTeamsSchema = z14.object({ ...paginationFields3 });
1989
2639
  async function listTeams(input) {
1990
- const { data, nextPage } = await capsuleGet("/teams", {
2640
+ const { data, nextPage } = await capsuleGetCached("/teams", {
1991
2641
  page: input.page ?? 1,
1992
2642
  perPage: input.perPage ?? 100
1993
2643
  });
@@ -1995,7 +2645,7 @@ async function listTeams(input) {
1995
2645
  }
1996
2646
  var listLostReasonsSchema = z14.object({ ...paginationFields3 });
1997
2647
  async function listLostReasons(input) {
1998
- const { data, nextPage } = await capsuleGet("/lostreasons", {
2648
+ const { data, nextPage } = await capsuleGetCached("/lostreasons", {
1999
2649
  page: input.page ?? 1,
2000
2650
  perPage: input.perPage ?? 100
2001
2651
  });
@@ -2003,20 +2653,23 @@ async function listLostReasons(input) {
2003
2653
  }
2004
2654
  var listActivityTypesSchema = z14.object({ ...paginationFields3 });
2005
2655
  async function listActivityTypes(input) {
2006
- const { data, nextPage } = await capsuleGet("/activitytypes", {
2007
- page: input.page ?? 1,
2008
- perPage: input.perPage ?? 100
2009
- });
2656
+ const { data, nextPage } = await capsuleGetCached(
2657
+ "/activitytypes",
2658
+ {
2659
+ page: input.page ?? 1,
2660
+ perPage: input.perPage ?? 100
2661
+ }
2662
+ );
2010
2663
  return { ...data, nextPage };
2011
2664
  }
2012
2665
  var getSiteSchema = z14.object({});
2013
2666
  async function getSite(_input) {
2014
- const { data } = await capsuleGet("/site");
2667
+ const { data } = await capsuleGetCached("/site");
2015
2668
  return data;
2016
2669
  }
2017
2670
  var listTrackDefinitionsSchema = z14.object({ ...paginationFields3 });
2018
2671
  async function listTrackDefinitions(input) {
2019
- const { data, nextPage } = await capsuleGet(
2672
+ const { data, nextPage } = await capsuleGetCached(
2020
2673
  "/trackdefinitions",
2021
2674
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
2022
2675
  );
@@ -2024,7 +2677,7 @@ async function listTrackDefinitions(input) {
2024
2677
  }
2025
2678
  var listCategoriesSchema = z14.object({ ...paginationFields3 });
2026
2679
  async function listCategories(input) {
2027
- const { data, nextPage } = await capsuleGet("/categories", {
2680
+ const { data, nextPage } = await capsuleGetCached("/categories", {
2028
2681
  page: input.page ?? 1,
2029
2682
  perPage: input.perPage ?? 100
2030
2683
  });
@@ -2032,7 +2685,7 @@ async function listCategories(input) {
2032
2685
  }
2033
2686
  var listGoalsSchema = z14.object({ ...paginationFields3 });
2034
2687
  async function listGoals(input) {
2035
- const { data, nextPage } = await capsuleGet("/goals", {
2688
+ const { data, nextPage } = await capsuleGetCached("/goals", {
2036
2689
  page: input.page ?? 1,
2037
2690
  perPage: input.perPage ?? 100
2038
2691
  });
@@ -2191,7 +2844,7 @@ var listCustomFieldsSchema = z17.object({
2191
2844
  entity: CustomFieldEntity
2192
2845
  });
2193
2846
  async function listCustomFields(input) {
2194
- const { data } = await capsuleGet(
2847
+ const { data } = await capsuleGetCached(
2195
2848
  `/${input.entity}/fields/definitions`
2196
2849
  );
2197
2850
  return data;
@@ -2201,7 +2854,7 @@ var getCustomFieldSchema = z17.object({
2201
2854
  fieldId: z17.number().int().positive().describe("Custom field definition id.")
2202
2855
  });
2203
2856
  async function getCustomField(input) {
2204
- const { data } = await capsuleGet(
2857
+ const { data } = await capsuleGetCached(
2205
2858
  `/${input.entity}/fields/definitions/${input.fieldId}`
2206
2859
  );
2207
2860
  return data;
@@ -2372,7 +3025,7 @@ var listSavedFiltersSchema = z20.object({
2372
3025
  entity: EntitySchema
2373
3026
  });
2374
3027
  async function listSavedFilters(input) {
2375
- const { data } = await capsuleGet(`/${input.entity}/filters`);
3028
+ const { data } = await capsuleGetCached(`/${input.entity}/filters`);
2376
3029
  return data;
2377
3030
  }
2378
3031
  var runSavedFilterSchema = z20.object({
@@ -2391,15 +3044,35 @@ async function runSavedFilter(input) {
2391
3044
  }
2392
3045
 
2393
3046
  // src/server.ts
2394
- function createCapsuleMcpServer() {
3047
+ function createCapsuleMcpServer(opts) {
2395
3048
  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
- });
3049
+ const tasksCfg = getTasksConfig();
3050
+ const tasksWired = tasksCfg.enabled && !!opts?.clientId;
3051
+ const server = new McpServer(
3052
+ {
3053
+ name: "capsulemcp",
3054
+ version: "1.6.0",
3055
+ description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
3056
+ websiteUrl: "https://github.com/soil-dev/capsulemcp",
3057
+ icons: ICONS
3058
+ },
3059
+ tasksWired ? {
3060
+ // tasksWired guards clientId presence; narrow explicitly
3061
+ // for the type-checker rather than using `!`.
3062
+ taskStore: createScopedTaskStore(opts?.clientId ?? ""),
3063
+ capabilities: {
3064
+ tasks: {
3065
+ // The SDK's task capability schema uses {} for "present"
3066
+ // markers, not booleans — see ServerTasksCapabilitySchema
3067
+ // in @modelcontextprotocol/sdk types.ts.
3068
+ list: {},
3069
+ cancel: {},
3070
+ requests: { tools: { call: {} } }
3071
+ }
3072
+ }
3073
+ } : void 0
3074
+ );
3075
+ const registerBatchTool = tasksWired ? registerToolTask : (s, name, description, schema, handler) => registerTool(s, name, description, schema, (input) => handler(input, {}));
2403
3076
  registerTool(
2404
3077
  server,
2405
3078
  "search_parties",
@@ -2417,14 +3090,14 @@ function createCapsuleMcpServer() {
2417
3090
  registerTool(
2418
3091
  server,
2419
3092
  "get_party",
2420
- "Fetch a single party (person or organisation) by its numeric ID.",
3093
+ "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
3094
  getPartySchema,
2422
3095
  getParty
2423
3096
  );
2424
3097
  registerTool(
2425
3098
  server,
2426
3099
  "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.",
3100
+ "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
3101
  getPartiesSchema,
2429
3102
  getParties
2430
3103
  );
@@ -2485,6 +3158,13 @@ function createCapsuleMcpServer() {
2485
3158
  updatePartySchema,
2486
3159
  updateParty
2487
3160
  );
3161
+ registerBatchTool(
3162
+ server,
3163
+ "batch_update_party",
3164
+ "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.",
3165
+ batchUpdatePartySchema,
3166
+ batchUpdateParty
3167
+ );
2488
3168
  registerTool(
2489
3169
  server,
2490
3170
  "delete_party",
@@ -2566,14 +3246,14 @@ function createCapsuleMcpServer() {
2566
3246
  registerTool(
2567
3247
  server,
2568
3248
  "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.",
3249
+ "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
3250
  getOpportunitySchema,
2571
3251
  getOpportunity
2572
3252
  );
2573
3253
  registerTool(
2574
3254
  server,
2575
3255
  "get_opportunities",
2576
- "Batch-fetch up to 10 opportunities by ID in a single call.",
3256
+ "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
3257
  getOpportunitiesSchema,
2578
3258
  getOpportunities
2579
3259
  );
@@ -2613,6 +3293,13 @@ function createCapsuleMcpServer() {
2613
3293
  updateOpportunitySchema,
2614
3294
  updateOpportunity
2615
3295
  );
3296
+ registerBatchTool(
3297
+ server,
3298
+ "batch_update_opportunity",
3299
+ "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.",
3300
+ batchUpdateOpportunitySchema,
3301
+ batchUpdateOpportunity
3302
+ );
2616
3303
  registerTool(
2617
3304
  server,
2618
3305
  "delete_opportunity",
@@ -2638,14 +3325,14 @@ function createCapsuleMcpServer() {
2638
3325
  registerTool(
2639
3326
  server,
2640
3327
  "get_project",
2641
- "Fetch a single project (case) by its numeric ID.",
3328
+ "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
3329
  getProjectSchema,
2643
3330
  getProject
2644
3331
  );
2645
3332
  registerTool(
2646
3333
  server,
2647
3334
  "get_projects",
2648
- "Batch-fetch up to 10 projects (cases) by ID in a single call.",
3335
+ "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
3336
  getProjectsSchema,
2650
3337
  getProjects
2651
3338
  );
@@ -2724,14 +3411,14 @@ function createCapsuleMcpServer() {
2724
3411
  registerTool(
2725
3412
  server,
2726
3413
  "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.",
3414
+ "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
3415
  getTaskSchema,
2729
3416
  getTask
2730
3417
  );
2731
3418
  registerTool(
2732
3419
  server,
2733
3420
  "get_tasks",
2734
- "Batch-fetch up to 10 tasks by ID in a single call.",
3421
+ "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
3422
  getTasksSchema,
2736
3423
  getTasks
2737
3424
  );
@@ -2746,7 +3433,7 @@ function createCapsuleMcpServer() {
2746
3433
  registerTool(
2747
3434
  server,
2748
3435
  "update_task",
2749
- "Update fields on an existing task. Only the fields you provide are changed. To mark a task done prefer complete_task.",
3436
+ "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
3437
  updateTaskSchema,
2751
3438
  updateTask
2752
3439
  );
@@ -2757,6 +3444,13 @@ function createCapsuleMcpServer() {
2757
3444
  completeTaskSchema,
2758
3445
  completeTask
2759
3446
  );
3447
+ registerBatchTool(
3448
+ server,
3449
+ "batch_complete_task",
3450
+ "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.",
3451
+ batchCompleteTaskSchema,
3452
+ batchCompleteTask
3453
+ );
2760
3454
  registerTool(
2761
3455
  server,
2762
3456
  "delete_task",
@@ -2768,7 +3462,7 @@ function createCapsuleMcpServer() {
2768
3462
  registerTool(
2769
3463
  server,
2770
3464
  "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.",
3465
+ "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
3466
  listPartyEntriesSchema,
2773
3467
  listPartyEntries
2774
3468
  );
@@ -2789,7 +3483,7 @@ function createCapsuleMcpServer() {
2789
3483
  registerTool(
2790
3484
  server,
2791
3485
  "get_entry",
2792
- "Fetch a single timeline entry by its numeric ID. Returns full content (note body, email subject + body, etc.).",
3486
+ "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
3487
  getEntrySchema,
2794
3488
  getEntry
2795
3489
  );
@@ -2804,6 +3498,10 @@ function createCapsuleMcpServer() {
2804
3498
  "get_attachment",
2805
3499
  "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
3500
  getAttachmentSchema.shape,
3501
+ // get_attachment is read-only — downloads a binary, never mutates.
3502
+ // Mirrors the auto-inferred `readOnlyHint: true` that
3503
+ // `registerTool` applies to every other `get_*` tool.
3504
+ { readOnlyHint: true },
2807
3505
  async (input) => {
2808
3506
  const result = await getAttachment(input);
2809
3507
  if (result.truncated) {
@@ -2931,7 +3629,7 @@ function createCapsuleMcpServer() {
2931
3629
  registerTool(
2932
3630
  server,
2933
3631
  "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.",
3632
+ "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
3633
  listStagesSchema,
2936
3634
  listStages
2937
3635
  );
@@ -2945,7 +3643,7 @@ function createCapsuleMcpServer() {
2945
3643
  registerTool(
2946
3644
  server,
2947
3645
  "list_lostreasons",
2948
- "List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor'). Useful for analysing closed-lost opportunities by reason.",
3646
+ "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
3647
  listLostReasonsSchema,
2950
3648
  listLostReasons
2951
3649
  );
@@ -2959,7 +3657,7 @@ function createCapsuleMcpServer() {
2959
3657
  registerTool(
2960
3658
  server,
2961
3659
  "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.",
3660
+ "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
3661
  listCategoriesSchema,
2964
3662
  listCategories
2965
3663
  );
@@ -3034,6 +3732,20 @@ function createCapsuleMcpServer() {
3034
3732
  removeTagByIdSchema,
3035
3733
  removeTagById
3036
3734
  );
3735
+ registerBatchTool(
3736
+ server,
3737
+ "batch_add_tag",
3738
+ "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.",
3739
+ batchAddTagSchema,
3740
+ batchAddTag
3741
+ );
3742
+ registerBatchTool(
3743
+ server,
3744
+ "batch_remove_tag_by_id",
3745
+ "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} }.",
3746
+ batchRemoveTagByIdSchema,
3747
+ batchRemoveTagById
3748
+ );
3037
3749
  }
3038
3750
  registerTool(
3039
3751
  server,
@@ -3059,6 +3771,20 @@ function secretDigest(value) {
3059
3771
  function timingSafeSecretEqual(provided, expected) {
3060
3772
  return timingSafeEqual3(secretDigest(provided), secretDigest(expected));
3061
3773
  }
3774
+ var DEFAULT_MCP_RATE_LIMIT_WINDOW_MS = 6e4;
3775
+ var DEFAULT_MCP_RATE_LIMIT_MAX = 600;
3776
+ var MAX_MEMORY_STORE_WINDOW_MS = 2 ** 31 - 1;
3777
+ function resolveMcpRateLimitConfig() {
3778
+ const windowMs = Math.min(
3779
+ readPositiveInt("MCP_HTTP_RATE_LIMIT_WINDOW_MS", DEFAULT_MCP_RATE_LIMIT_WINDOW_MS),
3780
+ MAX_MEMORY_STORE_WINDOW_MS
3781
+ );
3782
+ return {
3783
+ windowMs,
3784
+ limit: readPositiveInt("MCP_HTTP_RATE_LIMIT_MAX", DEFAULT_MCP_RATE_LIMIT_MAX),
3785
+ disabled: process.env["MCP_HTTP_RATE_LIMIT_DISABLED"] === "1"
3786
+ };
3787
+ }
3062
3788
  function createApp(opts) {
3063
3789
  const { oauthProvider: oauthProvider2, issuerUrl: issuerUrl2, jsonLimit: jsonLimit2, allowedOrigins } = opts;
3064
3790
  const resourceName = opts.resourceName ?? "Capsule CRM MCP";
@@ -3150,9 +3876,11 @@ function createApp(opts) {
3150
3876
  }
3151
3877
  next();
3152
3878
  };
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";
3879
+ const {
3880
+ windowMs: rateLimitWindowMs,
3881
+ limit: rateLimitMax,
3882
+ disabled: rateLimitDisabled
3883
+ } = resolveMcpRateLimitConfig();
3156
3884
  const mcpRateLimit = rateLimit({
3157
3885
  windowMs: rateLimitWindowMs,
3158
3886
  limit: rateLimitMax,
@@ -3160,7 +3888,8 @@ function createApp(opts) {
3160
3888
  legacyHeaders: false,
3161
3889
  keyGenerator: (req) => {
3162
3890
  const clientId = req.auth?.clientId;
3163
- return clientId ?? req.ip ?? "unknown";
3891
+ if (clientId) return clientId;
3892
+ return ipKeyGenerator(req.ip ?? "");
3164
3893
  },
3165
3894
  skip: () => rateLimitDisabled,
3166
3895
  handler: (_req, res) => {
@@ -3198,14 +3927,17 @@ function createApp(opts) {
3198
3927
  express.json({ limit: jsonLimit2 }),
3199
3928
  async (req, res) => {
3200
3929
  try {
3201
- const server = createCapsuleMcpServer();
3930
+ const clientId = req.auth?.clientId;
3931
+ const server = createCapsuleMcpServer({ clientId });
3202
3932
  const transport = new StreamableHTTPServerTransport({});
3203
3933
  res.on("close", () => {
3204
3934
  void transport.close();
3205
3935
  void server.close();
3206
3936
  });
3207
- await server.connect(transport);
3208
- await transport.handleRequest(req, res, req.body);
3937
+ await withRequestContext({ clientId }, async () => {
3938
+ await server.connect(transport);
3939
+ await transport.handleRequest(req, res, req.body);
3940
+ });
3209
3941
  } catch (err) {
3210
3942
  const name = err instanceof Error ? err.name : typeof err;
3211
3943
  const status = err && typeof err === "object" && "status" in err ? Number(err.status) : void 0;