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