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