capsulemcp 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js CHANGED
@@ -156,6 +156,26 @@ function invalidateByPrefix(pathPrefix, trigger) {
156
156
  }
157
157
  }
158
158
 
159
+ // src/capsule/normalize.ts
160
+ var KEY_RENAMES = {
161
+ kase: "project",
162
+ kases: "projects",
163
+ restrictedKases: "restrictedProjects"
164
+ };
165
+ function normalizeProjectKeys(value) {
166
+ if (Array.isArray(value)) {
167
+ return value.map(normalizeProjectKeys);
168
+ }
169
+ if (value !== null && typeof value === "object") {
170
+ const out = {};
171
+ for (const [key, v] of Object.entries(value)) {
172
+ out[KEY_RENAMES[key] ?? key] = normalizeProjectKeys(v);
173
+ }
174
+ return out;
175
+ }
176
+ return value;
177
+ }
178
+
159
179
  // src/capsule/client.ts
160
180
  var DEFAULT_BASE_URL = "https://api.capsulecrm.com/api/v2";
161
181
  function baseUrl() {
@@ -271,38 +291,31 @@ async function parseErrorBody(res) {
271
291
  }
272
292
  }
273
293
  var REQUEST_TIMEOUT_MS = 6e4;
274
- function withTimeout(options) {
275
- if (options && options.signal !== void 0) {
276
- return { options, cleanup: () => {
277
- } };
278
- }
279
- const controller = new AbortController();
280
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
281
- timer.unref();
282
- return {
283
- options: { ...options ?? {}, signal: controller.signal },
284
- cleanup: () => clearTimeout(timer)
285
- };
294
+ function isTimeoutAbort(err) {
295
+ return err instanceof Error && // AbortSignal.timeout rejects with a DOMException named
296
+ // "TimeoutError"; plain aborts (and older undici paths) surface
297
+ // as "AbortError" or carry "aborted" in the message.
298
+ (err.name === "TimeoutError" || err.name === "AbortError" || /aborted/i.test(err.message));
286
299
  }
287
300
  async function mapAbort(p) {
288
301
  try {
289
302
  return await p;
290
303
  } catch (err) {
291
- if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
304
+ if (isTimeoutAbort(err)) {
292
305
  throw new CapsuleTimeoutError();
293
306
  }
294
307
  throw err;
295
308
  }
296
309
  }
297
310
  async function fetchWithTimeout(url, options) {
298
- const { options: opts, cleanup } = withTimeout(options);
299
311
  const startedAt = Date.now();
300
312
  try {
301
- const res = await fetch(url, opts);
302
- return { res, cleanup };
313
+ return await fetch(url, {
314
+ ...options ?? {},
315
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
316
+ });
303
317
  } catch (err) {
304
- cleanup();
305
- const isAbort = err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message));
318
+ const isAbort = isTimeoutAbort(err);
306
319
  emitCapsuleFailure(
307
320
  options?.method ?? "GET",
308
321
  url,
@@ -326,24 +339,22 @@ async function doFetch(url, options) {
326
339
  const startedAt = Date.now();
327
340
  const method = options?.method ?? "GET";
328
341
  const first = await fetchWithTimeout(url, options);
329
- if (first.res.status === 429) {
330
- const delay = parseRateLimitDelay(first.res);
331
- first.cleanup();
332
- await drainBody(first.res);
342
+ if (first.status === 429) {
343
+ const delay = parseRateLimitDelay(first);
344
+ await drainBody(first);
333
345
  await new Promise((resolve) => setTimeout(resolve, delay));
334
346
  const retried = await fetchWithTimeout(url, options);
335
- if (retried.res.status === 429) {
336
- retried.cleanup();
337
- await drainBody(retried.res);
347
+ if (retried.status === 429) {
348
+ await drainBody(retried);
338
349
  emitCapsuleRateLimited(method, url, Date.now() - startedAt);
339
350
  throw new CapsuleApiError(
340
351
  429,
341
352
  "Rate limit exceeded after one retry. Please slow down your requests."
342
353
  );
343
354
  }
344
- return { ...retried, startedAt, method, url, retriedAfter429: true };
355
+ return { res: retried, startedAt, method, url, retriedAfter429: true };
345
356
  }
346
- return { ...first, startedAt, method, url, retriedAfter429: false };
357
+ return { res: first, startedAt, method, url, retriedAfter429: false };
347
358
  }
348
359
  async function consumeBody(start, body) {
349
360
  try {
@@ -436,7 +447,8 @@ async function throwForStatus(res) {
436
447
  }
437
448
  async function handleResponse(res) {
438
449
  await throwForStatus(res);
439
- return mapAbort(res.json());
450
+ const body = await mapAbort(res.json());
451
+ return normalizeProjectKeys(body);
440
452
  }
441
453
  function buildUrl(path, params) {
442
454
  const url = new URL(`${baseUrl()}${path}`);
@@ -453,15 +465,19 @@ async function capsuleGet(path, params) {
453
465
  const token = getToken();
454
466
  const url = buildUrl(path, params);
455
467
  const start = await doFetch(url, { headers: baseHeaders(token) });
456
- try {
457
- return await consumeBody(start, async () => {
458
- const data = await handleResponse(start.res);
459
- const nextPage = parseNextPage(start.res.headers.get("Link"));
460
- return { data, nextPage };
461
- });
462
- } finally {
463
- start.cleanup();
464
- }
468
+ return consumeBody(start, async () => {
469
+ const data = await handleResponse(start.res);
470
+ const nextPage = parseNextPage(start.res.headers.get("Link"));
471
+ return { data, nextPage };
472
+ });
473
+ }
474
+ async function capsuleGetList(path, params) {
475
+ const { data, nextPage } = await capsuleGet(path, params);
476
+ return { ...data, nextPage };
477
+ }
478
+ async function capsuleGetCachedList(path, params) {
479
+ const { data, nextPage } = await capsuleGetCached(path, params);
480
+ return { ...data, nextPage };
465
481
  }
466
482
  async function capsuleGetCached(path, params) {
467
483
  if (cacheDisabled()) return capsuleGet(path, params);
@@ -500,11 +516,7 @@ async function capsulePost(path, body) {
500
516
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
501
517
  body: JSON.stringify(body)
502
518
  });
503
- try {
504
- return await consumeBody(start, () => handleResponse(start.res));
505
- } finally {
506
- start.cleanup();
507
- }
519
+ return consumeBody(start, () => handleResponse(start.res));
508
520
  }
509
521
  async function capsulePostNoContent(path) {
510
522
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
@@ -514,15 +526,11 @@ async function capsulePostNoContent(path) {
514
526
  method: "POST",
515
527
  headers: baseHeaders(token)
516
528
  });
517
- try {
518
- await consumeBody(start, async () => {
519
- if (start.res.status === 204) return;
520
- await throwForStatus(start.res);
521
- await mapAbort(start.res.text());
522
- });
523
- } finally {
524
- start.cleanup();
525
- }
529
+ await consumeBody(start, async () => {
530
+ if (start.res.status === 204) return;
531
+ await throwForStatus(start.res);
532
+ await mapAbort(start.res.text());
533
+ });
526
534
  }
527
535
  async function capsuleSearch(path, body, params) {
528
536
  const token = getToken();
@@ -532,15 +540,11 @@ async function capsuleSearch(path, body, params) {
532
540
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
533
541
  body: JSON.stringify(body)
534
542
  });
535
- try {
536
- return await consumeBody(start, async () => {
537
- const data = await handleResponse(start.res);
538
- const nextPage = parseNextPage(start.res.headers.get("Link"));
539
- return { data, nextPage };
540
- });
541
- } finally {
542
- start.cleanup();
543
- }
543
+ return consumeBody(start, async () => {
544
+ const data = await handleResponse(start.res);
545
+ const nextPage = parseNextPage(start.res.headers.get("Link"));
546
+ return { data, nextPage };
547
+ });
544
548
  }
545
549
  async function capsulePut(path, body) {
546
550
  if (isReadOnly()) throw new CapsuleReadOnlyError("PUT");
@@ -551,68 +555,60 @@ async function capsulePut(path, body) {
551
555
  headers: { ...baseHeaders(token), "Content-Type": "application/json" },
552
556
  body: JSON.stringify(body)
553
557
  });
554
- try {
555
- return await consumeBody(start, () => handleResponse(start.res));
556
- } finally {
557
- start.cleanup();
558
- }
558
+ return consumeBody(start, () => handleResponse(start.res));
559
559
  }
560
560
  async function capsuleGetBinary(path, maxBytes) {
561
561
  const token = getToken();
562
562
  const url = buildUrl(path);
563
563
  const start = await doFetch(url, { headers: baseHeaders(token) });
564
- try {
565
- return await consumeBody(start, async () => {
566
- const res = start.res;
567
- await throwForStatus(res);
568
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
569
- const declared = res.headers.get("Content-Length");
570
- const declaredBytes = declared ? Number(declared) : NaN;
571
- if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
572
- if (res.body) await res.body.cancel().catch(() => {
573
- });
564
+ return consumeBody(start, async () => {
565
+ const res = start.res;
566
+ await throwForStatus(res);
567
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
568
+ const declared = res.headers.get("Content-Length");
569
+ const declaredBytes = declared ? Number(declared) : NaN;
570
+ if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
571
+ if (res.body) await res.body.cancel().catch(() => {
572
+ });
573
+ return {
574
+ contentType,
575
+ buffer: Buffer.alloc(0),
576
+ truncated: true,
577
+ sizeBytes: declaredBytes
578
+ };
579
+ }
580
+ if (maxBytes !== void 0 && res.body) {
581
+ const reader = res.body.getReader();
582
+ const chunks = [];
583
+ let total = 0;
584
+ let truncated = false;
585
+ while (true) {
586
+ const { done, value } = await mapAbort(reader.read());
587
+ if (done) break;
588
+ total += value.byteLength;
589
+ if (total > maxBytes) {
590
+ truncated = true;
591
+ await reader.cancel().catch(() => {
592
+ });
593
+ break;
594
+ }
595
+ chunks.push(value);
596
+ }
597
+ if (truncated) {
574
598
  return {
575
599
  contentType,
576
600
  buffer: Buffer.alloc(0),
577
601
  truncated: true,
578
- sizeBytes: declaredBytes
602
+ sizeBytes: total
579
603
  };
580
604
  }
581
- if (maxBytes !== void 0 && res.body) {
582
- const reader = res.body.getReader();
583
- const chunks = [];
584
- let total = 0;
585
- let truncated = false;
586
- while (true) {
587
- const { done, value } = await mapAbort(reader.read());
588
- if (done) break;
589
- total += value.byteLength;
590
- if (total > maxBytes) {
591
- truncated = true;
592
- await reader.cancel().catch(() => {
593
- });
594
- break;
595
- }
596
- chunks.push(value);
597
- }
598
- if (truncated) {
599
- return {
600
- contentType,
601
- buffer: Buffer.alloc(0),
602
- truncated: true,
603
- sizeBytes: total
604
- };
605
- }
606
- const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
607
- return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
608
- }
609
- const arrayBuffer = await mapAbort(res.arrayBuffer());
610
- const buffer = Buffer.from(arrayBuffer);
611
- return { contentType, buffer, sizeBytes: buffer.length };
612
- });
613
- } finally {
614
- start.cleanup();
615
- }
605
+ const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
606
+ return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
607
+ }
608
+ const arrayBuffer = await mapAbort(res.arrayBuffer());
609
+ const buffer = Buffer.from(arrayBuffer);
610
+ return { contentType, buffer, sizeBytes: buffer.length };
611
+ });
616
612
  }
617
613
  async function capsulePostBinary(path, body, contentType, filename) {
618
614
  if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
@@ -628,11 +624,7 @@ async function capsulePostBinary(path, body, contentType, filename) {
628
624
  },
629
625
  body
630
626
  });
631
- try {
632
- return await consumeBody(start, () => handleResponse(start.res));
633
- } finally {
634
- start.cleanup();
635
- }
627
+ return consumeBody(start, () => handleResponse(start.res));
636
628
  }
637
629
  async function capsuleDelete(path) {
638
630
  if (isReadOnly()) throw new CapsuleReadOnlyError("DELETE");
@@ -642,15 +634,11 @@ async function capsuleDelete(path) {
642
634
  method: "DELETE",
643
635
  headers: baseHeaders(token)
644
636
  });
645
- try {
646
- await consumeBody(start, async () => {
647
- if (start.res.status === 204) return;
648
- await throwForStatus(start.res);
649
- await mapAbort(start.res.text());
650
- });
651
- } finally {
652
- start.cleanup();
653
- }
637
+ await consumeBody(start, async () => {
638
+ if (start.res.status === 204) return;
639
+ await throwForStatus(start.res);
640
+ await mapAbort(start.res.text());
641
+ });
654
642
  }
655
643
 
656
644
  // src/auth/provider.ts
@@ -1171,6 +1159,45 @@ var ICONS = [
1171
1159
  }
1172
1160
  ];
1173
1161
 
1162
+ // src/server/tier.ts
1163
+ var CORE_TOOLS = /* @__PURE__ */ new Set([
1164
+ // Parties
1165
+ "search_parties",
1166
+ "filter_parties",
1167
+ "get_party",
1168
+ "create_party",
1169
+ "update_party",
1170
+ "list_party_entries",
1171
+ // Opportunities
1172
+ "search_opportunities",
1173
+ "filter_opportunities",
1174
+ "get_opportunity",
1175
+ "create_opportunity",
1176
+ "update_opportunity",
1177
+ // Projects
1178
+ "search_projects",
1179
+ "filter_projects",
1180
+ "list_projects",
1181
+ "get_project",
1182
+ "create_project",
1183
+ "update_project",
1184
+ // Tasks
1185
+ "list_tasks",
1186
+ "get_task",
1187
+ "create_task",
1188
+ "update_task",
1189
+ "complete_task",
1190
+ // Timeline + tags + identity
1191
+ "add_note",
1192
+ "list_tags",
1193
+ "add_tag",
1194
+ "get_current_user"
1195
+ ]);
1196
+ function shouldRegister(name) {
1197
+ if (process.env["CAPSULE_MCP_TIER"] !== "core") return true;
1198
+ return CORE_TOOLS.has(name);
1199
+ }
1200
+
1174
1201
  // src/tasks/store.ts
1175
1202
  import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
1176
1203
  import {
@@ -1379,27 +1406,25 @@ function wrapAsText(result) {
1379
1406
  };
1380
1407
  }
1381
1408
  function registerTool(server, name, description, schema, handler) {
1409
+ if (!shouldRegister(name)) return;
1382
1410
  const registerWithSchema = server.registerTool.bind(server);
1383
1411
  const annotations = inferAnnotations(name);
1384
- registerWithSchema(
1385
- name,
1386
- { description, inputSchema: schema, ...annotations ? { annotations } : {} },
1387
- async (input) => {
1388
- const startedAt = Date.now();
1389
- const argFields = argFieldNames(input);
1390
- const clientId = getRequestContext()?.clientId;
1391
- try {
1392
- const result = await handler(input);
1393
- emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
1394
- return wrapAsText(result);
1395
- } catch (err) {
1396
- emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
1397
- throw err;
1398
- }
1412
+ registerWithSchema(name, { description, inputSchema: schema, annotations }, async (input) => {
1413
+ const startedAt = Date.now();
1414
+ const argFields = argFieldNames(input);
1415
+ const clientId = getRequestContext()?.clientId;
1416
+ try {
1417
+ const result = await handler(input);
1418
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
1419
+ return wrapAsText(result);
1420
+ } catch (err) {
1421
+ emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
1422
+ throw err;
1399
1423
  }
1400
- );
1424
+ });
1401
1425
  }
1402
1426
  function registerToolTask(server, name, description, schema, handler) {
1427
+ if (!shouldRegister(name)) return;
1403
1428
  const registerWithSchema = server.experimental.tasks.registerToolTask.bind(
1404
1429
  server.experimental.tasks
1405
1430
  );
@@ -1410,7 +1435,7 @@ function registerToolTask(server, name, description, schema, handler) {
1410
1435
  description,
1411
1436
  inputSchema: schema,
1412
1437
  execution: { taskSupport: "optional" },
1413
- ...annotations ? { annotations } : {}
1438
+ annotations
1414
1439
  },
1415
1440
  {
1416
1441
  createTask: async (input, extra) => {
@@ -1470,7 +1495,7 @@ function registerToolTask(server, name, description, schema, handler) {
1470
1495
  }
1471
1496
 
1472
1497
  // src/tools/parties.ts
1473
- import { z as z7 } from "zod";
1498
+ import { z as z8 } from "zod";
1474
1499
 
1475
1500
  // src/tools/body-helpers.ts
1476
1501
  function setRef(body, key, id) {
@@ -1480,9 +1505,22 @@ function setNullableRef(body, key, id) {
1480
1505
  if (id === null) body[key] = null;
1481
1506
  else if (id !== void 0) body[key] = { id };
1482
1507
  }
1508
+ function assertSingleParentRef(toolName, refs, opts = {}) {
1509
+ const set = [refs.partyId, refs.opportunityId, refs.projectId].filter(
1510
+ (v) => typeof v === "number"
1511
+ ).length;
1512
+ if (opts.required && set !== 1) {
1513
+ throw new Error(`${toolName}: provide exactly one of partyId, opportunityId, or projectId`);
1514
+ }
1515
+ if (set > 1) {
1516
+ throw new Error(
1517
+ `${toolName}: provide at most one of partyId, opportunityId, or projectId \u2014 Capsule allows a record to be related to at most one entity`
1518
+ );
1519
+ }
1520
+ }
1483
1521
 
1484
1522
  // src/tools/define-batch.ts
1485
- import { z as z2 } from "zod";
1523
+ import { z as z3 } from "zod";
1486
1524
 
1487
1525
  // src/capsule/batch.ts
1488
1526
  function chunk(arr, size) {
@@ -1501,37 +1539,43 @@ function getBatchConcurrency() {
1501
1539
  MAX_CONCURRENCY
1502
1540
  );
1503
1541
  }
1504
- async function batchExecute(tool, items, action, options = {}) {
1505
- const concurrency = getBatchConcurrency();
1542
+ async function mapWithConcurrency(items, limit, fn) {
1506
1543
  const results = new Array(items.length);
1507
- const startedAt = Date.now();
1508
- const signal = options.signal;
1509
1544
  let cursor = 0;
1510
1545
  async function worker() {
1511
1546
  while (true) {
1512
1547
  const i = cursor;
1513
1548
  cursor += 1;
1514
1549
  if (i >= items.length) return;
1515
- if (signal?.aborted) {
1516
- results[i] = {
1517
- ok: false,
1518
- error: { message: "cancelled by tasks/cancel" }
1519
- };
1520
- continue;
1521
- }
1522
- try {
1523
- const result = await action(items[i], i);
1524
- results[i] = { ok: true, result };
1525
- } catch (err) {
1526
- results[i] = { ok: false, error: extractError(err) };
1527
- }
1550
+ results[i] = await fn(items[i], i);
1528
1551
  }
1529
1552
  }
1530
1553
  const workers = [];
1531
- for (let w = 0; w < Math.min(concurrency, items.length); w++) {
1554
+ for (let w = 0; w < Math.min(limit, items.length); w++) {
1532
1555
  workers.push(worker());
1533
1556
  }
1534
1557
  await Promise.all(workers);
1558
+ return results;
1559
+ }
1560
+ async function batchExecute(tool, items, action, options = {}) {
1561
+ const concurrency = getBatchConcurrency();
1562
+ const startedAt = Date.now();
1563
+ const signal = options.signal;
1564
+ const results = await mapWithConcurrency(
1565
+ items,
1566
+ concurrency,
1567
+ async (item, i) => {
1568
+ if (signal?.aborted) {
1569
+ return { ok: false, error: { message: "cancelled by tasks/cancel" } };
1570
+ }
1571
+ try {
1572
+ const result = await action(item, i);
1573
+ return { ok: true, result };
1574
+ } catch (err) {
1575
+ return { ok: false, error: extractError(err) };
1576
+ }
1577
+ }
1578
+ );
1535
1579
  const succeeded = results.filter((r) => r.ok).length;
1536
1580
  const failed = results.length - succeeded;
1537
1581
  const summary = { total: results.length, succeeded, failed };
@@ -1576,10 +1620,52 @@ function topFailureReasons(results, n) {
1576
1620
  return Array.from(counts.values()).sort((a, b) => b.count - a.count).slice(0, n);
1577
1621
  }
1578
1622
 
1623
+ // src/tools/strip-descriptions.ts
1624
+ import { z as z2 } from "zod";
1625
+ function cloneWithDef(node, patch) {
1626
+ const def = node.def;
1627
+ return node.clone({ ...def, ...patch });
1628
+ }
1629
+ function stripDescriptions(schema) {
1630
+ let node = schema;
1631
+ if (node instanceof z2.ZodObject) {
1632
+ const shape = node.def.shape;
1633
+ const next = {};
1634
+ let changed = false;
1635
+ for (const [key, child] of Object.entries(shape)) {
1636
+ next[key] = stripDescriptions(child);
1637
+ if (next[key] !== child) changed = true;
1638
+ }
1639
+ if (changed) node = cloneWithDef(node, { shape: next });
1640
+ } else if (node instanceof z2.ZodArray) {
1641
+ const element = stripDescriptions(node.def.element);
1642
+ if (element !== node.def.element) node = cloneWithDef(node, { element });
1643
+ } else if (node instanceof z2.ZodOptional || node instanceof z2.ZodNullable || node instanceof z2.ZodDefault || node instanceof z2.ZodReadonly) {
1644
+ const innerType = stripDescriptions(node.def.innerType);
1645
+ if (innerType !== node.def.innerType) node = cloneWithDef(node, { innerType });
1646
+ } else if (node instanceof z2.ZodUnion) {
1647
+ const options = node.def.options.map(stripDescriptions);
1648
+ if (options.some((o, i) => o !== node.def.options[i])) {
1649
+ node = cloneWithDef(node, { options });
1650
+ }
1651
+ } else if (node instanceof z2.ZodPipe) {
1652
+ const inSchema = stripDescriptions(node.def.in);
1653
+ const outSchema = stripDescriptions(node.def.out);
1654
+ if (inSchema !== node.def.in || outSchema !== node.def.out) {
1655
+ node = cloneWithDef(node, { in: inSchema, out: outSchema });
1656
+ }
1657
+ }
1658
+ if (node.description !== void 0) {
1659
+ node = node.meta({ description: void 0 });
1660
+ }
1661
+ return node;
1662
+ }
1663
+
1579
1664
  // src/tools/define-batch.ts
1580
1665
  function defineBatch(args) {
1581
- const schema = z2.object({
1582
- items: z2.array(args.itemSchema).min(1).max(50).describe(args.itemDescription)
1666
+ const itemSchema = stripDescriptions(args.itemSchema);
1667
+ const schema = z3.object({
1668
+ items: z3.array(itemSchema).min(1).max(50).describe(args.itemDescription)
1583
1669
  });
1584
1670
  async function handler(input, opts = {}) {
1585
1671
  return batchExecute(args.toolName, input.items, args.itemHandler, opts);
@@ -1587,27 +1673,51 @@ function defineBatch(args) {
1587
1673
  return { schema, handler };
1588
1674
  }
1589
1675
 
1590
- // src/tools/descriptions.ts
1591
- var EMBED_TAGS_FIELDS_DESCRIPTION = "Comma-separated embeds, e.g. 'tags,fields'";
1592
- var EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION = "Comma-separated embeds, e.g. 'attachments,participants'";
1593
-
1594
1676
  // src/tools/define-delete.ts
1595
- import { z as z5 } from "zod";
1677
+ import { z as z6 } from "zod";
1596
1678
 
1597
1679
  // src/tools/confirm-flag.ts
1598
- import { z as z3 } from "zod";
1680
+ import { z as z4 } from "zod";
1599
1681
  var CONFIRM_REQUIRED_MESSAGE = "confirm: true is required to perform this destructive operation (set the parameter explicitly to acknowledge the destructive intent)";
1600
1682
  function confirmFlag() {
1601
- return z3.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
1683
+ return z4.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
1602
1684
  }
1603
1685
 
1604
1686
  // src/tools/shared-schemas.ts
1605
- import { z as z4 } from "zod";
1606
- var positiveId = z4.preprocess((input) => {
1687
+ import { z as z5 } from "zod";
1688
+ var positiveId = z5.preprocess((input) => {
1607
1689
  if (typeof input !== "string") return input;
1608
1690
  const trimmed = input.trim();
1609
1691
  return /^\d+$/.test(trimmed) ? Number(trimmed) : input;
1610
- }, z4.number().int().positive());
1692
+ }, z5.number().int().positive());
1693
+ var paginationFields = {
1694
+ page: z5.number().int().positive().optional().default(1),
1695
+ perPage: z5.number().int().min(1).max(100).optional().default(25)
1696
+ };
1697
+ var paginationFieldsNoDefaults = {
1698
+ page: z5.number().int().positive().optional(),
1699
+ perPage: z5.number().int().min(1).max(100).optional()
1700
+ };
1701
+ var ENTITY_PATH = {
1702
+ parties: "parties",
1703
+ opportunities: "opportunities",
1704
+ projects: "kases"
1705
+ };
1706
+ function embedParam(allowed) {
1707
+ return z5.string().superRefine((value, ctx) => {
1708
+ const tokens = value.split(",").map((t) => t.trim());
1709
+ for (const token of tokens) {
1710
+ if (token === "" || !allowed.includes(token)) {
1711
+ ctx.addIssue({
1712
+ code: "custom",
1713
+ message: `Unknown embed token '${token}'. Valid tokens: ${allowed.join(", ")} (comma-separated). Capsule silently ignores unknown tokens, so this is rejected client-side to prevent silently-missing data.`
1714
+ });
1715
+ }
1716
+ }
1717
+ }).describe(`Comma-separated embeds. Valid tokens: ${allowed.join(", ")}.`).optional();
1718
+ }
1719
+ var RECORD_EMBEDS = ["tags", "fields", "missingImportantFields"];
1720
+ var ENTRY_EMBEDS = ["attachments", "participants"];
1611
1721
 
1612
1722
  // src/capsule/idempotent.ts
1613
1723
  var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
@@ -1634,7 +1744,7 @@ async function idempotentWithResult(op, success, alreadyDone, isAlreadyDoneError
1634
1744
  // src/tools/define-delete.ts
1635
1745
  function defineDelete(args) {
1636
1746
  const { toolName, pathPrefix, confirmHint, idDescription } = args;
1637
- const schema = z5.object({
1747
+ const schema = z6.object({
1638
1748
  id: idDescription ? positiveId.describe(idDescription) : positiveId,
1639
1749
  confirm: confirmFlag().describe(confirmHint)
1640
1750
  });
@@ -1682,12 +1792,12 @@ async function chunkedMultiGet(base, responseKey, ids, params) {
1682
1792
  }
1683
1793
 
1684
1794
  // src/tools/custom-field-helpers.ts
1685
- import { z as z6 } from "zod";
1686
- var CustomFieldWriteSchema = z6.object({
1795
+ import { z as z7 } from "zod";
1796
+ var CustomFieldWriteSchema = z7.object({
1687
1797
  definitionId: positiveId.describe(
1688
1798
  "The custom-field definition id from list_custom_fields. Identifies which field on the entity to set."
1689
1799
  ),
1690
- value: z6.union([z6.string(), z6.number(), z6.boolean(), z6.null()]).describe(
1800
+ value: z7.union([z7.string(), z7.number(), z7.boolean(), z7.null()]).describe(
1691
1801
  "The new value. String for TEXT / DATE / LIST / LARGE_TEXT / LINK fields, number for NUMBER fields, boolean for BOOLEAN fields. Clearing: pass null for TEXT / NUMBER / DATE / LIST (Capsule removes the row). BOOLEAN does NOT accept null (Capsule returns 422 'invalid type for field'); use `value: false` instead. Note BOOLEAN fields are observably **two-state**: a row exists with `value: true`, or no row exists. Setting `value: false` removes the row entirely \u2014 readers should treat absent BOOLEAN rows as equivalent to false. Tri-state BOOLEAN semantics (true / false / unknown) are not achievable through Capsule's API. Audit-log noise: sending value=null on a field that's already empty/cleared is accepted by Capsule but still bumps the parent entity's `updatedAt`. Read the current value via embed='fields' first if `updatedAt` is being used as a 'last meaningful change' signal. NUMBER quirks: Capsule stores numerics correctly but the read-back via embed=fields returns them as STRINGS (e.g. value=3 reads as '3'); callers comparing values must coerce. TEXT quirks: value='' has the same observable effect as value=null (row removed); empty-string and never-set are indistinguishable."
1692
1802
  )
1693
1803
  });
@@ -1703,24 +1813,24 @@ function mapFieldsForBody(fields) {
1703
1813
  }
1704
1814
 
1705
1815
  // src/tools/parties.ts
1706
- var EmailAddressSchema = z7.object({
1707
- address: z7.string().email(),
1708
- type: z7.string().optional()
1816
+ var EmailAddressSchema = z8.object({
1817
+ address: z8.string().email(),
1818
+ type: z8.string().optional()
1709
1819
  });
1710
- var PhoneNumberSchema = z7.object({
1820
+ var PhoneNumberSchema = z8.object({
1711
1821
  // Capsule rejects empty strings with `phoneNumber.number: number is
1712
1822
  // required`. Enforce at the schema layer to catch typos pre-call,
1713
1823
  // matching how EmailAddressSchema's address field behaves.
1714
- number: z7.string().min(1),
1715
- type: z7.string().optional()
1824
+ number: z8.string().min(1),
1825
+ type: z8.string().optional()
1716
1826
  });
1717
1827
  var CountryDescription = "Country name. Capsule validates this against a small canonical-English-name dictionary; inputs not in the dictionary are REJECTED with 422 'address.country: unknown country' (NOT silently passed through or normalised). Probed examples \u2014 accepted: `United States`, `United Kingdom`, `Czechia`, `Germany`. Aliased: `USA \u2192 United States`. Rejected: `United States of America`, `Czech Republic` (use `Czechia`), `UK`/`Britain` (use `United Kingdom`), `Deutschland` (use `Germany`). Empty string is accepted and stored as `null` \u2014 a de-facto 'clear' shape. To discover an accepted name, read an existing party that already has the country set.";
1718
- var AddressSchema = z7.object({
1719
- street: z7.string().optional(),
1720
- city: z7.string().optional(),
1721
- state: z7.string().optional(),
1722
- country: z7.string().optional().describe(CountryDescription),
1723
- zip: z7.string().optional()
1828
+ var AddressSchema = z8.object({
1829
+ street: z8.string().optional(),
1830
+ city: z8.string().optional(),
1831
+ state: z8.string().optional(),
1832
+ country: z8.string().optional().describe(CountryDescription),
1833
+ zip: z8.string().optional()
1724
1834
  });
1725
1835
  function validateWebsiteAddress(data, ctx) {
1726
1836
  const isUrlService = data.service === void 0 || data.service === "URL";
@@ -1743,7 +1853,7 @@ function validateWebsiteAddress(data, ctx) {
1743
1853
  });
1744
1854
  }
1745
1855
  }
1746
- var WebsiteServiceEnum = z7.enum([
1856
+ var WebsiteServiceEnum = z8.enum([
1747
1857
  "URL",
1748
1858
  "SKYPE",
1749
1859
  "TWITTER",
@@ -1762,33 +1872,31 @@ var WebsiteServiceEnum = z7.enum([
1762
1872
  "BLUESKY",
1763
1873
  "SNAPCHAT"
1764
1874
  ]);
1765
- var WebsiteSchema = z7.object({
1766
- address: z7.string().min(1).describe(
1875
+ var WebsiteSchema = z8.object({
1876
+ address: z8.string().min(1).describe(
1767
1877
  "The website address. A URL when service='URL', or a handle (e.g. '@acmeco') for social services like 'TWITTER', 'INSTAGRAM'. Capsule names this field `address` regardless of service type."
1768
1878
  ),
1769
1879
  service: WebsiteServiceEnum.optional().describe(
1770
1880
  "Service type. One of: URL, SKYPE, TWITTER, LINKED_IN, FACEBOOK, XING, FEED, GOOGLE_PLUS, FLICKR, GITHUB, YOUTUBE, INSTAGRAM, PINTEREST, TIKTOK, THREADS, BLUESKY, SNAPCHAT. Defaults to 'URL' if omitted."
1771
1881
  )
1772
1882
  }).superRefine(validateWebsiteAddress);
1773
- var searchPartiesSchema = z7.object({
1774
- q: z7.string().optional().describe("Free-text search query"),
1775
- embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
1776
- page: z7.number().int().positive().optional().default(1),
1777
- perPage: z7.number().int().min(1).max(100).optional().default(25)
1883
+ var searchPartiesSchema = z8.object({
1884
+ q: z8.string().optional().describe("Free-text search query"),
1885
+ embed: embedParam(RECORD_EMBEDS),
1886
+ ...paginationFields
1778
1887
  });
1779
1888
  async function searchParties(input) {
1780
1889
  const path = input.q ? "/parties/search" : "/parties";
1781
- const { data, nextPage } = await capsuleGet(path, {
1890
+ return capsuleGetList(path, {
1782
1891
  q: input.q,
1783
1892
  embed: input.embed,
1784
1893
  page: input.page,
1785
1894
  perPage: input.perPage
1786
1895
  });
1787
- return { ...data, nextPage };
1788
1896
  }
1789
- var getPartySchema = z7.object({
1897
+ var getPartySchema = z8.object({
1790
1898
  id: positiveId.describe("Party ID"),
1791
- embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1899
+ embed: embedParam(RECORD_EMBEDS)
1792
1900
  });
1793
1901
  async function getParty(input) {
1794
1902
  const { data } = await capsuleGet(`/parties/${input.id}`, {
@@ -1796,51 +1904,47 @@ async function getParty(input) {
1796
1904
  });
1797
1905
  return data;
1798
1906
  }
1799
- var getPartiesSchema = z7.object({
1800
- ids: z7.array(positiveId).min(1).max(50).describe(
1907
+ var getPartiesSchema = z8.object({
1908
+ ids: z8.array(positiveId).min(1).max(50).describe(
1801
1909
  "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."
1802
1910
  ),
1803
- embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1911
+ embed: embedParam(RECORD_EMBEDS)
1804
1912
  });
1805
1913
  async function getParties(input) {
1806
1914
  return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
1807
1915
  }
1808
- var listPartyOpportunitiesSchema = z7.object({
1916
+ var listPartyOpportunitiesSchema = z8.object({
1809
1917
  partyId: positiveId,
1810
- page: z7.number().int().positive().optional().default(1),
1811
- perPage: z7.number().int().min(1).max(100).optional().default(25)
1918
+ ...paginationFields
1812
1919
  });
1813
1920
  async function listPartyOpportunities(input) {
1814
- const { data, nextPage } = await capsuleGet(
1815
- `/parties/${input.partyId}/opportunities`,
1816
- { page: input.page, perPage: input.perPage }
1817
- );
1818
- return { ...data, nextPage };
1921
+ return capsuleGetList(`/parties/${input.partyId}/opportunities`, {
1922
+ page: input.page,
1923
+ perPage: input.perPage
1924
+ });
1819
1925
  }
1820
- var listPartyProjectsSchema = z7.object({
1926
+ var listPartyProjectsSchema = z8.object({
1821
1927
  partyId: positiveId,
1822
- page: z7.number().int().positive().optional().default(1),
1823
- perPage: z7.number().int().min(1).max(100).optional().default(25)
1928
+ ...paginationFields
1824
1929
  });
1825
1930
  async function listPartyProjects(input) {
1826
- const { data, nextPage } = await capsuleGet(
1827
- `/parties/${input.partyId}/kases`,
1828
- { page: input.page, perPage: input.perPage }
1829
- );
1830
- return { ...data, nextPage };
1931
+ return capsuleGetList(`/parties/${input.partyId}/kases`, {
1932
+ page: input.page,
1933
+ perPage: input.perPage
1934
+ });
1831
1935
  }
1832
1936
  var PartyWriteBaseSchema = {
1833
- about: z7.string().optional(),
1834
- emailAddresses: z7.array(EmailAddressSchema).optional().describe(
1937
+ about: z8.string().optional(),
1938
+ emailAddresses: z8.array(EmailAddressSchema).optional().describe(
1835
1939
  "APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_email_address and remove_party_email_address_by_id. Passing `[]` here is a silent no-op (does not clear the list and does not advance updatedAt)."
1836
1940
  ),
1837
- phoneNumbers: z7.array(PhoneNumberSchema).optional().describe(
1941
+ phoneNumbers: z8.array(PhoneNumberSchema).optional().describe(
1838
1942
  "APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_phone_number and remove_party_phone_number_by_id."
1839
1943
  ),
1840
- addresses: z7.array(AddressSchema).optional().describe(
1944
+ addresses: z8.array(AddressSchema).optional().describe(
1841
1945
  "APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_address and remove_party_address_by_id. The `country` field is mapped through Capsule's country dictionary \u2014 see `add_party_address.country` for the dictionary edges (small canonical-English-name list; inputs not in the dictionary are REJECTED with 422, not silently dropped)."
1842
1946
  ),
1843
- websites: z7.array(WebsiteSchema).optional().describe(
1947
+ websites: z8.array(WebsiteSchema).optional().describe(
1844
1948
  "APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_website and remove_party_website_by_id."
1845
1949
  ),
1846
1950
  ownerId: positiveId.nullable().optional().describe(
@@ -1850,16 +1954,16 @@ var PartyWriteBaseSchema = {
1850
1954
  "Assign to team ID (discover via list_teams). Pass a team ID to set, or `null` to unassign. Capsule enforces the owner\u2208team membership constraint \u2014 passing a team the current owner doesn't belong to returns 422 'owner is not a member of the team'. Combine `ownerId: null` + `teamId: <T>` in one call to transfer a party to team-ownership with no specific user (verified empirically in v1.6.4 wire-trace; the membership rule doesn't fire when owner is null)."
1851
1955
  )
1852
1956
  };
1853
- var createPartySchema = z7.object({
1854
- type: z7.enum(["person", "organisation"]),
1957
+ var createPartySchema = z8.object({
1958
+ type: z8.enum(["person", "organisation"]),
1855
1959
  // person
1856
- firstName: z7.string().optional(),
1857
- lastName: z7.string().optional(),
1858
- title: z7.string().optional(),
1859
- jobTitle: z7.string().optional(),
1960
+ firstName: z8.string().optional(),
1961
+ lastName: z8.string().optional(),
1962
+ title: z8.string().optional(),
1963
+ jobTitle: z8.string().optional(),
1860
1964
  organisationId: positiveId.optional().describe("Link person to an existing organisation ID"),
1861
1965
  // organisation
1862
- name: z7.string().optional(),
1966
+ name: z8.string().optional(),
1863
1967
  ...PartyWriteBaseSchema,
1864
1968
  ownerId: positiveId.optional().describe(
1865
1969
  "Assign to user ID. Defaults to the API-token owner when omitted. To create a team-owned party with no specific user, first create the party, then call update_party with `ownerId: null` and `teamId`."
@@ -1867,9 +1971,24 @@ var createPartySchema = z7.object({
1867
1971
  teamId: positiveId.optional().describe(
1868
1972
  "Assign to team ID (discover via list_teams). Omit to leave team unset on create. To clear an existing team or create a team-owned party with no specific owner, use update_party after creation."
1869
1973
  ),
1870
- fields: z7.array(CustomFieldWriteSchema).optional().describe(
1974
+ fields: z8.array(CustomFieldWriteSchema).optional().describe(
1871
1975
  fieldsArrayDescriptor("get_party") + " Verified empirically in v1.6.5 wire-trace: Capsule's POST /parties accepts the same `fields[]` shape as PUT, so callers can set custom field values on creation without a follow-up update."
1872
1976
  )
1977
+ }).superRefine((data, ctx) => {
1978
+ if (data.type === "person" && !data.firstName && !data.lastName) {
1979
+ ctx.addIssue({
1980
+ code: "custom",
1981
+ path: ["firstName"],
1982
+ message: "create_party: a person requires firstName and/or lastName"
1983
+ });
1984
+ }
1985
+ if (data.type === "organisation" && !data.name) {
1986
+ ctx.addIssue({
1987
+ code: "custom",
1988
+ path: ["name"],
1989
+ message: "create_party: an organisation requires name"
1990
+ });
1991
+ }
1873
1992
  });
1874
1993
  async function createParty(input) {
1875
1994
  const { ownerId, teamId, organisationId, fields, ...rest } = input;
@@ -1881,17 +2000,17 @@ async function createParty(input) {
1881
2000
  if (mappedFields !== void 0) body["fields"] = mappedFields;
1882
2001
  return capsulePost("/parties", { party: body });
1883
2002
  }
1884
- var updatePartySchema = z7.object({
2003
+ var updatePartySchema = z8.object({
1885
2004
  id: positiveId,
1886
- firstName: z7.string().optional(),
1887
- lastName: z7.string().optional(),
1888
- title: z7.string().optional(),
1889
- jobTitle: z7.string().optional(),
1890
- name: z7.string().optional(),
2005
+ firstName: z8.string().optional(),
2006
+ lastName: z8.string().optional(),
2007
+ title: z8.string().optional(),
2008
+ jobTitle: z8.string().optional(),
2009
+ name: z8.string().optional(),
1891
2010
  organisationId: positiveId.nullable().optional().describe(
1892
2011
  "For PERSON parties: link to an organisation by id, or `null` to unlink (the person becomes an orphan / standalone record). Discover org IDs via search_parties / filter_parties with type=organisation. For ORGANISATION parties: silently ignored by Capsule's API \u2014 organisations don't have a parent organisation in the data model. Empirically verified in v1.6.3 wire-trace; no client-side type guard since the no-op is harmless."
1893
2012
  ),
1894
- fields: z7.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_party")),
2013
+ fields: z8.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_party")),
1895
2014
  ...PartyWriteBaseSchema
1896
2015
  });
1897
2016
  async function updateParty(input) {
@@ -1920,12 +2039,44 @@ var { schema: batchUpdatePartySchema, handler: batchUpdateParty } = defineBatch(
1920
2039
  var { schema: deletePartySchema, handler: deleteParty } = defineDelete({
1921
2040
  toolName: "delete_party",
1922
2041
  pathPrefix: "/parties",
1923
- confirmHint: "Must be set to true. Deletes the party AND all linked notes, tasks, opportunities, and projects (kases). Deleting an ORGANISATION does NOT delete people linked to it via organisationId \u2014 their `organisation` field is silently cleared to null and they survive as standalone records. Irreversible."
2042
+ confirmHint: "Must be set to true. Deletes the party AND all linked notes, tasks, opportunities, and projects. Deleting an ORGANISATION does NOT delete people linked to it via organisationId \u2014 their `organisation` field is silently cleared to null and they survive as standalone records. Irreversible."
1924
2043
  });
1925
- var addPartyEmailAddressSchema = z7.object({
2044
+ function definePartySubResourceRemove(opts) {
2045
+ const shape = {
2046
+ partyId: positiveId,
2047
+ [opts.idField]: positiveId.describe(
2048
+ `Capsule's id for the ${opts.rowNoun} row. Read it from get_party (each entry in ${opts.arrayKey} carries an id).`
2049
+ )
2050
+ };
2051
+ const schema = z8.object(shape);
2052
+ async function handler(input) {
2053
+ const partyId = input["partyId"];
2054
+ const rowId = input[opts.idField];
2055
+ return idempotentWithResult(
2056
+ () => capsulePut(`/parties/${partyId}`, {
2057
+ party: { [opts.arrayKey]: [{ id: rowId, _delete: true }] }
2058
+ }),
2059
+ (result) => ({
2060
+ removed: true,
2061
+ alreadyRemoved: false,
2062
+ partyId,
2063
+ [opts.idField]: rowId,
2064
+ ...result
2065
+ }),
2066
+ () => ({
2067
+ removed: true,
2068
+ alreadyRemoved: true,
2069
+ partyId,
2070
+ [opts.idField]: rowId
2071
+ })
2072
+ );
2073
+ }
2074
+ return { schema, handler };
2075
+ }
2076
+ var addPartyEmailAddressSchema = z8.object({
1926
2077
  partyId: positiveId,
1927
- address: z7.string().email(),
1928
- type: z7.string().optional().describe("Free-form label, e.g. 'Work', 'Home'.")
2078
+ address: z8.string().email(),
2079
+ type: z8.string().optional().describe("Free-form label, e.g. 'Work', 'Home'.")
1929
2080
  });
1930
2081
  async function addPartyEmailAddress(input) {
1931
2082
  const { partyId, address, type } = input;
@@ -1935,32 +2086,17 @@ async function addPartyEmailAddress(input) {
1935
2086
  party: { emailAddresses: [item] }
1936
2087
  });
1937
2088
  }
1938
- var removePartyEmailAddressByIdSchema = z7.object({
1939
- partyId: positiveId,
1940
- emailAddressId: positiveId.describe(
1941
- "Capsule's id for the email-address row. Read it from get_party (each entry in emailAddresses carries an id)."
1942
- )
2089
+ var removePartyEmailAddress = definePartySubResourceRemove({
2090
+ arrayKey: "emailAddresses",
2091
+ idField: "emailAddressId",
2092
+ rowNoun: "email-address"
1943
2093
  });
1944
- async function removePartyEmailAddressById(input) {
1945
- const { partyId, emailAddressId } = input;
1946
- return idempotentWithResult(
1947
- () => capsulePut(`/parties/${partyId}`, {
1948
- party: { emailAddresses: [{ id: emailAddressId, _delete: true }] }
1949
- }),
1950
- (result) => ({
1951
- removed: true,
1952
- alreadyRemoved: false,
1953
- partyId,
1954
- emailAddressId,
1955
- ...result
1956
- }),
1957
- () => ({ removed: true, alreadyRemoved: true, partyId, emailAddressId })
1958
- );
1959
- }
1960
- var addPartyPhoneNumberSchema = z7.object({
2094
+ var removePartyEmailAddressByIdSchema = removePartyEmailAddress.schema;
2095
+ var removePartyEmailAddressById = removePartyEmailAddress.handler;
2096
+ var addPartyPhoneNumberSchema = z8.object({
1961
2097
  partyId: positiveId,
1962
- number: z7.string().min(1),
1963
- type: z7.string().optional().describe("Free-form label, e.g. 'Work', 'Mobile'.")
2098
+ number: z8.string().min(1),
2099
+ type: z8.string().optional().describe("Free-form label, e.g. 'Work', 'Mobile'.")
1964
2100
  });
1965
2101
  async function addPartyPhoneNumber(input) {
1966
2102
  const { partyId, number, type } = input;
@@ -1970,36 +2106,21 @@ async function addPartyPhoneNumber(input) {
1970
2106
  party: { phoneNumbers: [item] }
1971
2107
  });
1972
2108
  }
1973
- var removePartyPhoneNumberByIdSchema = z7.object({
1974
- partyId: positiveId,
1975
- phoneNumberId: positiveId.describe(
1976
- "Capsule's id for the phone-number row. Read it from get_party (each entry in phoneNumbers carries an id)."
1977
- )
2109
+ var removePartyPhoneNumber = definePartySubResourceRemove({
2110
+ arrayKey: "phoneNumbers",
2111
+ idField: "phoneNumberId",
2112
+ rowNoun: "phone-number"
1978
2113
  });
1979
- async function removePartyPhoneNumberById(input) {
1980
- const { partyId, phoneNumberId } = input;
1981
- return idempotentWithResult(
1982
- () => capsulePut(`/parties/${partyId}`, {
1983
- party: { phoneNumbers: [{ id: phoneNumberId, _delete: true }] }
1984
- }),
1985
- (result) => ({
1986
- removed: true,
1987
- alreadyRemoved: false,
1988
- partyId,
1989
- phoneNumberId,
1990
- ...result
1991
- }),
1992
- () => ({ removed: true, alreadyRemoved: true, partyId, phoneNumberId })
1993
- );
1994
- }
1995
- var addPartyAddressSchema = z7.object({
2114
+ var removePartyPhoneNumberByIdSchema = removePartyPhoneNumber.schema;
2115
+ var removePartyPhoneNumberById = removePartyPhoneNumber.handler;
2116
+ var addPartyAddressSchema = z8.object({
1996
2117
  partyId: positiveId,
1997
- street: z7.string().optional(),
1998
- city: z7.string().optional(),
1999
- state: z7.string().optional(),
2000
- country: z7.string().optional().describe(CountryDescription),
2001
- zip: z7.string().optional(),
2002
- type: z7.string().optional().describe("Free-form label, e.g. 'Office', 'Home'.")
2118
+ street: z8.string().optional(),
2119
+ city: z8.string().optional(),
2120
+ state: z8.string().optional(),
2121
+ country: z8.string().optional().describe(CountryDescription),
2122
+ zip: z8.string().optional(),
2123
+ type: z8.string().optional().describe("Free-form label, e.g. 'Office', 'Home'.")
2003
2124
  });
2004
2125
  async function addPartyAddress(input) {
2005
2126
  const { partyId, ...rest } = input;
@@ -2011,31 +2132,16 @@ async function addPartyAddress(input) {
2011
2132
  party: { addresses: [item] }
2012
2133
  });
2013
2134
  }
2014
- var removePartyAddressByIdSchema = z7.object({
2015
- partyId: positiveId,
2016
- addressId: positiveId.describe(
2017
- "Capsule's id for the address row. Read it from get_party (each entry in addresses carries an id)."
2018
- )
2135
+ var removePartyAddress = definePartySubResourceRemove({
2136
+ arrayKey: "addresses",
2137
+ idField: "addressId",
2138
+ rowNoun: "address"
2019
2139
  });
2020
- async function removePartyAddressById(input) {
2021
- const { partyId, addressId } = input;
2022
- return idempotentWithResult(
2023
- () => capsulePut(`/parties/${partyId}`, {
2024
- party: { addresses: [{ id: addressId, _delete: true }] }
2025
- }),
2026
- (result) => ({
2027
- removed: true,
2028
- alreadyRemoved: false,
2029
- partyId,
2030
- addressId,
2031
- ...result
2032
- }),
2033
- () => ({ removed: true, alreadyRemoved: true, partyId, addressId })
2034
- );
2035
- }
2036
- var addPartyWebsiteSchema = z7.object({
2140
+ var removePartyAddressByIdSchema = removePartyAddress.schema;
2141
+ var removePartyAddressById = removePartyAddress.handler;
2142
+ var addPartyWebsiteSchema = z8.object({
2037
2143
  partyId: positiveId,
2038
- address: z7.string().min(1).describe(
2144
+ address: z8.string().min(1).describe(
2039
2145
  "The website address. A URL when service='URL', or a handle (e.g. '@acmeco') for social services."
2040
2146
  ),
2041
2147
  service: WebsiteServiceEnum.optional().describe("Defaults to 'URL' if omitted.")
@@ -2048,58 +2154,41 @@ async function addPartyWebsite(input) {
2048
2154
  party: { websites: [item] }
2049
2155
  });
2050
2156
  }
2051
- var removePartyWebsiteByIdSchema = z7.object({
2052
- partyId: positiveId,
2053
- websiteId: positiveId.describe(
2054
- "Capsule's id for the website row. Read it from get_party (each entry in websites carries an id)."
2055
- )
2157
+ var removePartyWebsite = definePartySubResourceRemove({
2158
+ arrayKey: "websites",
2159
+ idField: "websiteId",
2160
+ rowNoun: "website"
2056
2161
  });
2057
- async function removePartyWebsiteById(input) {
2058
- const { partyId, websiteId } = input;
2059
- return idempotentWithResult(
2060
- () => capsulePut(`/parties/${partyId}`, {
2061
- party: { websites: [{ id: websiteId, _delete: true }] }
2062
- }),
2063
- (result) => ({
2064
- removed: true,
2065
- alreadyRemoved: false,
2066
- partyId,
2067
- websiteId,
2068
- ...result
2069
- }),
2070
- () => ({ removed: true, alreadyRemoved: true, partyId, websiteId })
2071
- );
2072
- }
2162
+ var removePartyWebsiteByIdSchema = removePartyWebsite.schema;
2163
+ var removePartyWebsiteById = removePartyWebsite.handler;
2073
2164
 
2074
2165
  // src/tools/opportunities.ts
2075
- import { z as z8 } from "zod";
2076
- var OpportunityValueSchema = z8.object({
2077
- amount: z8.number().nonnegative(),
2078
- currency: z8.string({
2166
+ import { z as z9 } from "zod";
2167
+ var OpportunityValueSchema = z9.object({
2168
+ amount: z9.number().nonnegative(),
2169
+ currency: z9.string({
2079
2170
  error: (iss) => iss.code === "invalid_type" && iss.input === void 0 ? "currency is required when amount is set (3-letter ISO 4217 code, e.g. 'USD', 'EUR', 'GBP')" : void 0
2080
2171
  }).length(3).describe(
2081
2172
  "ISO 4217 currency code (3 letters), e.g. 'GBP', 'USD', 'EUR'. Required when amount is set."
2082
2173
  )
2083
2174
  });
2084
- var searchOpportunitiesSchema = z8.object({
2085
- q: z8.string().optional().describe("Free-text search query"),
2086
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2087
- page: z8.number().int().positive().optional().default(1),
2088
- perPage: z8.number().int().min(1).max(100).optional().default(25)
2175
+ var searchOpportunitiesSchema = z9.object({
2176
+ q: z9.string().optional().describe("Free-text search query"),
2177
+ embed: embedParam(RECORD_EMBEDS),
2178
+ ...paginationFields
2089
2179
  });
2090
2180
  async function searchOpportunities(input) {
2091
2181
  const path = input.q ? "/opportunities/search" : "/opportunities";
2092
- const { data, nextPage } = await capsuleGet(path, {
2182
+ return capsuleGetList(path, {
2093
2183
  q: input.q,
2094
2184
  embed: input.embed,
2095
2185
  page: input.page,
2096
2186
  perPage: input.perPage
2097
2187
  });
2098
- return { ...data, nextPage };
2099
2188
  }
2100
- var getOpportunitySchema = z8.object({
2189
+ var getOpportunitySchema = z9.object({
2101
2190
  id: positiveId,
2102
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2191
+ embed: embedParam(RECORD_EMBEDS)
2103
2192
  });
2104
2193
  async function getOpportunity(input) {
2105
2194
  const { data } = await capsuleGet(`/opportunities/${input.id}`, {
@@ -2107,32 +2196,32 @@ async function getOpportunity(input) {
2107
2196
  });
2108
2197
  return data;
2109
2198
  }
2110
- var getOpportunitiesSchema = z8.object({
2111
- ids: z8.array(positiveId).min(1).max(50).describe(
2199
+ var getOpportunitiesSchema = z9.object({
2200
+ ids: z9.array(positiveId).min(1).max(50).describe(
2112
2201
  "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."
2113
2202
  ),
2114
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2203
+ embed: embedParam(RECORD_EMBEDS)
2115
2204
  });
2116
2205
  async function getOpportunities(input) {
2117
2206
  return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
2118
2207
  }
2119
- var createOpportunitySchema = z8.object({
2120
- name: z8.string().min(1),
2208
+ var createOpportunitySchema = z9.object({
2209
+ name: z9.string().min(1),
2121
2210
  partyId: positiveId.describe("ID of the party this opportunity belongs to"),
2122
2211
  milestoneId: positiveId.describe(
2123
2212
  "ID of the pipeline milestone to place this opportunity at. The milestone implicitly determines the pipeline \u2014 there is no separate pipelineId parameter. Discover via list_pipelines / list_milestones. NOTE: some Capsule tenants configure **pipeline / milestone-reached automation rules** that mutate `owner` and/or `team` immediately after creation \u2014 e.g. an 'Assign to a Team' action that fires on entry to a specific milestone and has been observed to clear `owner` as an automation side-effect. If you observe a newly-created opp landing with `owner: null` despite passing `ownerId`, the cause is almost certainly a milestone automation on the destination pipeline rather than the connector. Documented workaround: follow `create_opportunity` with an immediate `batch_update_opportunity({items: [{id, ownerId, teamId}]})` carrying both fields \u2014 PUT does not re-fire milestone-reached triggers, so the owner sticks."
2124
2213
  ),
2125
- description: z8.string().optional(),
2214
+ description: z9.string().optional(),
2126
2215
  value: OpportunityValueSchema.optional(),
2127
- expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2128
- probability: z8.number().int().min(0).max(100).optional(),
2216
+ expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2217
+ probability: z9.number().int().min(0).max(100).optional(),
2129
2218
  ownerId: positiveId.optional().describe(
2130
2219
  "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. To clear owner later, call update_opportunity with `ownerId: null`. Discover IDs via list_users. WARNING: tenant pipeline / milestone-reached automation can mutate this field post-create \u2014 see the `milestoneId` description for details and the chained-PUT workaround."
2131
2220
  ),
2132
2221
  teamId: positiveId.optional().describe(
2133
2222
  "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)."
2134
2223
  ),
2135
- fields: z8.array(CustomFieldWriteSchema).optional().describe(
2224
+ fields: z9.array(CustomFieldWriteSchema).optional().describe(
2136
2225
  fieldsArrayDescriptor("get_opportunity") + " Capsule's POST /opportunities accepts the same `fields[]` shape as PUT (inferred by symmetry with the v1.6.5 wire-trace findings on POST /parties and POST /kases \u2014 the tenant probed had no opportunity custom fields configured, so this is unverified empirically). Setting custom fields on creation removes the create-then-update ritual."
2137
2226
  )
2138
2227
  });
@@ -2149,23 +2238,23 @@ async function createOpportunity(input) {
2149
2238
  if (mappedFields !== void 0) body["fields"] = mappedFields;
2150
2239
  return capsulePost("/opportunities", { opportunity: body });
2151
2240
  }
2152
- var updateOpportunitySchema = z8.object({
2241
+ var updateOpportunitySchema = z9.object({
2153
2242
  id: positiveId,
2154
- name: z8.string().min(1).optional(),
2243
+ name: z9.string().min(1).optional(),
2155
2244
  partyId: positiveId.optional().describe(
2156
2245
  "Reassign the opportunity to a different primary party. Capsule requires every opportunity to have a party \u2014 passing `null` is rejected with 422 'party is required' (use Capsule's web UI if you need to dissolve the link entirely). Discover ids via search_parties / filter_parties. No defensive read-modify-write needed: this connector verified empirically (v1.6.3 wire-trace) that `party` is a standalone PUT field on /opportunities and does not interact with the asymmetric owner/team semantic from NOTES-ON-CAPSULE-API.md \xA727. NOTE: parent-ref nullability differs by entity \u2014 `update_task.partyId` IS nullable (orphan task), but opportunities and projects must always have a parent party. The same applies to `update_project.partyId`."
2157
2246
  ),
2158
2247
  milestoneId: positiveId.optional().describe(
2159
2248
  "Move the opportunity to this milestone. Side effects depend on the target: closing milestones (Won/Lost) auto-set `closedOn` to today and `probability` to the milestone default (100/0), preserving `lastOpenMilestone` as the previous open stage; moving back to an open milestone clears `closedOn` and re-applies the milestone's default probability (Won/Lost is reversible \u2014 no separate reopen tool). WARNING: Capsule does NOT validate that the new milestone belongs to the opportunity's current pipeline. Passing a milestoneId from a different pipeline silently relocates the opportunity across pipelines, and `lastOpenMilestone` may then reference a milestone in the previous pipeline. Verify against the opportunity's current pipeline (read the opp first, list its pipeline's milestones via list_milestones) before passing a cross-pipeline id. NOTE: changing `milestoneId` can fire **pipeline / milestone-reached automations** that mutate `owner` / `team` on the destination milestone (same shape as `create_opportunity` \u2014 see its `milestoneId` description for the owner-clearing automation caveat). If a milestone-change-and-owner-set in the same call lands with `owner: null`, follow up with a second `update_opportunity` (or `batch_update_opportunity`) carrying both `ownerId` and `teamId` \u2014 milestone-reached triggers only fire on the transition, so a subsequent PUT preserves your values."
2160
2249
  ),
2161
- description: z8.string().optional(),
2250
+ description: z9.string().optional(),
2162
2251
  value: OpportunityValueSchema.optional(),
2163
- expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
2164
- probability: z8.number().int().min(0).max(100).optional().describe(
2252
+ expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
2253
+ probability: z9.number().int().min(0).max(100).optional().describe(
2165
2254
  "Win probability 0\u2013100. On an open milestone this overrides the milestone's default probability. CANNOT be set in the same call as a closing milestone (Won/Lost) \u2014 Capsule processes the milestone change first, the opportunity becomes closed, then the probability update is rejected as edit-on-closed-opp with 422 'probability can be updated only for open opportunity'. To close an opportunity, leave probability out of the call: it auto-snaps to 100% (Won) or 0% (Lost)."
2166
2255
  ),
2167
2256
  lostReasonId: positiveId.optional().describe(
2168
- "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."
2257
+ "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_lost_reasons."
2169
2258
  ),
2170
2259
  ownerId: positiveId.nullable().optional().describe(
2171
2260
  "Reassign owner: pass a user ID to set, or `null` to unassign (verified empirically in v1.6.5 wire-trace \u2014 Capsule accepts `owner: null` on PUT /opportunities/:id, mirroring the v1.6.4 finding on /parties; brings update_opportunity into parity with update_party and update_project). 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. Combine `ownerId: null` + `teamId: <T>` in one call to transfer an opportunity to team-ownership with no specific user (verified empirically in v1.6.5; the owner-clears-team semantic doesn't fire when owner is being cleared to null)."
@@ -2173,7 +2262,7 @@ var updateOpportunitySchema = z8.object({
2173
2262
  teamId: positiveId.nullable().optional().describe(
2174
2263
  "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."
2175
2264
  ),
2176
- fields: z8.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_opportunity"))
2265
+ fields: z9.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_opportunity"))
2177
2266
  });
2178
2267
  async function updateOpportunity(input) {
2179
2268
  const { id, partyId, milestoneId, ownerId, teamId, lostReasonId, fields, ...rest } = input;
@@ -2209,25 +2298,37 @@ var { schema: deleteOpportunitySchema, handler: deleteOpportunity } = defineDele
2209
2298
  });
2210
2299
 
2211
2300
  // src/tools/projects.ts
2212
- import { z as z9 } from "zod";
2213
- var listProjectsSchema = z9.object({
2214
- status: z9.enum(["OPEN", "CLOSED"]).optional(),
2215
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2216
- page: z9.number().int().positive().optional().default(1),
2217
- perPage: z9.number().int().min(1).max(100).optional().default(25)
2301
+ import { z as z10 } from "zod";
2302
+ var searchProjectsSchema = z10.object({
2303
+ q: z10.string().optional().describe("Free-text search query"),
2304
+ embed: embedParam(RECORD_EMBEDS),
2305
+ ...paginationFields
2306
+ });
2307
+ async function searchProjects(input) {
2308
+ const path = input.q ? "/kases/search" : "/kases";
2309
+ return capsuleGetList(path, {
2310
+ q: input.q,
2311
+ embed: input.embed,
2312
+ page: input.page,
2313
+ perPage: input.perPage
2314
+ });
2315
+ }
2316
+ var listProjectsSchema = z10.object({
2317
+ status: z10.enum(["OPEN", "CLOSED"]).optional(),
2318
+ embed: embedParam(RECORD_EMBEDS),
2319
+ ...paginationFields
2218
2320
  });
2219
2321
  async function listProjects(input) {
2220
- const { data, nextPage } = await capsuleGet("/kases", {
2322
+ return capsuleGetList("/kases", {
2221
2323
  status: input.status,
2222
2324
  embed: input.embed,
2223
2325
  page: input.page,
2224
2326
  perPage: input.perPage
2225
2327
  });
2226
- return { ...data, nextPage };
2227
2328
  }
2228
- var getProjectSchema = z9.object({
2329
+ var getProjectSchema = z10.object({
2229
2330
  id: positiveId,
2230
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2331
+ embed: embedParam(RECORD_EMBEDS)
2231
2332
  });
2232
2333
  async function getProject(input) {
2233
2334
  const { data } = await capsuleGet(`/kases/${input.id}`, {
@@ -2235,20 +2336,20 @@ async function getProject(input) {
2235
2336
  });
2236
2337
  return data;
2237
2338
  }
2238
- var getProjectsSchema = z9.object({
2239
- ids: z9.array(positiveId).min(1).max(50).describe(
2339
+ var getProjectsSchema = z10.object({
2340
+ ids: z10.array(positiveId).min(1).max(50).describe(
2240
2341
  "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."
2241
2342
  ),
2242
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2343
+ embed: embedParam(RECORD_EMBEDS)
2243
2344
  });
2244
2345
  async function getProjects(input) {
2245
- return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
2346
+ return chunkedMultiGet("/kases", "projects", input.ids, { embed: input.embed });
2246
2347
  }
2247
- var createProjectSchema = z9.object({
2248
- name: z9.string().min(1),
2348
+ var createProjectSchema = z10.object({
2349
+ name: z10.string().min(1),
2249
2350
  partyId: positiveId.describe("ID of the party linked to this project"),
2250
- description: z9.string().optional(),
2251
- status: z9.enum(["OPEN", "CLOSED"]).optional().describe("Defaults to OPEN when omitted."),
2351
+ description: z10.string().optional(),
2352
+ status: z10.enum(["OPEN", "CLOSED"]).optional().describe("Defaults to OPEN when omitted."),
2252
2353
  ownerId: positiveId.optional().describe(
2253
2354
  "Assign to user ID. Defaults to the API-token owner when omitted, same as create_party / create_opportunity / create_task. NOTE: some Capsule tenants configure board-level **automation rules** that mutate `owner` (and `team`) on project creation \u2014 e.g. an automation that clears `owner` when a project enters a particular board. If you observe a project landing with unexpected `owner: null` after a create_project with `ownerId`, check the target board's automation configuration. Capsule's API itself does not drop `ownerId` when `stageId` is also supplied."
2254
2355
  ),
@@ -2258,8 +2359,8 @@ var createProjectSchema = z9.object({
2258
2359
  stageId: positiveId.optional().describe(
2259
2360
  "Stage (board column) to place the project on. Discover IDs via list_stages \u2014 each stage belongs to one Board, so picking a stageId implicitly picks the board. If omitted, the project is created with no stage assignment (and won't appear on any board). NOTE: tenant-specific board automation rules may run on project creation and mutate `owner` / `team` fields. See `create_project.ownerId` / `create_project.teamId` for the automation caveat. Capsule's create endpoint itself preserves the `ownerId` / `teamId` you supply \u2014 any clearing you observe traces to board automations, not the API."
2260
2361
  ),
2261
- expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2262
- fields: z9.array(CustomFieldWriteSchema).optional().describe(
2362
+ expectedCloseOn: z10.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2363
+ fields: z10.array(CustomFieldWriteSchema).optional().describe(
2263
2364
  fieldsArrayDescriptor("get_project") + " Verified empirically in v1.6.5 wire-trace: Capsule's POST /kases accepts the same `fields[]` shape as PUT, so callers can set custom field values on creation without a follow-up update. Project-specific: setting a field whose definition lives under a 'data tag' populates the row's internal tagId but does NOT auto-add the data tag to the project's tags array \u2014 use add_tag explicitly if you want it visible via embed=tags."
2264
2365
  )
2265
2366
  });
@@ -2277,11 +2378,11 @@ async function createProject(input) {
2277
2378
  if (mappedFields !== void 0) body["fields"] = mappedFields;
2278
2379
  return capsulePost("/kases", { kase: body });
2279
2380
  }
2280
- var updateProjectSchema = z9.object({
2381
+ var updateProjectSchema = z10.object({
2281
2382
  id: positiveId,
2282
- name: z9.string().min(1).optional(),
2283
- description: z9.string().optional(),
2284
- status: z9.enum(["OPEN", "CLOSED"]).optional(),
2383
+ name: z10.string().min(1).optional(),
2384
+ description: z10.string().optional(),
2385
+ status: z10.enum(["OPEN", "CLOSED"]).optional(),
2285
2386
  partyId: positiveId.optional().describe(
2286
2387
  "Reassign the project to a different primary party. Capsule requires every project to have a party \u2014 passing `null` is rejected with 422 'party is required' (verified empirically in v1.6.3 wire-trace). Discover ids via search_parties / filter_parties. NOTE: parent-ref nullability differs by entity \u2014 `update_task.partyId` IS nullable (orphan task), but opportunities and projects must always have a parent party. The same applies to `update_opportunity.partyId`."
2287
2388
  ),
@@ -2294,8 +2395,8 @@ var updateProjectSchema = z9.object({
2294
2395
  stageId: positiveId.nullable().optional().describe(
2295
2396
  "Move the project to this stage (board column), or `null` to remove from all stages (verified empirically in v1.6.5 wire-trace \u2014 Capsule accepts `stage: null` on PUT /kases/:id and the project no longer appears on any board). Discover IDs via list_stages. Owner and team are preserved across stage-only updates (Capsule's PUT semantic). WARNING (cross-board): Capsule does NOT validate that the new stage belongs to the project's current board \u2014 passing a stageId from a different board silently relocates the project across boards. Team and other board-derived defaults are NOT updated to match the new board. Verify against the project's current board (read the project first, list its board's stages) before passing a cross-board id."
2296
2397
  ),
2297
- expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2298
- fields: z9.array(CustomFieldWriteSchema).optional().describe(
2398
+ expectedCloseOn: z10.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2399
+ fields: z10.array(CustomFieldWriteSchema).optional().describe(
2299
2400
  fieldsArrayDescriptor("get_project") + " Project-specific: setting a field whose definition lives under a 'data tag' populates the row's internal tagId but does NOT auto-add the data tag to the project's tags array \u2014 use add_tag explicitly if you want it visible via embed=tags."
2300
2401
  )
2301
2402
  });
@@ -2309,7 +2410,7 @@ async function updateProject(input) {
2309
2410
  let resolvedTeamId = teamId;
2310
2411
  let resolvedStageId = stageId;
2311
2412
  if (ownerId !== void 0 && (teamId === void 0 || stageId === void 0)) {
2312
- const current = await readEntityRefs(`/kases/${id}`, "kase");
2413
+ const current = await readEntityRefs(`/kases/${id}`, "project");
2313
2414
  if (teamId === void 0) resolvedTeamId = current.teamId;
2314
2415
  if (stageId === void 0) resolvedStageId = current.stageId;
2315
2416
  }
@@ -2334,23 +2435,22 @@ var { schema: deleteProjectSchema, handler: deleteProject } = defineDelete({
2334
2435
  });
2335
2436
 
2336
2437
  // src/tools/tasks.ts
2337
- import { z as z10 } from "zod";
2338
- var listTasksSchema = z10.object({
2438
+ import { z as z11 } from "zod";
2439
+ var listTasksSchema = z11.object({
2339
2440
  // Note: Capsule has a third internal status `PENDING` (a task that's
2340
2441
  // part of an active track but not yet "open"), but it can only be
2341
2442
  // reached via track machinery — it is NOT directly settable by
2342
2443
  // /tasks PUT, and a list filter for it returns the same as OPEN
2343
2444
  // anyway. We expose only the two values that are actually filterable
2344
2445
  // by the v2 API.
2345
- status: z10.enum(["OPEN", "COMPLETED"]).optional().describe(
2446
+ status: z11.enum(["OPEN", "COMPLETED"]).optional().describe(
2346
2447
  "Defaults to OPEN when omitted. Pass COMPLETED to filter to completed tasks, or 'OPEN' explicitly."
2347
2448
  ),
2348
2449
  ownerId: positiveId.optional().describe("Filter to tasks owned by this user ID"),
2349
- page: z10.number().int().positive().optional().default(1),
2350
- perPage: z10.number().int().min(1).max(100).optional().default(25)
2450
+ ...paginationFields
2351
2451
  });
2352
2452
  async function listTasks(input) {
2353
- const { data, nextPage } = await capsuleGet("/tasks", {
2453
+ return capsuleGetList("/tasks", {
2354
2454
  // Default 'OPEN' applied here (not via zod .default()) so that
2355
2455
  // z.infer keeps `status` optional for callers that omit it.
2356
2456
  status: input.status ?? "OPEN",
@@ -2359,28 +2459,27 @@ async function listTasks(input) {
2359
2459
  page: input.page,
2360
2460
  perPage: input.perPage
2361
2461
  });
2362
- return { ...data, nextPage };
2363
2462
  }
2364
- var getTaskSchema = z10.object({
2463
+ var getTaskSchema = z11.object({
2365
2464
  id: positiveId.describe("Task ID")
2366
2465
  });
2367
2466
  async function getTask(input) {
2368
2467
  const { data } = await capsuleGet(`/tasks/${input.id}`);
2369
2468
  return data;
2370
2469
  }
2371
- var getTasksSchema = z10.object({
2372
- ids: z10.array(positiveId).min(1).max(50).describe(
2470
+ var getTasksSchema = z11.object({
2471
+ ids: z11.array(positiveId).min(1).max(50).describe(
2373
2472
  "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."
2374
2473
  )
2375
2474
  });
2376
2475
  async function getTasks(input) {
2377
2476
  return chunkedMultiGet("/tasks", "tasks", input.ids);
2378
2477
  }
2379
- var createTaskSchema = z10.object({
2380
- description: z10.string().min(1),
2381
- dueOn: z10.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD"),
2382
- dueTime: z10.string().regex(/^\d{2}:\d{2}$/).optional().describe("HH:MM in user's timezone"),
2383
- detail: z10.string().optional(),
2478
+ var createTaskSchema = z11.object({
2479
+ description: z11.string().min(1),
2480
+ dueOn: z11.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD"),
2481
+ dueTime: z11.string().regex(/^\d{2}:\d{2}$/).optional().describe("HH:MM in user's timezone"),
2482
+ detail: z11.string().optional(),
2384
2483
  ownerId: positiveId.optional().describe(
2385
2484
  "Assign to user ID. Defaults to the API-token owner when omitted. Once set, this connector cannot clear the owner back to null \u2014 use Capsule's web UI for that."
2386
2485
  ),
@@ -2389,10 +2488,7 @@ var createTaskSchema = z10.object({
2389
2488
  projectId: positiveId.optional().describe("Link task to a project (mutually exclusive with partyId/opportunityId)")
2390
2489
  });
2391
2490
  async function createTask(input) {
2392
- const linked = [input.partyId, input.opportunityId, input.projectId].filter(Boolean);
2393
- if (linked.length > 1) {
2394
- throw new Error("Provide at most one of partyId, opportunityId, or projectId");
2395
- }
2491
+ assertSingleParentRef("create_task", input);
2396
2492
  const { ownerId, partyId, opportunityId, projectId, ...rest } = input;
2397
2493
  const body = { ...rest };
2398
2494
  setRef(body, "owner", ownerId);
@@ -2401,16 +2497,16 @@ async function createTask(input) {
2401
2497
  setRef(body, "kase", projectId);
2402
2498
  return capsulePost("/tasks", { task: body });
2403
2499
  }
2404
- var updateTaskSchema = z10.object({
2500
+ var updateTaskSchema = z11.object({
2405
2501
  id: positiveId,
2406
- description: z10.string().min(1).optional(),
2407
- dueOn: z10.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2408
- dueTime: z10.string().regex(/^\d{2}:\d{2}$/).optional().describe("HH:MM in user's timezone"),
2409
- detail: z10.string().optional(),
2502
+ description: z11.string().min(1).optional(),
2503
+ dueOn: z11.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2504
+ dueTime: z11.string().regex(/^\d{2}:\d{2}$/).optional().describe("HH:MM in user's timezone"),
2505
+ detail: z11.string().optional(),
2410
2506
  // Capsule rejects direct sets of `PENDING` (which is a track-machinery
2411
2507
  // internal state) with 422 "cannot set task status to PENDING".
2412
2508
  // Only OPEN and COMPLETED are settable here.
2413
- status: z10.enum(["OPEN", "COMPLETED"]).optional().describe(
2509
+ status: z11.enum(["OPEN", "COMPLETED"]).optional().describe(
2414
2510
  "Set to OPEN or COMPLETED. (PENDING exists internally for track-driven tasks but cannot be set directly via this tool \u2014 Capsule rejects it.) Setting status: OPEN on an already-open task is a true no-op (does not advance updatedAt)."
2415
2511
  ),
2416
2512
  ownerId: positiveId.optional().describe(
@@ -2428,12 +2524,7 @@ var updateTaskSchema = z10.object({
2428
2524
  });
2429
2525
  async function updateTask(input) {
2430
2526
  const { id, ownerId, partyId, opportunityId, projectId, ...rest } = input;
2431
- const setCount = [partyId, opportunityId, projectId].filter((v) => typeof v === "number").length;
2432
- if (setCount > 1) {
2433
- throw new Error(
2434
- "update_task: provide at most one of partyId, opportunityId, or projectId (Capsule rejects multi-parent tasks with 422 'task can be related to at most one entity')"
2435
- );
2436
- }
2527
+ assertSingleParentRef("update_task", { partyId, opportunityId, projectId });
2437
2528
  const body = {};
2438
2529
  for (const [k, v] of Object.entries(rest)) {
2439
2530
  if (v !== void 0) body[k] = v;
@@ -2444,7 +2535,7 @@ async function updateTask(input) {
2444
2535
  setNullableRef(body, "kase", projectId);
2445
2536
  return capsulePut(`/tasks/${id}`, { task: body });
2446
2537
  }
2447
- var completeTaskSchema = z10.object({
2538
+ var completeTaskSchema = z11.object({
2448
2539
  id: positiveId
2449
2540
  });
2450
2541
  async function completeTask(input) {
@@ -2452,8 +2543,8 @@ async function completeTask(input) {
2452
2543
  task: { status: "COMPLETED" }
2453
2544
  });
2454
2545
  }
2455
- var batchCompleteTaskSchema = z10.object({
2456
- ids: z10.array(positiveId).min(1).max(50).describe(
2546
+ var batchCompleteTaskSchema = z11.object({
2547
+ ids: z11.array(positiveId).min(1).max(50).describe(
2457
2548
  "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."
2458
2549
  )
2459
2550
  });
@@ -2467,77 +2558,59 @@ var { schema: deleteTaskSchema, handler: deleteTask } = defineDelete({
2467
2558
  });
2468
2559
 
2469
2560
  // src/tools/entries.ts
2470
- import { z as z11 } from "zod";
2561
+ import { z as z12 } from "zod";
2471
2562
  var listEntriesPagination = {
2472
- page: z11.number().int().positive().optional().default(1),
2473
- perPage: z11.number().int().min(1).max(100).optional().default(25),
2474
- embed: z11.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
2563
+ ...paginationFields,
2564
+ embed: embedParam(ENTRY_EMBEDS)
2475
2565
  };
2476
- var listPartyEntriesSchema = z11.object({
2566
+ var listPartyEntriesSchema = z12.object({
2477
2567
  partyId: positiveId,
2478
2568
  ...listEntriesPagination,
2479
- includeLinkedPersons: z11.boolean().optional().describe(
2569
+ includeLinkedPersons: z12.boolean().optional().describe(
2480
2570
  "When true AND `partyId` is an ORGANISATION, also include entries filed against the organisation's linked people (the persons whose `organisation` field references this org). The connector enumerates linked persons via `GET /parties/{orgId}/people`, fans out `GET /parties/{personId}/entries` in parallel (concurrency-capped, default 5 / configurable via `CAPSULE_MCP_BATCH_CONCURRENCY`), and merges into a single feed sorted by `entryAt` descending, deduped by entry id. Default is `false` \u2014 single GET, existing behaviour unchanged. WHY THIS FLAG EXISTS: Capsule's API files each entry against exactly one party row (verified v1.6.6 wire-trace probe 4 \u2014 POST /entries rejects multi-party bodies with 422 'entry must be linked to either a party, opportunity or kase'). For an organisation with multiple contacts, captured emails almost always land on a person row, not the org. As a result, `list_party_entries(orgId)` with `includeLinkedPersons: false` will miss recent customer-facing email \u2014 even though the org's own `lastContactedAt` is updated by the activity. This flag is the correct call for any 'what's new with $ORG?' question. WHEN `partyId` IS A PERSON: silently no-op \u2014 persons have no linked-people relationship in Capsule's data model, so the flag is functionally inert (the connector still issues a cheap `/people` check; the response is empty). LATENCY: 1 + N round trips for an org with N linked people, concurrency-capped (typical: 2-3 waves for N=10). Linked-person enumeration reads the first 100 linked people; use list_employees for explicit pagination when an organisation has more contacts than that. Use `includeLinkedPersons: false` for fast pre-screen reads where you only need the org-row entries (e.g. invoice/contract notes that are typically filed at the org level). PAGINATION CAVEAT: `page` and `perPage` apply to the MERGED window, and the merge has a hard ceiling \u2014 it reliably orders only the most-recent ~100 entries across the org + its people (each party is fetched at Capsule's per-party cap of 100, and a top-100-per-party merge is correct only up to global position 100). Windows that cross the ceiling are truncated to the entries still inside that top-100 set; windows starting beyond it return no entries and end the feed. It does NOT continue into older history. To read a specific contact's full timeline beyond the merged ceiling, call `list_party_entries` on that person's id directly (the default single-GET path paginates natively with no ceiling). For the LLM-driven 'what's the latest with $ORG' query this is the typical use of, the first page is exact and the ceiling is never reached."
2481
2571
  )
2482
2572
  });
2573
+ var PER_PARTY_FETCH_CAP = 100;
2483
2574
  async function fanOutPartyEntries(partyIds, embed, perPage) {
2484
- const concurrency = getBatchConcurrency();
2485
- const results = new Array(partyIds.length);
2486
- let cursor = 0;
2487
- async function worker() {
2488
- while (true) {
2489
- const i = cursor;
2490
- cursor += 1;
2491
- if (i >= partyIds.length) return;
2492
- const id = partyIds[i];
2493
- const { data, nextPage } = await capsuleGet(
2494
- `/parties/${id}/entries`,
2495
- {
2496
- embed,
2497
- page: 1,
2498
- perPage
2499
- }
2500
- );
2501
- results[i] = { entries: data.entries, nextPage };
2502
- }
2503
- }
2504
- const workers = [];
2505
- for (let w = 0; w < Math.min(concurrency, partyIds.length); w++) {
2506
- workers.push(worker());
2507
- }
2508
- await Promise.all(workers);
2509
- return results;
2575
+ return mapWithConcurrency(partyIds, getBatchConcurrency(), async (id) => {
2576
+ const { data, nextPage } = await capsuleGet(`/parties/${id}/entries`, {
2577
+ embed,
2578
+ page: 1,
2579
+ perPage
2580
+ });
2581
+ return { entries: data.entries, nextPage };
2582
+ });
2510
2583
  }
2511
2584
  function mergedTimelineCandidatePerParty(page, perPage) {
2512
- return Math.min(page * perPage, 100);
2585
+ return Math.min(page * perPage, PER_PARTY_FETCH_CAP);
2513
2586
  }
2514
2587
  function mergedTimelineNextPage(page, perPage, mergedLength, upstreamHasNextPage) {
2515
2588
  const requestedWindowEnd = page * perPage;
2516
2589
  if (mergedLength > requestedWindowEnd) return page + 1;
2517
- const nextWindowWithinCap = requestedWindowEnd < 100;
2590
+ const nextWindowWithinCap = requestedWindowEnd < PER_PARTY_FETCH_CAP;
2518
2591
  if (nextWindowWithinCap && upstreamHasNextPage) return page + 1;
2519
2592
  return void 0;
2520
2593
  }
2521
2594
  async function listPartyEntries(input) {
2522
2595
  const { partyId, embed, page, perPage, includeLinkedPersons } = input;
2523
2596
  if (!includeLinkedPersons) {
2524
- const { data, nextPage: nextPage2 } = await capsuleGet(
2525
- `/parties/${partyId}/entries`,
2526
- { embed, page, perPage }
2527
- );
2528
- return { ...data, nextPage: nextPage2 };
2597
+ return capsuleGetList(`/parties/${partyId}/entries`, {
2598
+ embed,
2599
+ page,
2600
+ perPage
2601
+ });
2529
2602
  }
2530
2603
  const { data: peopleData } = await capsuleGet(
2531
2604
  `/parties/${partyId}/people`,
2532
- { page: 1, perPage: 100 }
2605
+ { page: 1, perPage: PER_PARTY_FETCH_CAP }
2533
2606
  );
2534
2607
  const peopleIds = (peopleData.parties ?? []).map((p) => p.id);
2535
2608
  if (peopleIds.length === 0) {
2536
- const { data, nextPage: nextPage2 } = await capsuleGet(
2537
- `/parties/${partyId}/entries`,
2538
- { embed, page, perPage }
2539
- );
2540
- return { ...data, nextPage: nextPage2 };
2609
+ return capsuleGetList(`/parties/${partyId}/entries`, {
2610
+ embed,
2611
+ page,
2612
+ perPage
2613
+ });
2541
2614
  }
2542
2615
  const targetIds = [partyId, ...peopleIds];
2543
2616
  const perPartyPages = await fanOutPartyEntries(
@@ -2572,31 +2645,31 @@ async function listPartyEntries(input) {
2572
2645
  );
2573
2646
  return { entries: slice, ...nextPage !== void 0 ? { nextPage } : {} };
2574
2647
  }
2575
- var listOpportunityEntriesSchema = z11.object({
2648
+ var listOpportunityEntriesSchema = z12.object({
2576
2649
  opportunityId: positiveId,
2577
2650
  ...listEntriesPagination
2578
2651
  });
2579
2652
  async function listOpportunityEntries(input) {
2580
- const { data, nextPage } = await capsuleGet(
2581
- `/opportunities/${input.opportunityId}/entries`,
2582
- { embed: input.embed, page: input.page, perPage: input.perPage }
2583
- );
2584
- return { ...data, nextPage };
2653
+ return capsuleGetList(`/opportunities/${input.opportunityId}/entries`, {
2654
+ embed: input.embed,
2655
+ page: input.page,
2656
+ perPage: input.perPage
2657
+ });
2585
2658
  }
2586
- var listProjectEntriesSchema = z11.object({
2659
+ var listProjectEntriesSchema = z12.object({
2587
2660
  projectId: positiveId,
2588
2661
  ...listEntriesPagination
2589
2662
  });
2590
2663
  async function listProjectEntries(input) {
2591
- const { data, nextPage } = await capsuleGet(
2592
- `/kases/${input.projectId}/entries`,
2593
- { embed: input.embed, page: input.page, perPage: input.perPage }
2594
- );
2595
- return { ...data, nextPage };
2664
+ return capsuleGetList(`/kases/${input.projectId}/entries`, {
2665
+ embed: input.embed,
2666
+ page: input.page,
2667
+ perPage: input.perPage
2668
+ });
2596
2669
  }
2597
- var getEntrySchema = z11.object({
2670
+ var getEntrySchema = z12.object({
2598
2671
  id: positiveId,
2599
- embed: z11.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
2672
+ embed: embedParam(ENTRY_EMBEDS)
2600
2673
  });
2601
2674
  async function getEntry(input) {
2602
2675
  const { data } = await capsuleGet(`/entries/${input.id}`, {
@@ -2604,34 +2677,30 @@ async function getEntry(input) {
2604
2677
  });
2605
2678
  return data;
2606
2679
  }
2607
- var listEntriesSchema = z11.object({
2680
+ var listEntriesSchema = z12.object({
2608
2681
  ...listEntriesPagination
2609
2682
  });
2610
2683
  async function listEntries(input) {
2611
- const { data, nextPage } = await capsuleGet("/entries", {
2684
+ return capsuleGetList("/entries", {
2612
2685
  embed: input.embed,
2613
2686
  page: input.page,
2614
2687
  perPage: input.perPage
2615
2688
  });
2616
- return { ...data, nextPage };
2617
2689
  }
2618
- var addNoteSchema = z11.object({
2619
- content: z11.string().min(1).describe(
2690
+ var addNoteSchema = z12.object({
2691
+ content: z12.string().min(1).describe(
2620
2692
  "Note body text. Stored verbatim and treated as MARKDOWN \u2014 Capsule's web UI renders the markdown when displaying. Pass markdown source ('# Heading', '**bold**', '- bullet'), not HTML."
2621
2693
  ),
2622
2694
  partyId: positiveId.optional().describe("Link note to a party (mutually exclusive with opportunityId/projectId)"),
2623
2695
  opportunityId: positiveId.optional().describe("Link note to an opportunity (mutually exclusive with partyId/projectId)"),
2624
2696
  projectId: positiveId.optional().describe("Link note to a project (mutually exclusive with partyId/opportunityId)"),
2625
- entryAt: z11.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/).optional().describe(
2697
+ entryAt: z12.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/).optional().describe(
2626
2698
  "ISO-8601 timestamp for when this note actually happened (e.g. '2024-03-15T14:30:00Z'). Defaults to now. Use this for backdating historical notes when migrating from another system. `entryAt` is preserved across subsequent update_entry calls; only `updatedAt` advances on edits. Note attribution flows to the API-token owner \u2014 there is no way to record a note as authored by a different user via this connector (a `creatorId` parameter would enable audit-attribution spoofing on shared-connector deployments, so it is intentionally not exposed)."
2627
2699
  )
2628
2700
  });
2629
2701
  async function addNote(input) {
2630
2702
  const { content, partyId, opportunityId, projectId, entryAt } = input;
2631
- const linked = [partyId, opportunityId, projectId].filter(Boolean);
2632
- if (linked.length !== 1) {
2633
- throw new Error("Provide exactly one of partyId, opportunityId, or projectId");
2634
- }
2703
+ assertSingleParentRef("add_note", input, { required: true });
2635
2704
  const body = { type: "note", content };
2636
2705
  setRef(body, "party", partyId);
2637
2706
  setRef(body, "opportunity", opportunityId);
@@ -2639,12 +2708,12 @@ async function addNote(input) {
2639
2708
  if (entryAt !== void 0) body["entryAt"] = entryAt;
2640
2709
  return capsulePost("/entries", { entry: body });
2641
2710
  }
2642
- var updateEntrySchema = z11.object({
2711
+ var updateEntrySchema = z12.object({
2643
2712
  id: positiveId.describe("Entry ID to update"),
2644
- content: z11.string().min(1).optional().describe(
2713
+ content: z12.string().min(1).optional().describe(
2645
2714
  "New body text for the entry. For notes, this is the markdown content; for emails, the body. Provide only if you want to change it."
2646
2715
  ),
2647
- subject: z11.string().optional().describe(
2716
+ subject: z12.string().optional().describe(
2648
2717
  "New subject line. Mostly meaningful on email-type entries; on plain notes Capsule accepts the call (HTTP 200) but **does not store the subject and does not advance `updatedAt`** \u2014 a true no-op for inapplicable fields. `entryAt` (when the note was authored) is preserved across edits; `updatedAt` advances only when an applicable field actually changes. To sort/filter by 'when did this happen', use `entryAt`; for 'last touched', use `updatedAt`."
2649
2718
  )
2650
2719
  });
@@ -2666,103 +2735,89 @@ var { schema: deleteEntrySchema, handler: deleteEntry } = defineDelete({
2666
2735
  });
2667
2736
 
2668
2737
  // src/tools/pipelines.ts
2669
- import { z as z12 } from "zod";
2670
- var paginationFields = {
2671
- page: z12.number().int().positive().optional(),
2672
- perPage: z12.number().int().min(1).max(100).optional()
2673
- };
2674
- var listPipelinesSchema = z12.object({ ...paginationFields });
2738
+ import { z as z13 } from "zod";
2739
+ var listPipelinesSchema = z13.object({ ...paginationFieldsNoDefaults });
2675
2740
  async function listPipelines(input) {
2676
- const { data, nextPage } = await capsuleGetCached("/pipelines", {
2741
+ return capsuleGetCachedList("/pipelines", {
2677
2742
  page: input.page ?? 1,
2678
2743
  perPage: input.perPage ?? 100
2679
2744
  });
2680
- return { ...data, nextPage };
2681
2745
  }
2682
- var listMilestonesSchema = z12.object({
2746
+ var listMilestonesSchema = z13.object({
2683
2747
  pipelineId: positiveId,
2684
- ...paginationFields
2748
+ ...paginationFieldsNoDefaults
2685
2749
  });
2686
2750
  async function listMilestones(input) {
2687
- const { data, nextPage } = await capsuleGetCached(
2751
+ return capsuleGetCachedList(
2688
2752
  `/pipelines/${input.pipelineId}/milestones`,
2689
2753
  { page: input.page ?? 1, perPage: input.perPage ?? 100 }
2690
2754
  );
2691
- return { ...data, nextPage };
2692
2755
  }
2693
2756
 
2694
2757
  // src/tools/boards.ts
2695
- import { z as z13 } from "zod";
2696
- var paginationFields2 = {
2697
- page: z13.number().int().positive().optional(),
2698
- perPage: z13.number().int().min(1).max(100).optional()
2699
- };
2700
- var listBoardsSchema = z13.object({ ...paginationFields2 });
2758
+ import { z as z14 } from "zod";
2759
+ var listBoardsSchema = z14.object({ ...paginationFieldsNoDefaults });
2701
2760
  async function listBoards(input) {
2702
- const { data, nextPage } = await capsuleGetCached("/boards", {
2761
+ return capsuleGetCachedList("/boards", {
2703
2762
  page: input.page ?? 1,
2704
2763
  perPage: input.perPage ?? 100
2705
2764
  });
2706
- return { ...data, nextPage };
2707
2765
  }
2708
- var listStagesSchema = z13.object({
2766
+ var listStagesSchema = z14.object({
2709
2767
  boardId: positiveId.optional().describe(
2710
2768
  "Optional. If provided, returns only the stages defined on that specific board (uses /boards/{id}/stages). Omit to get all stages across all boards in one call."
2711
2769
  ),
2712
- ...paginationFields2
2770
+ ...paginationFieldsNoDefaults
2713
2771
  });
2714
2772
  async function listStages(input) {
2715
2773
  const path = input.boardId !== void 0 ? `/boards/${input.boardId}/stages` : "/stages";
2716
- const { data, nextPage } = await capsuleGetCached(path, {
2774
+ return capsuleGetCachedList(path, {
2717
2775
  page: input.page ?? 1,
2718
2776
  perPage: input.perPage ?? 100
2719
2777
  });
2720
- return { ...data, nextPage };
2721
2778
  }
2722
2779
 
2723
2780
  // src/tools/tags.ts
2724
- import { z as z14 } from "zod";
2781
+ import { z as z15 } from "zod";
2725
2782
  var TAG_LIST_PATH = {
2726
2783
  parties: "/parties/tags",
2727
2784
  opportunities: "/opportunities/tags",
2728
- kases: "/kases/tags"
2785
+ projects: "/kases/tags"
2729
2786
  };
2730
2787
  var ENTITY_TO_WRAPPER = {
2731
2788
  parties: "party",
2732
2789
  opportunities: "opportunity",
2733
- kases: "kase"
2790
+ projects: "kase"
2734
2791
  };
2735
- var TagEntity = z14.enum(["parties", "opportunities", "kases"]).describe("Which entity type. Use 'kases' for projects (Capsule's legacy path name).");
2736
- var listTagsSchema = z14.object({
2737
- entity: z14.enum(["parties", "opportunities", "kases"]).describe("The resource type to list tags for"),
2738
- page: z14.number().int().positive().optional(),
2739
- perPage: z14.number().int().min(1).max(100).optional()
2792
+ var TagEntity = z15.enum(["parties", "opportunities", "projects"]).describe("Which entity type.");
2793
+ var listTagsSchema = z15.object({
2794
+ entity: z15.enum(["parties", "opportunities", "projects"]).describe("The resource type to list tags for"),
2795
+ ...paginationFieldsNoDefaults
2740
2796
  });
2741
2797
  async function listTags(input) {
2742
2798
  const path = TAG_LIST_PATH[input.entity];
2743
- const { data, nextPage } = await capsuleGetCached(path, {
2799
+ return capsuleGetCachedList(path, {
2744
2800
  page: input.page ?? 1,
2745
2801
  perPage: input.perPage ?? 100
2746
2802
  });
2747
- return { ...data, nextPage };
2748
2803
  }
2749
- var addTagSchema = z14.object({
2804
+ var addTagSchema = z15.object({
2750
2805
  entity: TagEntity,
2751
2806
  entityId: positiveId.describe("The party/opportunity/kase id."),
2752
- tagName: z14.string().min(1).describe(
2807
+ tagName: z15.string().min(1).describe(
2753
2808
  "Name of the tag to attach. Capsule resolves by name: if a tag with this name already exists in the tenant it is attached to the entity; if not, Capsule creates the tag and attaches it. Names are tenant-global. Capsule matches case-INSENSITIVELY when resolving (so 'VIP' and 'vip' attach the same tag), preserving the canonical casing from whichever variant was created first. To ensure consistent casing in your tag list, call list_tags first and reuse the exact name from there. Idempotent \u2014 re-attaching an already-attached tag is harmless."
2754
2809
  )
2755
2810
  });
2756
2811
  async function addTag(input) {
2757
2812
  const { entity, entityId, tagName } = input;
2758
2813
  const wrapper = ENTITY_TO_WRAPPER[entity];
2759
- const result = await capsulePut(`/${entity}/${entityId}`, {
2814
+ const result = await capsulePut(`/${ENTITY_PATH[entity]}/${entityId}`, {
2760
2815
  [wrapper]: { tags: [{ name: tagName }] }
2761
2816
  });
2762
2817
  invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
2763
2818
  return result;
2764
2819
  }
2765
- var removeTagByIdSchema = z14.object({
2820
+ var removeTagByIdSchema = z15.object({
2766
2821
  entity: TagEntity,
2767
2822
  entityId: positiveId.describe("The party/opportunity/kase id."),
2768
2823
  tagId: positiveId.describe(
@@ -2773,7 +2828,7 @@ async function removeTagById(input) {
2773
2828
  const { entity, entityId, tagId } = input;
2774
2829
  const wrapper = ENTITY_TO_WRAPPER[entity];
2775
2830
  const result = await idempotentWithResult(
2776
- () => capsulePut(`/${entity}/${entityId}`, {
2831
+ () => capsulePut(`/${ENTITY_PATH[entity]}/${entityId}`, {
2777
2832
  [wrapper]: { tags: [{ id: tagId, _delete: true }] }
2778
2833
  }),
2779
2834
  (result2) => ({
@@ -2793,7 +2848,7 @@ async function removeTagById(input) {
2793
2848
  invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
2794
2849
  return result;
2795
2850
  }
2796
- var deleteTagDefinitionSchema = z14.object({
2851
+ var deleteTagDefinitionSchema = z15.object({
2797
2852
  entity: TagEntity,
2798
2853
  tagId: positiveId.describe(
2799
2854
  "The tag definition's id (from list_tags, or embed='tags' on a record). NOT an entity id."
@@ -2808,7 +2863,7 @@ async function deleteTagDefinition(input) {
2808
2863
  throw new Error("delete_tag_definition requires confirm: true");
2809
2864
  }
2810
2865
  const result = await idempotent(
2811
- () => capsuleDelete(`/${entity}/tags/${tagId}`),
2866
+ () => capsuleDelete(`/${ENTITY_PATH[entity]}/tags/${tagId}`),
2812
2867
  () => ({ deleted: true, alreadyDeleted: false, entity, tagId }),
2813
2868
  () => ({ deleted: true, alreadyDeleted: true, entity, tagId })
2814
2869
  );
@@ -2829,44 +2884,41 @@ var { schema: batchRemoveTagByIdSchema, handler: batchRemoveTagById } = defineBa
2829
2884
  });
2830
2885
 
2831
2886
  // src/tools/users.ts
2832
- import { z as z15 } from "zod";
2833
- var listUsersSchema = z15.object({
2834
- page: z15.number().int().positive().optional(),
2835
- perPage: z15.number().int().min(1).max(100).optional()
2887
+ import { z as z16 } from "zod";
2888
+ var listUsersSchema = z16.object({
2889
+ ...paginationFieldsNoDefaults
2836
2890
  });
2837
2891
  async function listUsers(input) {
2838
- const { data, nextPage } = await capsuleGetCached("/users", {
2892
+ return capsuleGetCachedList("/users", {
2839
2893
  page: input.page ?? 1,
2840
2894
  perPage: input.perPage ?? 100
2841
2895
  });
2842
- return { ...data, nextPage };
2843
2896
  }
2844
- var getCurrentUserSchema = z15.object({});
2897
+ var getCurrentUserSchema = z16.object({});
2845
2898
  async function getCurrentUser(_input) {
2846
2899
  const { data } = await capsuleGet("/users/current");
2847
2900
  return data;
2848
2901
  }
2849
2902
 
2850
2903
  // src/tools/filters.ts
2851
- import { z as z16 } from "zod";
2852
- var FilterConditionSchema = z16.object({
2853
- field: z16.string().describe(
2904
+ import { z as z17 } from "zod";
2905
+ var FilterConditionSchema = z17.object({
2906
+ field: z17.string().describe(
2854
2907
  "The Capsule filter-side field name (these differ from response field names \u2014 e.g. response.createdAt is filter-side 'addedOn', response.lastContactedAt is filter-side 'lastContactedOn'). Common: 'addedOn' (date created), 'updatedOn' (date last modified), 'lastContactedOn' (parties only), 'name', 'tag', 'owner', 'team', 'type' (parties: person|organisation), 'milestone' (opportunities), 'status' (opp/project: OPEN|CLOSED), 'closedOn' (opp/project), 'expectedCloseOn' (opp/project), 'hasTags', 'hasEmailAddress' (parties), 'isOpen', 'isStale' (opportunities), 'custom:{fieldId}'. Full per-entity list: https://developer.capsulecrm.com/v2/reference/filters"
2855
2908
  ),
2856
- operator: z16.string().describe(
2909
+ operator: z17.string().describe(
2857
2910
  "The filter operator. Common: 'is', 'is not' (use value=null to test for null), 'contains', 'does not contain', 'is greater than', 'is less than', 'is within last' (date fields, value=integer days), 'is more than' (date fields, value=integer days ago), 'starts with', 'ends with'. Operator validity depends on the field's type."
2858
2911
  ),
2859
- value: z16.union([z16.string(), z16.number(), z16.boolean(), z16.null()]).describe(
2912
+ value: z17.union([z17.string(), z17.number(), z17.boolean(), z17.null()]).describe(
2860
2913
  "The value to compare against. For 'is within last' on date fields, pass an integer number of days. For tag filters, pass the tag name (string) or tag id (number). For 'is not' null tests, pass null literally."
2861
2914
  )
2862
2915
  });
2863
- var FilterInputSchema = z16.object({
2864
- conditions: z16.array(FilterConditionSchema).min(1).describe(
2916
+ var FilterInputSchema = z17.object({
2917
+ conditions: z17.array(FilterConditionSchema).min(1).describe(
2865
2918
  "Array of filter conditions. All conditions are ANDed together. To get newest records, use a date condition like {field: 'addedOn', operator: 'is within last', value: 7} and pick the highest-id row from the result (Capsule IDs are monotonic)."
2866
2919
  ),
2867
- embed: z16.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2868
- page: z16.number().int().positive().optional().default(1),
2869
- perPage: z16.number().int().min(1).max(100).optional().default(25)
2920
+ embed: embedParam(RECORD_EMBEDS),
2921
+ ...paginationFields
2870
2922
  });
2871
2923
  async function runFilter(entityPath, input) {
2872
2924
  const { data, nextPage } = await capsuleSearch(
@@ -2886,10 +2938,7 @@ async function filterParties(input) {
2886
2938
  }
2887
2939
  var filterOpportunitiesSchema = FilterInputSchema;
2888
2940
  async function filterOpportunities(input) {
2889
- return runFilter(
2890
- "opportunities",
2891
- input
2892
- );
2941
+ return runFilter("opportunities", input);
2893
2942
  }
2894
2943
  var filterProjectsSchema = FilterInputSchema;
2895
2944
  async function filterProjects(input) {
@@ -2897,139 +2946,129 @@ async function filterProjects(input) {
2897
2946
  }
2898
2947
 
2899
2948
  // src/tools/metadata.ts
2900
- import { z as z17 } from "zod";
2901
- var paginationFields3 = {
2902
- page: z17.number().int().positive().optional(),
2903
- perPage: z17.number().int().min(1).max(100).optional().describe("Page size, max 100. Defaults to 100 for reference data.")
2949
+ import { z as z18 } from "zod";
2950
+ var paginationFields2 = {
2951
+ ...paginationFieldsNoDefaults,
2952
+ perPage: paginationFieldsNoDefaults.perPage.describe(
2953
+ "Page size, max 100. Defaults to 100 for reference data."
2954
+ )
2904
2955
  };
2905
- var listTeamsSchema = z17.object({ ...paginationFields3 });
2956
+ var listTeamsSchema = z18.object({ ...paginationFields2 });
2906
2957
  async function listTeams(input) {
2907
- const { data, nextPage } = await capsuleGetCached("/teams", {
2958
+ return capsuleGetCachedList("/teams", {
2908
2959
  page: input.page ?? 1,
2909
2960
  perPage: input.perPage ?? 100
2910
2961
  });
2911
- return { ...data, nextPage };
2912
2962
  }
2913
- var listLostReasonsSchema = z17.object({ ...paginationFields3 });
2963
+ var listLostReasonsSchema = z18.object({ ...paginationFields2 });
2914
2964
  async function listLostReasons(input) {
2915
- const { data, nextPage } = await capsuleGetCached("/lostreasons", {
2965
+ return capsuleGetCachedList("/lostreasons", {
2916
2966
  page: input.page ?? 1,
2917
2967
  perPage: input.perPage ?? 100
2918
2968
  });
2919
- return { ...data, nextPage };
2920
2969
  }
2921
- var listActivityTypesSchema = z17.object({ ...paginationFields3 });
2970
+ var listActivityTypesSchema = z18.object({ ...paginationFields2 });
2922
2971
  async function listActivityTypes(input) {
2923
- const { data, nextPage } = await capsuleGetCached(
2924
- "/activitytypes",
2925
- {
2926
- page: input.page ?? 1,
2927
- perPage: input.perPage ?? 100
2928
- }
2929
- );
2930
- return { ...data, nextPage };
2972
+ return capsuleGetCachedList("/activitytypes", {
2973
+ page: input.page ?? 1,
2974
+ perPage: input.perPage ?? 100
2975
+ });
2931
2976
  }
2932
- var getSiteSchema = z17.object({});
2977
+ var getSiteSchema = z18.object({});
2933
2978
  async function getSite(_input) {
2934
2979
  const { data } = await capsuleGetCached("/site");
2935
2980
  return data;
2936
2981
  }
2937
- var listTrackDefinitionsSchema = z17.object({ ...paginationFields3 });
2982
+ var listTrackDefinitionsSchema = z18.object({ ...paginationFields2 });
2938
2983
  async function listTrackDefinitions(input) {
2939
- const { data, nextPage } = await capsuleGetCached(
2940
- "/trackdefinitions",
2941
- { page: input.page ?? 1, perPage: input.perPage ?? 100 }
2942
- );
2943
- return { ...data, nextPage };
2984
+ return capsuleGetCachedList("/trackdefinitions", {
2985
+ page: input.page ?? 1,
2986
+ perPage: input.perPage ?? 100
2987
+ });
2944
2988
  }
2945
- var listCategoriesSchema = z17.object({ ...paginationFields3 });
2989
+ var listCategoriesSchema = z18.object({ ...paginationFields2 });
2946
2990
  async function listCategories(input) {
2947
- const { data, nextPage } = await capsuleGetCached("/categories", {
2991
+ return capsuleGetCachedList("/categories", {
2948
2992
  page: input.page ?? 1,
2949
2993
  perPage: input.perPage ?? 100
2950
2994
  });
2951
- return { ...data, nextPage };
2952
2995
  }
2953
- var listGoalsSchema = z17.object({ ...paginationFields3 });
2996
+ var listGoalsSchema = z18.object({ ...paginationFields2 });
2954
2997
  async function listGoals(input) {
2955
- const { data, nextPage } = await capsuleGetCached("/goals", {
2998
+ return capsuleGetCachedList("/goals", {
2956
2999
  page: input.page ?? 1,
2957
3000
  perPage: input.perPage ?? 100
2958
3001
  });
2959
- return { ...data, nextPage };
2960
3002
  }
2961
3003
 
2962
3004
  // src/tools/audit.ts
2963
- import { z as z18 } from "zod";
2964
- var listEmployeesSchema = z18.object({
3005
+ import { z as z19 } from "zod";
3006
+ var listEmployeesSchema = z19.object({
2965
3007
  partyId: positiveId.describe(
2966
3008
  "The organisation's party id. Returns the people whose `organisation` field links to this party."
2967
3009
  ),
2968
- page: z18.number().int().positive().optional().default(1),
2969
- perPage: z18.number().int().min(1).max(100).optional().default(25),
2970
- embed: z18.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
3010
+ ...paginationFields,
3011
+ embed: embedParam(RECORD_EMBEDS)
2971
3012
  });
2972
3013
  async function listEmployees(input) {
2973
- const { data, nextPage } = await capsuleGet(
2974
- `/parties/${input.partyId}/people`,
2975
- { page: input.page, perPage: input.perPage, embed: input.embed }
2976
- );
2977
- return { ...data, nextPage };
3014
+ return capsuleGetList(`/parties/${input.partyId}/people`, {
3015
+ page: input.page,
3016
+ perPage: input.perPage,
3017
+ embed: input.embed
3018
+ });
2978
3019
  }
2979
- var DeletedSinceSchema = z18.string().describe(
3020
+ var DeletedSinceSchema = z19.string().describe(
2980
3021
  "REQUIRED. ISO-8601 timestamp; only deletions on or after this point are returned. Example: '2026-01-01T00:00:00Z'."
2981
3022
  );
2982
3023
  var DeletedPagination = {
2983
3024
  since: DeletedSinceSchema,
2984
- page: z18.number().int().positive().optional().default(1),
2985
- perPage: z18.number().int().min(1).max(100).optional().default(25)
3025
+ ...paginationFields
2986
3026
  };
2987
- var listDeletedPartiesSchema = z18.object(DeletedPagination);
3027
+ var listDeletedPartiesSchema = z19.object(DeletedPagination);
2988
3028
  async function listDeletedParties(input) {
2989
- const { data, nextPage } = await capsuleGet("/parties/deleted", {
3029
+ return capsuleGetList("/parties/deleted", {
2990
3030
  since: input.since,
2991
3031
  page: input.page,
2992
3032
  perPage: input.perPage
2993
3033
  });
2994
- return { ...data, nextPage };
2995
3034
  }
2996
- var listDeletedOpportunitiesSchema = z18.object(DeletedPagination);
3035
+ var listDeletedOpportunitiesSchema = z19.object(DeletedPagination);
2997
3036
  async function listDeletedOpportunities(input) {
2998
- const { data, nextPage } = await capsuleGet("/opportunities/deleted", {
3037
+ return capsuleGetList("/opportunities/deleted", {
2999
3038
  since: input.since,
3000
3039
  page: input.page,
3001
3040
  perPage: input.perPage
3002
3041
  });
3003
- return { ...data, nextPage };
3004
3042
  }
3005
- var listDeletedProjectsSchema = z18.object(DeletedPagination);
3043
+ var listDeletedProjectsSchema = z19.object(DeletedPagination);
3006
3044
  async function listDeletedProjects(input) {
3007
- const { data, nextPage } = await capsuleGet("/kases/deleted", {
3045
+ return capsuleGetList("/kases/deleted", {
3008
3046
  since: input.since,
3009
3047
  page: input.page,
3010
3048
  perPage: input.perPage
3011
3049
  });
3012
- return { ...data, nextPage };
3013
3050
  }
3014
3051
 
3015
3052
  // src/tools/relationships.ts
3016
- import { z as z19 } from "zod";
3017
- var RelationshipEntity = z19.enum(["opportunities", "kases"]).describe("Which entity has the additional-party links. Use 'kases' for projects.");
3018
- var listAdditionalPartiesSchema = z19.object({
3053
+ import { z as z20 } from "zod";
3054
+ var RelationshipEntity = z20.enum(["opportunities", "projects"]).describe("Which entity has the additional-party links.");
3055
+ var listAdditionalPartiesSchema = z20.object({
3019
3056
  entity: RelationshipEntity,
3020
3057
  entityId: positiveId.describe("ID of the opportunity or project."),
3021
- embed: z19.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
3022
- page: z19.number().int().positive().optional().default(1),
3023
- perPage: z19.number().int().min(1).max(100).optional().default(25)
3058
+ embed: embedParam(RECORD_EMBEDS),
3059
+ ...paginationFields
3024
3060
  });
3025
3061
  async function listAdditionalParties(input) {
3026
- const { data, nextPage } = await capsuleGet(
3027
- `/${input.entity}/${input.entityId}/parties`,
3028
- { embed: input.embed, page: input.page, perPage: input.perPage }
3062
+ return capsuleGetList(
3063
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/parties`,
3064
+ {
3065
+ embed: input.embed,
3066
+ page: input.page,
3067
+ perPage: input.perPage
3068
+ }
3029
3069
  );
3030
- return { ...data, nextPage };
3031
3070
  }
3032
- var addAdditionalPartySchema = z19.object({
3071
+ var addAdditionalPartySchema = z20.object({
3033
3072
  entity: RelationshipEntity,
3034
3073
  entityId: positiveId,
3035
3074
  partyId: positiveId.describe(
@@ -3038,7 +3077,9 @@ var addAdditionalPartySchema = z19.object({
3038
3077
  });
3039
3078
  async function addAdditionalParty(input) {
3040
3079
  try {
3041
- await capsulePostNoContent(`/${input.entity}/${input.entityId}/parties/${input.partyId}`);
3080
+ await capsulePostNoContent(
3081
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/parties/${input.partyId}`
3082
+ );
3042
3083
  return {
3043
3084
  linked: true,
3044
3085
  alreadyLinked: false,
@@ -3062,7 +3103,7 @@ async function addAdditionalParty(input) {
3062
3103
  throw err;
3063
3104
  }
3064
3105
  }
3065
- var removeAdditionalPartySchema = z19.object({
3106
+ var removeAdditionalPartySchema = z20.object({
3066
3107
  entity: RelationshipEntity,
3067
3108
  entityId: positiveId,
3068
3109
  partyId: positiveId,
@@ -3075,7 +3116,7 @@ async function removeAdditionalParty(input) {
3075
3116
  throw new Error("remove_additional_party requires confirm: true");
3076
3117
  }
3077
3118
  return idempotent(
3078
- () => capsuleDelete(`/${input.entity}/${input.entityId}/parties/${input.partyId}`),
3119
+ () => capsuleDelete(`/${ENTITY_PATH[input.entity]}/${input.entityId}/parties/${input.partyId}`),
3079
3120
  () => ({
3080
3121
  removed: true,
3081
3122
  alreadyRemoved: false,
@@ -3092,70 +3133,69 @@ async function removeAdditionalParty(input) {
3092
3133
  })
3093
3134
  );
3094
3135
  }
3095
- var listAssociatedProjectsSchema = z19.object({
3136
+ var listAssociatedProjectsSchema = z20.object({
3096
3137
  opportunityId: positiveId,
3097
- embed: z19.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
3098
- page: z19.number().int().positive().optional().default(1),
3099
- perPage: z19.number().int().min(1).max(100).optional().default(25)
3138
+ embed: embedParam(RECORD_EMBEDS),
3139
+ ...paginationFields
3100
3140
  });
3101
3141
  async function listAssociatedProjects(input) {
3102
- const { data, nextPage } = await capsuleGet(
3103
- `/opportunities/${input.opportunityId}/kases`,
3104
- { embed: input.embed, page: input.page, perPage: input.perPage }
3105
- );
3106
- return { ...data, nextPage };
3142
+ return capsuleGetList(`/opportunities/${input.opportunityId}/kases`, {
3143
+ embed: input.embed,
3144
+ page: input.page,
3145
+ perPage: input.perPage
3146
+ });
3107
3147
  }
3108
3148
 
3109
3149
  // src/tools/custom-fields.ts
3110
- import { z as z20 } from "zod";
3111
- var CustomFieldEntity = z20.enum(["parties", "opportunities", "kases"]).describe("Which entity type's custom field schema to inspect. Use 'kases' for projects.");
3112
- var listCustomFieldsSchema = z20.object({
3150
+ import { z as z21 } from "zod";
3151
+ var CustomFieldEntity = z21.enum(["parties", "opportunities", "projects"]).describe("Which entity type's custom field schema to inspect.");
3152
+ var listCustomFieldsSchema = z21.object({
3113
3153
  entity: CustomFieldEntity
3114
3154
  });
3115
3155
  async function listCustomFields(input) {
3116
3156
  const { data } = await capsuleGetCached(
3117
- `/${input.entity}/fields/definitions`
3157
+ `/${ENTITY_PATH[input.entity]}/fields/definitions`
3118
3158
  );
3119
3159
  return data;
3120
3160
  }
3121
- var getCustomFieldSchema = z20.object({
3161
+ var getCustomFieldSchema = z21.object({
3122
3162
  entity: CustomFieldEntity,
3123
- fieldId: positiveId.describe("Custom field definition id.")
3163
+ id: positiveId.describe("Custom field definition id.")
3124
3164
  });
3125
3165
  async function getCustomField(input) {
3126
3166
  const { data } = await capsuleGetCached(
3127
- `/${input.entity}/fields/definitions/${input.fieldId}`
3167
+ `/${input.entity}/fields/definitions/${input.id}`
3128
3168
  );
3129
3169
  return data;
3130
3170
  }
3131
3171
 
3132
3172
  // src/tools/tracks.ts
3133
- import { z as z21 } from "zod";
3134
- var TrackEntity = z21.enum(["parties", "opportunities", "kases"]).describe("Use 'kases' for projects.");
3135
- var listEntityTracksSchema = z21.object({
3173
+ import { z as z22 } from "zod";
3174
+ var TrackEntity = z22.enum(["parties", "opportunities", "projects"]).describe("Which entity type.");
3175
+ var listEntityTracksSchema = z22.object({
3136
3176
  entity: TrackEntity,
3137
3177
  entityId: positiveId
3138
3178
  });
3139
3179
  async function listEntityTracks(input) {
3140
3180
  const { data } = await capsuleGet(
3141
- `/${input.entity}/${input.entityId}/tracks`
3181
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/tracks`
3142
3182
  );
3143
3183
  return data;
3144
3184
  }
3145
- var showTrackSchema = z21.object({
3146
- trackId: positiveId
3185
+ var getTrackSchema = z22.object({
3186
+ id: positiveId
3147
3187
  });
3148
- async function showTrack(input) {
3149
- const { data } = await capsuleGet(`/tracks/${input.trackId}`);
3188
+ async function getTrack(input) {
3189
+ const { data } = await capsuleGet(`/tracks/${input.id}`);
3150
3190
  return data;
3151
3191
  }
3152
- var applyTrackSchema = z21.object({
3153
- entity: z21.enum(["opportunities", "kases"]).describe("Which entity to apply the track to. Use 'kases' for projects."),
3192
+ var applyTrackSchema = z22.object({
3193
+ entity: z22.enum(["opportunities", "projects"]).describe("Which entity to apply the track to."),
3154
3194
  entityId: positiveId,
3155
3195
  trackDefinitionId: positiveId.describe(
3156
3196
  "The trackDefinition to apply (from list_track_definitions). Auto-creates task definitions on the target entity per the track's rules."
3157
3197
  ),
3158
- startDate: z21.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe(
3198
+ startDate: z22.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe(
3159
3199
  "Optional ISO-8601 date (YYYY-MM-DD) the track should start from \u2014 drives task due-date calculations (each task's `dueOn` is computed as startDate + the track-definition's `daysAfter` offset). Defaults to today if omitted. Useful for scheduling a renewal-queue track against a future contract end-date, or backfilling tracks for historical projects."
3160
3200
  )
3161
3201
  });
@@ -3168,9 +3208,9 @@ async function applyTrack(input) {
3168
3208
  if (input.startDate !== void 0) track["trackDateOn"] = input.startDate;
3169
3209
  return capsulePost("/tracks", { track });
3170
3210
  }
3171
- var updateTrackSchema = z21.object({
3172
- trackId: positiveId,
3173
- fields: z21.record(z21.string(), z21.unknown()).describe(
3211
+ var updateTrackSchema = z22.object({
3212
+ id: positiveId,
3213
+ fields: z22.record(z22.string(), z22.unknown()).describe(
3174
3214
  "Object of fields to update on the track. Capsule's PUT semantics are partial \u2014 only the fields you provide are changed. Common: { complete: true } to mark a track completed. Capsule rejects unknown keys; consult Capsule's docs for the full updatable set."
3175
3215
  )
3176
3216
  });
@@ -3178,12 +3218,12 @@ async function updateTrack(input) {
3178
3218
  if (Object.keys(input.fields).length === 0) {
3179
3219
  throw new Error("update_track: provide at least one field in `fields`");
3180
3220
  }
3181
- return capsulePut(`/tracks/${input.trackId}`, {
3221
+ return capsulePut(`/tracks/${input.id}`, {
3182
3222
  track: input.fields
3183
3223
  });
3184
3224
  }
3185
- var removeTrackSchema = z21.object({
3186
- trackId: positiveId,
3225
+ var removeTrackSchema = z22.object({
3226
+ id: positiveId,
3187
3227
  confirm: confirmFlag().describe(
3188
3228
  "Must be set to true. Removes the track instance from its entity. **Capsule also deletes the auto-tasks the track created when it was applied** \u2014 they go with the track and become unreachable (404 on GET /tasks/{id}, gone from list_tasks on the parent entity). If you need any of those tasks to outlive the track, copy their content into fresh tasks (or use the web UI) before calling remove_track."
3189
3229
  )
@@ -3193,20 +3233,20 @@ async function removeTrack(input) {
3193
3233
  throw new Error("remove_track requires confirm: true");
3194
3234
  }
3195
3235
  return idempotent(
3196
- () => capsuleDelete(`/tracks/${input.trackId}`),
3197
- () => ({ removed: true, alreadyRemoved: false, trackId: input.trackId }),
3198
- () => ({ removed: true, alreadyRemoved: true, trackId: input.trackId })
3236
+ () => capsuleDelete(`/tracks/${input.id}`),
3237
+ () => ({ removed: true, alreadyRemoved: false, id: input.id }),
3238
+ () => ({ removed: true, alreadyRemoved: true, id: input.id })
3199
3239
  );
3200
3240
  }
3201
3241
 
3202
3242
  // src/tools/attachments.ts
3203
- import { z as z22 } from "zod";
3243
+ import { z as z23 } from "zod";
3204
3244
  var DEFAULT_MAX_SIZE_BYTES = 5 * 1024 * 1024;
3205
3245
  var HARD_MAX_SIZE_BYTES = 25 * 1024 * 1024;
3206
3246
  var HARD_MAX_BASE64_CHARS = Math.ceil(HARD_MAX_SIZE_BYTES / 3) * 4;
3207
- var getAttachmentSchema = z22.object({
3247
+ var getAttachmentSchema = z23.object({
3208
3248
  id: positiveId.describe("Attachment ID."),
3209
- maxSizeBytes: z22.number().int().positive().max(HARD_MAX_SIZE_BYTES).optional().describe(
3249
+ maxSizeBytes: z23.number().int().positive().max(HARD_MAX_SIZE_BYTES).optional().describe(
3210
3250
  `Refuse to return content over this size (default ${DEFAULT_MAX_SIZE_BYTES} bytes \u2248 5MB; max ${HARD_MAX_SIZE_BYTES} bytes \u2248 25MB). Files exceeding the cap return metadata only with a 'truncated: true' flag.`
3211
3251
  )
3212
3252
  });
@@ -3221,17 +3261,17 @@ async function getAttachment(input) {
3221
3261
  }
3222
3262
  return { contentType, buffer, sizeBytes };
3223
3263
  }
3224
- var uploadAttachmentSchema = z22.object({
3225
- filename: z22.string().min(1).describe(
3264
+ var uploadAttachmentSchema = z23.object({
3265
+ filename: z23.string().min(1).describe(
3226
3266
  "Filename Capsule should record (e.g. 'contract.pdf'). Capsule does NOT validate consistency between filename, contentType, and the actual bytes \u2014 a typo in either is accepted and the file is stored as labelled."
3227
3267
  ),
3228
- contentType: z22.string().min(1).describe(
3268
+ contentType: z23.string().min(1).describe(
3229
3269
  "MIME type of the file (e.g. 'application/pdf', 'image/png', 'text/plain'). Trusted by Capsule verbatim; not cross-checked against `filename` or the actual bytes."
3230
3270
  ),
3231
- dataBase64: z22.string().min(1).max(HARD_MAX_BASE64_CHARS).describe(
3271
+ dataBase64: z23.string().min(1).max(HARD_MAX_BASE64_CHARS).describe(
3232
3272
  "File contents, base64-encoded. Decoded server-side and uploaded as the request body. Maximum 25 MB per attachment (Capsule's documented limit); the connector rejects oversized base64 before uploading. The inbound HTTP body limit is ~35 MB which leaves room for the base64 expansion of a 25 MB binary."
3233
3273
  ),
3234
- content: z22.string().optional().describe(
3274
+ content: z23.string().optional().describe(
3235
3275
  "Body text for the note that will hold the attachment. Defaults to '[attachment]' if omitted."
3236
3276
  ),
3237
3277
  partyId: positiveId.optional().describe("Link the new note to a party (mutually exclusive with opportunityId / projectId)."),
@@ -3249,12 +3289,7 @@ function decodedBase64Size(s) {
3249
3289
  return s.length / 4 * 3 - padding;
3250
3290
  }
3251
3291
  async function uploadAttachment(input) {
3252
- const linked = [input.partyId, input.opportunityId, input.projectId].filter(Boolean);
3253
- if (linked.length !== 1) {
3254
- throw new Error(
3255
- "upload_attachment: provide exactly one of partyId, opportunityId, or projectId"
3256
- );
3257
- }
3292
+ assertSingleParentRef("upload_attachment", input, { required: true });
3258
3293
  if (!isValidBase64(input.dataBase64)) {
3259
3294
  throw new Error(
3260
3295
  "upload_attachment: dataBase64 is not valid base64 \u2014 Node's tolerant decoder would silently produce corrupt bytes. Verify the encoding (RFC 4648, padded with '=' to a multiple of 4 chars)."
@@ -3279,37 +3314,39 @@ async function uploadAttachment(input) {
3279
3314
  content: input.content ?? "[attachment]",
3280
3315
  attachments: [{ token }]
3281
3316
  };
3282
- if (input.partyId) entryBody["party"] = { id: input.partyId };
3283
- if (input.opportunityId) entryBody["opportunity"] = { id: input.opportunityId };
3284
- if (input.projectId) entryBody["kase"] = { id: input.projectId };
3317
+ setRef(entryBody, "party", input.partyId);
3318
+ setRef(entryBody, "opportunity", input.opportunityId);
3319
+ setRef(entryBody, "kase", input.projectId);
3285
3320
  return capsulePost("/entries", { entry: entryBody });
3286
3321
  }
3287
3322
 
3288
3323
  // src/tools/saved-filters.ts
3289
- import { z as z23 } from "zod";
3290
- var EntitySchema = z23.enum(["parties", "opportunities", "kases"]).describe(
3291
- "Which entity type the filter operates over. Use 'kases' for projects (Capsule's legacy name)."
3292
- );
3293
- var listSavedFiltersSchema = z23.object({
3324
+ import { z as z24 } from "zod";
3325
+ var EntitySchema = z24.enum(["parties", "opportunities", "projects"]).describe("Which entity type the filter operates over.");
3326
+ var listSavedFiltersSchema = z24.object({
3294
3327
  entity: EntitySchema
3295
3328
  });
3296
3329
  async function listSavedFilters(input) {
3297
- const { data } = await capsuleGetCached(`/${input.entity}/filters`);
3330
+ const { data } = await capsuleGetCached(
3331
+ `/${ENTITY_PATH[input.entity]}/filters`
3332
+ );
3298
3333
  return data;
3299
3334
  }
3300
- var runSavedFilterSchema = z23.object({
3335
+ var runSavedFilterSchema = z24.object({
3301
3336
  entity: EntitySchema,
3302
3337
  id: positiveId.describe("The saved filter id (from list_saved_filters)."),
3303
- embed: z23.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
3304
- page: z23.number().int().positive().optional().default(1),
3305
- perPage: z23.number().int().min(1).max(100).optional().default(25)
3338
+ embed: embedParam(RECORD_EMBEDS),
3339
+ ...paginationFields
3306
3340
  });
3307
3341
  async function runSavedFilter(input) {
3308
- const { data, nextPage } = await capsuleGet(
3309
- `/${input.entity}/filters/${input.id}/results`,
3310
- { page: input.page, perPage: input.perPage, embed: input.embed }
3342
+ return capsuleGetList(
3343
+ `/${ENTITY_PATH[input.entity]}/filters/${input.id}/results`,
3344
+ {
3345
+ page: input.page,
3346
+ perPage: input.perPage,
3347
+ embed: input.embed
3348
+ }
3311
3349
  );
3312
- return { ...data, nextPage };
3313
3350
  }
3314
3351
 
3315
3352
  // src/server.ts
@@ -3320,7 +3357,7 @@ function createCapsuleMcpServer(opts) {
3320
3357
  const server = new McpServer(
3321
3358
  {
3322
3359
  name: "capsulemcp",
3323
- version: "1.8.0",
3360
+ version: "2.0.0",
3324
3361
  description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
3325
3362
  websiteUrl: "https://github.com/soil-dev/capsulemcp",
3326
3363
  icons: ICONS
@@ -3437,7 +3474,7 @@ function createCapsuleMcpServer(opts) {
3437
3474
  registerTool(
3438
3475
  server,
3439
3476
  "delete_party",
3440
- "DESTRUCTIVE & IRREVERSIBLE: permanently delete a party (person or organisation). Cascades to all linked notes, tasks, opportunities, AND projects (kases). Deleting an organisation does NOT delete people linked to it via organisationId \u2014 their `organisation` field is silently cleared to null and they survive as standalone records. TRACK INSTANCES applied to cascaded opportunities/projects are NOT cleaned up either \u2014 they survive as orphan records reachable only by track id via show_track. Use remove_track on each track explicitly before deleting the parent party if orphan accumulation matters (rare in practice \u2014 orphans are unreachable from normal navigation). Requires confirm=true. Always read the party first with get_party and confirm with the user before calling. Idempotent on retry: response is `{deleted: true, alreadyDeleted: false, id}` on a fresh delete or `{deleted: true, alreadyDeleted: true, id}` if the party was already gone (Capsule's 404 is caught internally so reconciliation loops can re-issue safely).",
3477
+ "DESTRUCTIVE & IRREVERSIBLE: permanently delete a party (person or organisation). Cascades to all linked notes, tasks, opportunities, AND projects. Deleting an organisation does NOT delete people linked to it via organisationId \u2014 their `organisation` field is silently cleared to null and they survive as standalone records. TRACK INSTANCES applied to cascaded opportunities/projects are NOT cleaned up either \u2014 they survive as orphan records reachable only by track id via get_track. Use remove_track on each track explicitly before deleting the parent party if orphan accumulation matters (rare in practice \u2014 orphans are unreachable from normal navigation). Requires confirm=true. Always read the party first with get_party and confirm with the user before calling. Idempotent on retry: response is `{deleted: true, alreadyDeleted: false, id}` on a fresh delete or `{deleted: true, alreadyDeleted: true, id}` if the party was already gone (Capsule's 404 is caught internally so reconciliation loops can re-issue safely).",
3441
3478
  deletePartySchema,
3442
3479
  deleteParty
3443
3480
  );
@@ -3536,7 +3573,7 @@ function createCapsuleMcpServer(opts) {
3536
3573
  registerTool(
3537
3574
  server,
3538
3575
  "list_additional_parties",
3539
- "List secondary party links on an opportunity or project. The 'main' party is on the entity itself (opportunity.party); additional parties are e.g. partners, consultants, or referrers also involved in the deal. Set entity to 'opportunities' or 'kases' (Capsule's term for projects).",
3576
+ "List secondary party links on an opportunity or project. The 'main' party is on the entity itself (opportunity.party); additional parties are e.g. partners, consultants, or referrers also involved in the deal. Set entity to 'opportunities' or 'projects'.",
3540
3577
  listAdditionalPartiesSchema,
3541
3578
  listAdditionalParties
3542
3579
  );
@@ -3577,10 +3614,17 @@ function createCapsuleMcpServer(opts) {
3577
3614
  deleteOpportunity
3578
3615
  );
3579
3616
  }
3617
+ registerTool(
3618
+ server,
3619
+ "search_projects",
3620
+ "Free-text search projects in Capsule CRM (matches name and description). Returns results in Capsule's default order (no sort parameter is supported here). Omit `q` to list all projects. For structured queries \u2014 'most recent project', 'projects opened this month', 'projects tagged X' \u2014 use filter_projects instead.",
3621
+ searchProjectsSchema,
3622
+ searchProjects
3623
+ );
3580
3624
  registerTool(
3581
3625
  server,
3582
3626
  "list_projects",
3583
- "List projects (cases) in Capsule CRM, optionally filtered by status. Returns results in Capsule's default order (no sort parameter is supported here). For structured queries \u2014 'most recent project', 'projects opened this month', 'projects tagged X' \u2014 use filter_projects instead.",
3627
+ "List projects (cases) in Capsule CRM, optionally filtered by status. Returns results in Capsule's default order (no sort parameter is supported here). For free-text matching use search_projects; for structured queries \u2014 'most recent project', 'projects opened this month', 'projects tagged X' \u2014 use filter_projects instead.",
3584
3628
  listProjectsSchema,
3585
3629
  listProjects
3586
3630
  );
@@ -3608,7 +3652,7 @@ function createCapsuleMcpServer(opts) {
3608
3652
  registerTool(
3609
3653
  server,
3610
3654
  "list_deleted_projects",
3611
- "Audit feature: list projects deleted on or after a given timestamp. The `since` parameter is REQUIRED. Response also includes a `restrictedKases` key for records the integration user can't read fully.",
3655
+ "Audit feature: list projects deleted on or after a given timestamp. The `since` parameter is REQUIRED. Response also includes a `restrictedProjects` key for records the integration user can't read fully.",
3612
3656
  listDeletedProjectsSchema,
3613
3657
  listDeletedProjects
3614
3658
  );
@@ -3770,20 +3814,72 @@ function createCapsuleMcpServer(opts) {
3770
3814
  listEntriesSchema,
3771
3815
  listEntries
3772
3816
  );
3773
- server.tool(
3774
- "get_attachment",
3775
- "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.",
3776
- getAttachmentSchema.shape,
3777
- // get_attachment is read-only — downloads a binary, never mutates.
3778
- // Mirrors the auto-inferred `{readOnlyHint: true, destructiveHint:
3779
- // false}` that `registerTool` applies to every other `get_*` tool.
3780
- // Explicit destructiveHint: false is load-bearing MCP spec
3781
- // defaults destructiveHint to `true`, so omitting it would (in
3782
- // some client implementations) classify this read as destructive.
3783
- { readOnlyHint: true, destructiveHint: false },
3784
- async (input) => {
3785
- const result = await getAttachment(input);
3786
- if (result.truncated) {
3817
+ if (shouldRegister("get_attachment")) {
3818
+ server.tool(
3819
+ "get_attachment",
3820
+ "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.",
3821
+ getAttachmentSchema.shape,
3822
+ // get_attachment is read-only downloads a binary, never mutates.
3823
+ // Mirrors the auto-inferred `{readOnlyHint: true, destructiveHint:
3824
+ // false}` that `registerTool` applies to every other `get_*` tool.
3825
+ // Explicit destructiveHint: false is load-bearing MCP spec
3826
+ // defaults destructiveHint to `true`, so omitting it would (in
3827
+ // some client implementations) classify this read as destructive.
3828
+ { readOnlyHint: true, destructiveHint: false },
3829
+ async (input) => {
3830
+ const result = await getAttachment(input);
3831
+ if (result.truncated) {
3832
+ return {
3833
+ content: [
3834
+ {
3835
+ type: "text",
3836
+ text: JSON.stringify(
3837
+ {
3838
+ id: input.id,
3839
+ contentType: result.contentType,
3840
+ sizeBytes: result.sizeBytes,
3841
+ truncated: true,
3842
+ message: `File exceeds the size cap (${input.maxSizeBytes ?? "default"} bytes). Increase maxSizeBytes if you need the bytes; max is 25MB.`
3843
+ },
3844
+ null,
3845
+ 2
3846
+ )
3847
+ }
3848
+ ]
3849
+ };
3850
+ }
3851
+ const baseType = result.contentType.split(";")[0].trim().toLowerCase();
3852
+ if (baseType.startsWith("image/")) {
3853
+ return {
3854
+ content: [
3855
+ {
3856
+ type: "image",
3857
+ data: result.buffer.toString("base64"),
3858
+ mimeType: result.contentType
3859
+ }
3860
+ ]
3861
+ };
3862
+ }
3863
+ const isText = baseType.startsWith("text/") || baseType === "application/json" || baseType === "application/xml";
3864
+ if (isText) {
3865
+ return {
3866
+ content: [
3867
+ {
3868
+ type: "text",
3869
+ text: JSON.stringify(
3870
+ {
3871
+ id: input.id,
3872
+ contentType: result.contentType,
3873
+ sizeBytes: result.sizeBytes
3874
+ },
3875
+ null,
3876
+ 2
3877
+ )
3878
+ },
3879
+ { type: "text", text: result.buffer.toString("utf8") }
3880
+ ]
3881
+ };
3882
+ }
3787
3883
  return {
3788
3884
  content: [
3789
3885
  {
@@ -3793,8 +3889,7 @@ function createCapsuleMcpServer(opts) {
3793
3889
  id: input.id,
3794
3890
  contentType: result.contentType,
3795
3891
  sizeBytes: result.sizeBytes,
3796
- truncated: true,
3797
- message: `File exceeds the size cap (${input.maxSizeBytes ?? "default"} bytes). Increase maxSizeBytes if you need the bytes; max is 25MB.`
3892
+ base64: result.buffer.toString("base64")
3798
3893
  },
3799
3894
  null,
3800
3895
  2
@@ -3803,57 +3898,8 @@ function createCapsuleMcpServer(opts) {
3803
3898
  ]
3804
3899
  };
3805
3900
  }
3806
- const baseType = result.contentType.split(";")[0].trim().toLowerCase();
3807
- if (baseType.startsWith("image/")) {
3808
- return {
3809
- content: [
3810
- {
3811
- type: "image",
3812
- data: result.buffer.toString("base64"),
3813
- mimeType: result.contentType
3814
- }
3815
- ]
3816
- };
3817
- }
3818
- const isText = baseType.startsWith("text/") || baseType === "application/json" || baseType === "application/xml";
3819
- if (isText) {
3820
- return {
3821
- content: [
3822
- {
3823
- type: "text",
3824
- text: JSON.stringify(
3825
- {
3826
- id: input.id,
3827
- contentType: result.contentType,
3828
- sizeBytes: result.sizeBytes
3829
- },
3830
- null,
3831
- 2
3832
- )
3833
- },
3834
- { type: "text", text: result.buffer.toString("utf8") }
3835
- ]
3836
- };
3837
- }
3838
- return {
3839
- content: [
3840
- {
3841
- type: "text",
3842
- text: JSON.stringify(
3843
- {
3844
- id: input.id,
3845
- contentType: result.contentType,
3846
- sizeBytes: result.sizeBytes,
3847
- base64: result.buffer.toString("base64")
3848
- },
3849
- null,
3850
- 2
3851
- )
3852
- }
3853
- ]
3854
- };
3855
- }
3856
- );
3901
+ );
3902
+ }
3857
3903
  if (!readOnly) {
3858
3904
  registerTool(
3859
3905
  server,
@@ -3921,14 +3967,14 @@ function createCapsuleMcpServer(opts) {
3921
3967
  );
3922
3968
  registerTool(
3923
3969
  server,
3924
- "list_lostreasons",
3970
+ "list_lost_reasons",
3925
3971
  "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.",
3926
3972
  listLostReasonsSchema,
3927
3973
  listLostReasons
3928
3974
  );
3929
3975
  registerTool(
3930
3976
  server,
3931
- "list_activitytypes",
3977
+ "list_activity_types",
3932
3978
  "List all configured activity types (e.g. Call, Meeting, Email). These are the categories used when logging timeline entries via add_note. Returns each type's id and name. The set is account-configured rather than a fixed enum, so call this to discover valid values before referencing an activityType in entry creation.",
3933
3979
  listActivityTypesSchema,
3934
3980
  listActivityTypes
@@ -3956,10 +4002,10 @@ function createCapsuleMcpServer(opts) {
3956
4002
  );
3957
4003
  registerTool(
3958
4004
  server,
3959
- "show_track",
4005
+ "get_track",
3960
4006
  "Fetch a single track instance by id. Returns the minimal Capsule projection: id, description, trackDateOn, direction, and the array of tasks attached to the track. Capsule's GET /tracks/{id} does NOT include a trackDefinition link, an entity reference, or a completion field \u2014 to find the entity a track is applied to, use list_entity_tracks (which lists track instances by their parent entity); to check completion, the track-tasks' own statuses are the proxy.",
3961
- showTrackSchema,
3962
- showTrack
4007
+ getTrackSchema,
4008
+ getTrack
3963
4009
  );
3964
4010
  registerTool(
3965
4011
  server,
@@ -3992,7 +4038,7 @@ function createCapsuleMcpServer(opts) {
3992
4038
  registerTool(
3993
4039
  server,
3994
4040
  "list_tags",
3995
- "List all tags available for a given entity type (parties, opportunities, or kases). Returns each tag's id, name, and any data-tag field schema. Tags are entity-specific \u2014 a party tag is not interchangeable with an opportunity tag. Use this to discover valid tag ids before calling add_tag, or to display the tag catalogue to the user when they ask 'what tags do we use?'",
4041
+ "List all tags available for a given entity type (parties, opportunities, or projects). Returns each tag's id, name, and any data-tag field schema. Tags are entity-specific \u2014 a party tag is not interchangeable with an opportunity tag. Use this to discover valid tag ids before calling add_tag, or to display the tag catalogue to the user when they ask 'what tags do we use?'",
3996
4042
  listTagsSchema,
3997
4043
  listTags
3998
4044
  );
@@ -4014,7 +4060,7 @@ function createCapsuleMcpServer(opts) {
4014
4060
  registerTool(
4015
4061
  server,
4016
4062
  "delete_tag_definition",
4017
- "DESTRUCTIVE & TENANT-WIDE: permanently delete a tag DEFINITION from an entity type's tag namespace (parties / opportunities / kases). Unlike remove_tag_by_id \u2014 which detaches a tag from ONE record and leaves the definition intact for others \u2014 this removes the definition itself, so the tag disappears from EVERY record that shared it. Use it to clean up stray / mistyped / test tag definitions polluting the tenant-global list. Requires confirm=true. Always read the affected tag first via list_tags and confirm with the user; if you only want to untag one record, use remove_tag_by_id instead. Irreversible (re-creating by name via add_tag mints a brand-new id). Idempotent on retry: `{deleted: true, alreadyDeleted: false, entity, tagId}` on a fresh delete, or `{deleted: true, alreadyDeleted: true, entity, tagId}` if the definition was already gone (Capsule's 404 is caught). Endpoint verified empirically (DELETE /<entity>/tags/{id} \u2192 204).",
4063
+ "DESTRUCTIVE & TENANT-WIDE: permanently delete a tag DEFINITION from an entity type's tag namespace (parties / opportunities / projects). Unlike remove_tag_by_id \u2014 which detaches a tag from ONE record and leaves the definition intact for others \u2014 this removes the definition itself, so the tag disappears from EVERY record that shared it. Use it to clean up stray / mistyped / test tag definitions polluting the tenant-global list. Requires confirm=true. Always read the affected tag first via list_tags and confirm with the user; if you only want to untag one record, use remove_tag_by_id instead. Irreversible (re-creating by name via add_tag mints a brand-new id). Idempotent on retry: `{deleted: true, alreadyDeleted: false, entity, tagId}` on a fresh delete, or `{deleted: true, alreadyDeleted: true, entity, tagId}` if the definition was already gone (Capsule's 404 is caught). Endpoint verified empirically (DELETE /<entity>/tags/{id} \u2192 204).",
4018
4064
  deleteTagDefinitionSchema,
4019
4065
  deleteTagDefinition
4020
4066
  );
@@ -4229,77 +4275,51 @@ a{color:#1e3a8a}
4229
4275
  }
4230
4276
  next();
4231
4277
  };
4232
- app2.post(
4233
- "/mcp",
4278
+ const mcpGuards = [
4234
4279
  guardOrigin,
4235
4280
  requireBearerAuth({
4236
4281
  verifier: oauthProvider2,
4237
4282
  resourceMetadataUrl: mcpResourceMetadataUrl
4238
4283
  }),
4239
4284
  mcpRateLimit,
4240
- guardProtocolVersion,
4241
- express.json({ limit: jsonLimit2 }),
4242
- async (req, res) => {
4243
- try {
4244
- const clientId = req.auth?.clientId;
4245
- const server = createCapsuleMcpServer({ clientId });
4246
- const transport = new StreamableHTTPServerTransport({});
4247
- res.on("close", () => {
4248
- void transport.close();
4249
- void server.close();
4250
- });
4251
- await withRequestContext({ clientId }, async () => {
4252
- await server.connect(transport);
4253
- await transport.handleRequest(req, res, req.body);
4254
- });
4255
- } catch (err) {
4256
- const name = err instanceof Error ? err.name : typeof err;
4257
- const status = err && typeof err === "object" && "status" in err ? Number(err.status) : void 0;
4258
- const summary = status !== void 0 ? `${name} ${status}` : name;
4259
- if (process.env["MCP_HTTP_DEBUG"] === "1") {
4260
- const message = err instanceof Error ? err.message : String(err);
4261
- console.error(`[capsulemcp] /mcp error: ${summary} \u2014 ${message}`);
4262
- } else {
4263
- console.error(`[capsulemcp] /mcp error: ${summary}`);
4264
- }
4265
- if (!res.headersSent) {
4266
- res.status(500).json({ error: "internal_error" });
4267
- }
4268
- }
4269
- }
4270
- );
4271
- app2.get(
4272
- "/mcp",
4273
- guardOrigin,
4274
- requireBearerAuth({
4275
- verifier: oauthProvider2,
4276
- resourceMetadataUrl: mcpResourceMetadataUrl
4277
- }),
4278
- mcpRateLimit,
4279
- guardProtocolVersion,
4280
- (_req, res) => {
4281
- res.set("Allow", "POST").status(405).json({
4282
- error: "method_not_allowed",
4283
- message: "Use POST for MCP requests; this server runs in stateless mode."
4285
+ guardProtocolVersion
4286
+ ];
4287
+ const methodNotAllowed = (_req, res) => {
4288
+ res.set("Allow", "POST").status(405).json({
4289
+ error: "method_not_allowed",
4290
+ message: "Use POST for MCP requests; this server runs in stateless mode."
4291
+ });
4292
+ };
4293
+ app2.post("/mcp", ...mcpGuards, express.json({ limit: jsonLimit2 }), async (req, res) => {
4294
+ try {
4295
+ const clientId = req.auth?.clientId;
4296
+ const server = createCapsuleMcpServer({ clientId });
4297
+ const transport = new StreamableHTTPServerTransport({});
4298
+ res.on("close", () => {
4299
+ void transport.close();
4300
+ void server.close();
4284
4301
  });
4285
- }
4286
- );
4287
- app2.delete(
4288
- "/mcp",
4289
- guardOrigin,
4290
- requireBearerAuth({
4291
- verifier: oauthProvider2,
4292
- resourceMetadataUrl: mcpResourceMetadataUrl
4293
- }),
4294
- mcpRateLimit,
4295
- guardProtocolVersion,
4296
- (_req, res) => {
4297
- res.set("Allow", "POST").status(405).json({
4298
- error: "method_not_allowed",
4299
- message: "Use POST for MCP requests; this server runs in stateless mode."
4302
+ await withRequestContext({ clientId }, async () => {
4303
+ await server.connect(transport);
4304
+ await transport.handleRequest(req, res, req.body);
4300
4305
  });
4306
+ } catch (err) {
4307
+ const name = err instanceof Error ? err.name : typeof err;
4308
+ const status = err && typeof err === "object" && "status" in err ? Number(err.status) : void 0;
4309
+ const summary = status !== void 0 ? `${name} ${status}` : name;
4310
+ if (process.env["MCP_HTTP_DEBUG"] === "1") {
4311
+ const message = err instanceof Error ? err.message : String(err);
4312
+ console.error(`[capsulemcp] /mcp error: ${summary} \u2014 ${message}`);
4313
+ } else {
4314
+ console.error(`[capsulemcp] /mcp error: ${summary}`);
4315
+ }
4316
+ if (!res.headersSent) {
4317
+ res.status(500).json({ error: "internal_error" });
4318
+ }
4301
4319
  }
4302
- );
4320
+ });
4321
+ app2.get("/mcp", ...mcpGuards, methodNotAllowed);
4322
+ app2.delete("/mcp", ...mcpGuards, methodNotAllowed);
4303
4323
  return app2;
4304
4324
  }
4305
4325