capsulemcp 1.8.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -138,6 +138,26 @@ function invalidateByPrefix(pathPrefix, trigger) {
138
138
  }
139
139
  }
140
140
 
141
+ // src/capsule/normalize.ts
142
+ var KEY_RENAMES = {
143
+ kase: "project",
144
+ kases: "projects",
145
+ restrictedKases: "restrictedProjects"
146
+ };
147
+ function normalizeProjectKeys(value) {
148
+ if (Array.isArray(value)) {
149
+ return value.map(normalizeProjectKeys);
150
+ }
151
+ if (value !== null && typeof value === "object") {
152
+ const out = {};
153
+ for (const [key, v] of Object.entries(value)) {
154
+ out[KEY_RENAMES[key] ?? key] = normalizeProjectKeys(v);
155
+ }
156
+ return out;
157
+ }
158
+ return value;
159
+ }
160
+
141
161
  // src/capsule/client.ts
142
162
  var DEFAULT_BASE_URL = "https://api.capsulecrm.com/api/v2";
143
163
  function baseUrl() {
@@ -409,7 +429,8 @@ async function throwForStatus(res) {
409
429
  }
410
430
  async function handleResponse(res) {
411
431
  await throwForStatus(res);
412
- return mapAbort(res.json());
432
+ const body = await mapAbort(res.json());
433
+ return normalizeProjectKeys(body);
413
434
  }
414
435
  function buildUrl(path, params) {
415
436
  const url = new URL(`${baseUrl()}${path}`);
@@ -651,6 +672,7 @@ var CORE_TOOLS = /* @__PURE__ */ new Set([
651
672
  "create_opportunity",
652
673
  "update_opportunity",
653
674
  // Projects
675
+ "search_projects",
654
676
  "filter_projects",
655
677
  "list_projects",
656
678
  "get_project",
@@ -1148,10 +1170,6 @@ function defineBatch(args) {
1148
1170
  return { schema, handler };
1149
1171
  }
1150
1172
 
1151
- // src/tools/descriptions.ts
1152
- var EMBED_TAGS_FIELDS_DESCRIPTION = "Comma-separated embeds, e.g. 'tags,fields'";
1153
- var EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION = "Comma-separated embeds, e.g. 'attachments,participants'";
1154
-
1155
1173
  // src/tools/define-delete.ts
1156
1174
  import { z as z5 } from "zod";
1157
1175
 
@@ -1177,6 +1195,26 @@ var paginationFieldsNoDefaults = {
1177
1195
  page: z4.number().int().positive().optional(),
1178
1196
  perPage: z4.number().int().min(1).max(100).optional()
1179
1197
  };
1198
+ var ENTITY_PATH = {
1199
+ parties: "parties",
1200
+ opportunities: "opportunities",
1201
+ projects: "kases"
1202
+ };
1203
+ function embedParam(allowed) {
1204
+ return z4.string().superRefine((value, ctx) => {
1205
+ const tokens = value.split(",").map((t) => t.trim());
1206
+ for (const token of tokens) {
1207
+ if (token === "" || !allowed.includes(token)) {
1208
+ ctx.addIssue({
1209
+ code: "custom",
1210
+ 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.`
1211
+ });
1212
+ }
1213
+ }
1214
+ }).describe(`Comma-separated embeds. Valid tokens: ${allowed.join(", ")}.`).optional();
1215
+ }
1216
+ var RECORD_EMBEDS = ["tags", "fields", "missingImportantFields"];
1217
+ var ENTRY_EMBEDS = ["attachments", "participants"];
1180
1218
 
1181
1219
  // src/capsule/idempotent.ts
1182
1220
  var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
@@ -1341,7 +1379,7 @@ var WebsiteSchema = z7.object({
1341
1379
  }).superRefine(validateWebsiteAddress);
1342
1380
  var searchPartiesSchema = z7.object({
1343
1381
  q: z7.string().optional().describe("Free-text search query"),
1344
- embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
1382
+ embed: embedParam(RECORD_EMBEDS),
1345
1383
  ...paginationFields
1346
1384
  });
1347
1385
  async function searchParties(input) {
@@ -1355,7 +1393,7 @@ async function searchParties(input) {
1355
1393
  }
1356
1394
  var getPartySchema = z7.object({
1357
1395
  id: positiveId.describe("Party ID"),
1358
- embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1396
+ embed: embedParam(RECORD_EMBEDS)
1359
1397
  });
1360
1398
  async function getParty(input) {
1361
1399
  const { data } = await capsuleGet(`/parties/${input.id}`, {
@@ -1367,7 +1405,7 @@ var getPartiesSchema = z7.object({
1367
1405
  ids: z7.array(positiveId).min(1).max(50).describe(
1368
1406
  "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."
1369
1407
  ),
1370
- embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1408
+ embed: embedParam(RECORD_EMBEDS)
1371
1409
  });
1372
1410
  async function getParties(input) {
1373
1411
  return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
@@ -1407,7 +1445,7 @@ var PartyWriteBaseSchema = {
1407
1445
  "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."
1408
1446
  ),
1409
1447
  ownerId: positiveId.nullable().optional().describe(
1410
- "Pass a user ID to set, or `null` to unassign (verified empirically in v1.6.4 wire-trace \u2014 Capsule accepts `owner: null` on PUT /parties/:id for both persons and organisations). Discover IDs via list_users. WARNING: Capsule's PUT on /parties has the same asymmetric owner/team semantic documented in NOTES-ON-CAPSULE-API.md \xA727 for /kases \u2014 setting `owner` while omitting `team` is plausibly clearing-prone. When you supply `ownerId` and omit `teamId`, this connector reads the party's current team and includes it in the PUT body to preserve it across the owner change. Supply `teamId` explicitly to change it."
1448
+ "Pass a user ID to set, or `null` to unassign (verified empirically in v1.6.4 wire-trace \u2014 Capsule accepts `owner: null` on PUT /parties/:id for both persons and organisations). Discover IDs via list_users. WARNING: Capsule's PUT on parties has the same asymmetric owner/team semantic documented in NOTES-ON-CAPSULE-API.md \xA727 for project updates \u2014 setting `owner` while omitting `team` is plausibly clearing-prone. When you supply `ownerId` and omit `teamId`, this connector reads the party's current team and includes it in the PUT body to preserve it across the owner change. Supply `teamId` explicitly to change it."
1411
1449
  ),
1412
1450
  teamId: positiveId.nullable().optional().describe(
1413
1451
  "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)."
@@ -1433,6 +1471,21 @@ var createPartySchema = z7.object({
1433
1471
  fields: z7.array(CustomFieldWriteSchema).optional().describe(
1434
1472
  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."
1435
1473
  )
1474
+ }).superRefine((data, ctx) => {
1475
+ if (data.type === "person" && !data.firstName && !data.lastName) {
1476
+ ctx.addIssue({
1477
+ code: "custom",
1478
+ path: ["firstName"],
1479
+ message: "create_party: a person requires firstName and/or lastName"
1480
+ });
1481
+ }
1482
+ if (data.type === "organisation" && !data.name) {
1483
+ ctx.addIssue({
1484
+ code: "custom",
1485
+ path: ["name"],
1486
+ message: "create_party: an organisation requires name"
1487
+ });
1488
+ }
1436
1489
  });
1437
1490
  async function createParty(input) {
1438
1491
  const { ownerId, teamId, organisationId, fields, ...rest } = input;
@@ -1483,7 +1536,7 @@ var { schema: batchUpdatePartySchema, handler: batchUpdateParty } = defineBatch(
1483
1536
  var { schema: deletePartySchema, handler: deleteParty } = defineDelete({
1484
1537
  toolName: "delete_party",
1485
1538
  pathPrefix: "/parties",
1486
- 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."
1539
+ 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."
1487
1540
  });
1488
1541
  function definePartySubResourceRemove(opts) {
1489
1542
  const shape = {
@@ -1618,7 +1671,7 @@ var OpportunityValueSchema = z8.object({
1618
1671
  });
1619
1672
  var searchOpportunitiesSchema = z8.object({
1620
1673
  q: z8.string().optional().describe("Free-text search query"),
1621
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
1674
+ embed: embedParam(RECORD_EMBEDS),
1622
1675
  ...paginationFields
1623
1676
  });
1624
1677
  async function searchOpportunities(input) {
@@ -1632,7 +1685,7 @@ async function searchOpportunities(input) {
1632
1685
  }
1633
1686
  var getOpportunitySchema = z8.object({
1634
1687
  id: positiveId,
1635
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1688
+ embed: embedParam(RECORD_EMBEDS)
1636
1689
  });
1637
1690
  async function getOpportunity(input) {
1638
1691
  const { data } = await capsuleGet(`/opportunities/${input.id}`, {
@@ -1644,7 +1697,7 @@ var getOpportunitiesSchema = z8.object({
1644
1697
  ids: z8.array(positiveId).min(1).max(50).describe(
1645
1698
  "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."
1646
1699
  ),
1647
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1700
+ embed: embedParam(RECORD_EMBEDS)
1648
1701
  });
1649
1702
  async function getOpportunities(input) {
1650
1703
  return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
@@ -1666,7 +1719,7 @@ var createOpportunitySchema = z8.object({
1666
1719
  "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)."
1667
1720
  ),
1668
1721
  fields: z8.array(CustomFieldWriteSchema).optional().describe(
1669
- 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."
1722
+ 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 party and project creation \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."
1670
1723
  )
1671
1724
  });
1672
1725
  async function createOpportunity(input) {
@@ -1698,10 +1751,10 @@ var updateOpportunitySchema = z8.object({
1698
1751
  "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)."
1699
1752
  ),
1700
1753
  lostReasonId: positiveId.optional().describe(
1701
- "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."
1754
+ "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."
1702
1755
  ),
1703
1756
  ownerId: positiveId.nullable().optional().describe(
1704
- "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)."
1757
+ "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 project updates). 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)."
1705
1758
  ),
1706
1759
  teamId: positiveId.nullable().optional().describe(
1707
1760
  "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."
@@ -1743,9 +1796,23 @@ var { schema: deleteOpportunitySchema, handler: deleteOpportunity } = defineDele
1743
1796
 
1744
1797
  // src/tools/projects.ts
1745
1798
  import { z as z9 } from "zod";
1799
+ var searchProjectsSchema = z9.object({
1800
+ q: z9.string().optional().describe("Free-text search query"),
1801
+ embed: embedParam(RECORD_EMBEDS),
1802
+ ...paginationFields
1803
+ });
1804
+ async function searchProjects(input) {
1805
+ const path = input.q ? "/kases/search" : "/kases";
1806
+ return capsuleGetList(path, {
1807
+ q: input.q,
1808
+ embed: input.embed,
1809
+ page: input.page,
1810
+ perPage: input.perPage
1811
+ });
1812
+ }
1746
1813
  var listProjectsSchema = z9.object({
1747
1814
  status: z9.enum(["OPEN", "CLOSED"]).optional(),
1748
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
1815
+ embed: embedParam(RECORD_EMBEDS),
1749
1816
  ...paginationFields
1750
1817
  });
1751
1818
  async function listProjects(input) {
@@ -1758,7 +1825,7 @@ async function listProjects(input) {
1758
1825
  }
1759
1826
  var getProjectSchema = z9.object({
1760
1827
  id: positiveId,
1761
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1828
+ embed: embedParam(RECORD_EMBEDS)
1762
1829
  });
1763
1830
  async function getProject(input) {
1764
1831
  const { data } = await capsuleGet(`/kases/${input.id}`, {
@@ -1770,10 +1837,10 @@ var getProjectsSchema = z9.object({
1770
1837
  ids: z9.array(positiveId).min(1).max(50).describe(
1771
1838
  "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."
1772
1839
  ),
1773
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1840
+ embed: embedParam(RECORD_EMBEDS)
1774
1841
  });
1775
1842
  async function getProjects(input) {
1776
- return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
1843
+ return chunkedMultiGet("/kases", "projects", input.ids, { embed: input.embed });
1777
1844
  }
1778
1845
  var createProjectSchema = z9.object({
1779
1846
  name: z9.string().min(1),
@@ -1791,7 +1858,7 @@ var createProjectSchema = z9.object({
1791
1858
  ),
1792
1859
  expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
1793
1860
  fields: z9.array(CustomFieldWriteSchema).optional().describe(
1794
- 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."
1861
+ fieldsArrayDescriptor("get_project") + " Verified empirically in v1.6.5 wire-trace: Capsule's project create endpoint 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."
1795
1862
  )
1796
1863
  });
1797
1864
  async function createProject(input) {
@@ -1823,7 +1890,7 @@ var updateProjectSchema = z9.object({
1823
1890
  "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_project { 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'. A project must always have at least one of {owner, team} set \u2014 `teamId: null` on a project whose owner is already null returns 422 'owner or team is required'."
1824
1891
  ),
1825
1892
  stageId: positiveId.nullable().optional().describe(
1826
- "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."
1893
+ "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 project update 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."
1827
1894
  ),
1828
1895
  expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
1829
1896
  fields: z9.array(CustomFieldWriteSchema).optional().describe(
@@ -1840,7 +1907,7 @@ async function updateProject(input) {
1840
1907
  let resolvedTeamId = teamId;
1841
1908
  let resolvedStageId = stageId;
1842
1909
  if (ownerId !== void 0 && (teamId === void 0 || stageId === void 0)) {
1843
- const current = await readEntityRefs(`/kases/${id}`, "kase");
1910
+ const current = await readEntityRefs(`/kases/${id}`, "project");
1844
1911
  if (teamId === void 0) resolvedTeamId = current.teamId;
1845
1912
  if (stageId === void 0) resolvedStageId = current.stageId;
1846
1913
  }
@@ -1861,7 +1928,7 @@ var { schema: batchUpdateProjectSchema, handler: batchUpdateProject } = defineBa
1861
1928
  var { schema: deleteProjectSchema, handler: deleteProject } = defineDelete({
1862
1929
  toolName: "delete_project",
1863
1930
  pathPrefix: "/kases",
1864
- confirmHint: "Must be set to true. Permanently deletes the project (case). Consider update_project status='CLOSED' instead. Irreversible."
1931
+ confirmHint: "Must be set to true. Permanently deletes the project. Consider update_project status='CLOSED' instead. Irreversible."
1865
1932
  });
1866
1933
 
1867
1934
  // src/tools/tasks.ts
@@ -1949,7 +2016,7 @@ var updateTaskSchema = z10.object({
1949
2016
  "Re-link the task to an opportunity by id, or `null` to orphan it. Mutually exclusive with `partyId` / `projectId` \u2014 see `partyId` for the XOR semantic."
1950
2017
  ),
1951
2018
  projectId: positiveId.nullable().optional().describe(
1952
- "Re-link the task to a project (kase) by id, or `null` to orphan it. Mutually exclusive with `partyId` / `opportunityId` \u2014 see `partyId` for the XOR semantic."
2019
+ "Re-link the task to a project by id, or `null` to orphan it. Mutually exclusive with `partyId` / `opportunityId` \u2014 see `partyId` for the XOR semantic."
1953
2020
  )
1954
2021
  });
1955
2022
  async function updateTask(input) {
@@ -1991,13 +2058,13 @@ var { schema: deleteTaskSchema, handler: deleteTask } = defineDelete({
1991
2058
  import { z as z11 } from "zod";
1992
2059
  var listEntriesPagination = {
1993
2060
  ...paginationFields,
1994
- embed: z11.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
2061
+ embed: embedParam(ENTRY_EMBEDS)
1995
2062
  };
1996
2063
  var listPartyEntriesSchema = z11.object({
1997
2064
  partyId: positiveId,
1998
2065
  ...listEntriesPagination,
1999
2066
  includeLinkedPersons: z11.boolean().optional().describe(
2000
- "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."
2067
+ "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, opportunity, or project row (verified v1.6.6 wire-trace probe 4 \u2014 POST /entries rejects multi-party bodies with 422). 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."
2001
2068
  )
2002
2069
  });
2003
2070
  var PER_PARTY_FETCH_CAP = 100;
@@ -2099,7 +2166,7 @@ async function listProjectEntries(input) {
2099
2166
  }
2100
2167
  var getEntrySchema = z11.object({
2101
2168
  id: positiveId,
2102
- embed: z11.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
2169
+ embed: embedParam(ENTRY_EMBEDS)
2103
2170
  });
2104
2171
  async function getEntry(input) {
2105
2172
  const { data } = await capsuleGet(`/entries/${input.id}`, {
@@ -2212,16 +2279,16 @@ import { z as z14 } from "zod";
2212
2279
  var TAG_LIST_PATH = {
2213
2280
  parties: "/parties/tags",
2214
2281
  opportunities: "/opportunities/tags",
2215
- kases: "/kases/tags"
2282
+ projects: "/kases/tags"
2216
2283
  };
2217
2284
  var ENTITY_TO_WRAPPER = {
2218
2285
  parties: "party",
2219
2286
  opportunities: "opportunity",
2220
- kases: "kase"
2287
+ projects: "kase"
2221
2288
  };
2222
- var TagEntity = z14.enum(["parties", "opportunities", "kases"]).describe("Which entity type. Use 'kases' for projects (Capsule's legacy path name).");
2289
+ var TagEntity = z14.enum(["parties", "opportunities", "projects"]).describe("Which entity type.");
2223
2290
  var listTagsSchema = z14.object({
2224
- entity: z14.enum(["parties", "opportunities", "kases"]).describe("The resource type to list tags for"),
2291
+ entity: z14.enum(["parties", "opportunities", "projects"]).describe("The resource type to list tags for"),
2225
2292
  ...paginationFieldsNoDefaults
2226
2293
  });
2227
2294
  async function listTags(input) {
@@ -2233,7 +2300,7 @@ async function listTags(input) {
2233
2300
  }
2234
2301
  var addTagSchema = z14.object({
2235
2302
  entity: TagEntity,
2236
- entityId: positiveId.describe("The party/opportunity/kase id."),
2303
+ entityId: positiveId.describe("The party/opportunity/project id."),
2237
2304
  tagName: z14.string().min(1).describe(
2238
2305
  "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."
2239
2306
  )
@@ -2241,7 +2308,7 @@ var addTagSchema = z14.object({
2241
2308
  async function addTag(input) {
2242
2309
  const { entity, entityId, tagName } = input;
2243
2310
  const wrapper = ENTITY_TO_WRAPPER[entity];
2244
- const result = await capsulePut(`/${entity}/${entityId}`, {
2311
+ const result = await capsulePut(`/${ENTITY_PATH[entity]}/${entityId}`, {
2245
2312
  [wrapper]: { tags: [{ name: tagName }] }
2246
2313
  });
2247
2314
  invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
@@ -2249,7 +2316,7 @@ async function addTag(input) {
2249
2316
  }
2250
2317
  var removeTagByIdSchema = z14.object({
2251
2318
  entity: TagEntity,
2252
- entityId: positiveId.describe("The party/opportunity/kase id."),
2319
+ entityId: positiveId.describe("The party/opportunity/project id."),
2253
2320
  tagId: positiveId.describe(
2254
2321
  "The tag's id. Read via get_party / get_opportunity / get_project with embed='tags' \u2014 each tag entry in the response has an `id` field. list_tags returns the same ids for the same tags, so either source works; reading via embed first is the safer pattern because it confirms the tag is actually attached to this entity before you try to remove it (otherwise Capsule returns 422 'tag not found to delete'). Removing detaches the tag from this entity only; the tag definition itself persists in the tenant for other entities that share it."
2255
2322
  )
@@ -2258,7 +2325,7 @@ async function removeTagById(input) {
2258
2325
  const { entity, entityId, tagId } = input;
2259
2326
  const wrapper = ENTITY_TO_WRAPPER[entity];
2260
2327
  const result = await idempotentWithResult(
2261
- () => capsulePut(`/${entity}/${entityId}`, {
2328
+ () => capsulePut(`/${ENTITY_PATH[entity]}/${entityId}`, {
2262
2329
  [wrapper]: { tags: [{ id: tagId, _delete: true }] }
2263
2330
  }),
2264
2331
  (result2) => ({
@@ -2293,7 +2360,7 @@ async function deleteTagDefinition(input) {
2293
2360
  throw new Error("delete_tag_definition requires confirm: true");
2294
2361
  }
2295
2362
  const result = await idempotent(
2296
- () => capsuleDelete(`/${entity}/tags/${tagId}`),
2363
+ () => capsuleDelete(`/${ENTITY_PATH[entity]}/tags/${tagId}`),
2297
2364
  () => ({ deleted: true, alreadyDeleted: false, entity, tagId }),
2298
2365
  () => ({ deleted: true, alreadyDeleted: true, entity, tagId })
2299
2366
  );
@@ -2347,7 +2414,7 @@ var FilterInputSchema = z16.object({
2347
2414
  conditions: z16.array(FilterConditionSchema).min(1).describe(
2348
2415
  "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)."
2349
2416
  ),
2350
- embed: z16.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2417
+ embed: embedParam(RECORD_EMBEDS),
2351
2418
  ...paginationFields
2352
2419
  });
2353
2420
  async function runFilter(entityPath, input) {
@@ -2438,7 +2505,7 @@ var listEmployeesSchema = z18.object({
2438
2505
  "The organisation's party id. Returns the people whose `organisation` field links to this party."
2439
2506
  ),
2440
2507
  ...paginationFields,
2441
- embed: z18.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2508
+ embed: embedParam(RECORD_EMBEDS)
2442
2509
  });
2443
2510
  async function listEmployees(input) {
2444
2511
  return capsuleGetList(`/parties/${input.partyId}/people`, {
@@ -2481,19 +2548,22 @@ async function listDeletedProjects(input) {
2481
2548
 
2482
2549
  // src/tools/relationships.ts
2483
2550
  import { z as z19 } from "zod";
2484
- var RelationshipEntity = z19.enum(["opportunities", "kases"]).describe("Which entity has the additional-party links. Use 'kases' for projects.");
2551
+ var RelationshipEntity = z19.enum(["opportunities", "projects"]).describe("Which entity has the additional-party links.");
2485
2552
  var listAdditionalPartiesSchema = z19.object({
2486
2553
  entity: RelationshipEntity,
2487
2554
  entityId: positiveId.describe("ID of the opportunity or project."),
2488
- embed: z19.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2555
+ embed: embedParam(RECORD_EMBEDS),
2489
2556
  ...paginationFields
2490
2557
  });
2491
2558
  async function listAdditionalParties(input) {
2492
- return capsuleGetList(`/${input.entity}/${input.entityId}/parties`, {
2493
- embed: input.embed,
2494
- page: input.page,
2495
- perPage: input.perPage
2496
- });
2559
+ return capsuleGetList(
2560
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/parties`,
2561
+ {
2562
+ embed: input.embed,
2563
+ page: input.page,
2564
+ perPage: input.perPage
2565
+ }
2566
+ );
2497
2567
  }
2498
2568
  var addAdditionalPartySchema = z19.object({
2499
2569
  entity: RelationshipEntity,
@@ -2504,7 +2574,9 @@ var addAdditionalPartySchema = z19.object({
2504
2574
  });
2505
2575
  async function addAdditionalParty(input) {
2506
2576
  try {
2507
- await capsulePostNoContent(`/${input.entity}/${input.entityId}/parties/${input.partyId}`);
2577
+ await capsulePostNoContent(
2578
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/parties/${input.partyId}`
2579
+ );
2508
2580
  return {
2509
2581
  linked: true,
2510
2582
  alreadyLinked: false,
@@ -2541,7 +2613,7 @@ async function removeAdditionalParty(input) {
2541
2613
  throw new Error("remove_additional_party requires confirm: true");
2542
2614
  }
2543
2615
  return idempotent(
2544
- () => capsuleDelete(`/${input.entity}/${input.entityId}/parties/${input.partyId}`),
2616
+ () => capsuleDelete(`/${ENTITY_PATH[input.entity]}/${input.entityId}/parties/${input.partyId}`),
2545
2617
  () => ({
2546
2618
  removed: true,
2547
2619
  alreadyRemoved: false,
@@ -2560,7 +2632,7 @@ async function removeAdditionalParty(input) {
2560
2632
  }
2561
2633
  var listAssociatedProjectsSchema = z19.object({
2562
2634
  opportunityId: positiveId,
2563
- embed: z19.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2635
+ embed: embedParam(RECORD_EMBEDS),
2564
2636
  ...paginationFields
2565
2637
  });
2566
2638
  async function listAssociatedProjects(input) {
@@ -2573,49 +2645,49 @@ async function listAssociatedProjects(input) {
2573
2645
 
2574
2646
  // src/tools/custom-fields.ts
2575
2647
  import { z as z20 } from "zod";
2576
- var CustomFieldEntity = z20.enum(["parties", "opportunities", "kases"]).describe("Which entity type's custom field schema to inspect. Use 'kases' for projects.");
2648
+ var CustomFieldEntity = z20.enum(["parties", "opportunities", "projects"]).describe("Which entity type's custom field schema to inspect.");
2577
2649
  var listCustomFieldsSchema = z20.object({
2578
2650
  entity: CustomFieldEntity
2579
2651
  });
2580
2652
  async function listCustomFields(input) {
2581
2653
  const { data } = await capsuleGetCached(
2582
- `/${input.entity}/fields/definitions`
2654
+ `/${ENTITY_PATH[input.entity]}/fields/definitions`
2583
2655
  );
2584
2656
  return data;
2585
2657
  }
2586
2658
  var getCustomFieldSchema = z20.object({
2587
2659
  entity: CustomFieldEntity,
2588
- fieldId: positiveId.describe("Custom field definition id.")
2660
+ id: positiveId.describe("Custom field definition id.")
2589
2661
  });
2590
2662
  async function getCustomField(input) {
2591
2663
  const { data } = await capsuleGetCached(
2592
- `/${input.entity}/fields/definitions/${input.fieldId}`
2664
+ `/${ENTITY_PATH[input.entity]}/fields/definitions/${input.id}`
2593
2665
  );
2594
2666
  return data;
2595
2667
  }
2596
2668
 
2597
2669
  // src/tools/tracks.ts
2598
2670
  import { z as z21 } from "zod";
2599
- var TrackEntity = z21.enum(["parties", "opportunities", "kases"]).describe("Use 'kases' for projects.");
2671
+ var TrackEntity = z21.enum(["parties", "opportunities", "projects"]).describe("Which entity type.");
2600
2672
  var listEntityTracksSchema = z21.object({
2601
2673
  entity: TrackEntity,
2602
2674
  entityId: positiveId
2603
2675
  });
2604
2676
  async function listEntityTracks(input) {
2605
2677
  const { data } = await capsuleGet(
2606
- `/${input.entity}/${input.entityId}/tracks`
2678
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/tracks`
2607
2679
  );
2608
2680
  return data;
2609
2681
  }
2610
- var showTrackSchema = z21.object({
2611
- trackId: positiveId
2682
+ var getTrackSchema = z21.object({
2683
+ id: positiveId
2612
2684
  });
2613
- async function showTrack(input) {
2614
- const { data } = await capsuleGet(`/tracks/${input.trackId}`);
2685
+ async function getTrack(input) {
2686
+ const { data } = await capsuleGet(`/tracks/${input.id}`);
2615
2687
  return data;
2616
2688
  }
2617
2689
  var applyTrackSchema = z21.object({
2618
- entity: z21.enum(["opportunities", "kases"]).describe("Which entity to apply the track to. Use 'kases' for projects."),
2690
+ entity: z21.enum(["opportunities", "projects"]).describe("Which entity to apply the track to."),
2619
2691
  entityId: positiveId,
2620
2692
  trackDefinitionId: positiveId.describe(
2621
2693
  "The trackDefinition to apply (from list_track_definitions). Auto-creates task definitions on the target entity per the track's rules."
@@ -2634,7 +2706,7 @@ async function applyTrack(input) {
2634
2706
  return capsulePost("/tracks", { track });
2635
2707
  }
2636
2708
  var updateTrackSchema = z21.object({
2637
- trackId: positiveId,
2709
+ id: positiveId,
2638
2710
  fields: z21.record(z21.string(), z21.unknown()).describe(
2639
2711
  "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."
2640
2712
  )
@@ -2643,12 +2715,12 @@ async function updateTrack(input) {
2643
2715
  if (Object.keys(input.fields).length === 0) {
2644
2716
  throw new Error("update_track: provide at least one field in `fields`");
2645
2717
  }
2646
- return capsulePut(`/tracks/${input.trackId}`, {
2718
+ return capsulePut(`/tracks/${input.id}`, {
2647
2719
  track: input.fields
2648
2720
  });
2649
2721
  }
2650
2722
  var removeTrackSchema = z21.object({
2651
- trackId: positiveId,
2723
+ id: positiveId,
2652
2724
  confirm: confirmFlag().describe(
2653
2725
  "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."
2654
2726
  )
@@ -2658,9 +2730,9 @@ async function removeTrack(input) {
2658
2730
  throw new Error("remove_track requires confirm: true");
2659
2731
  }
2660
2732
  return idempotent(
2661
- () => capsuleDelete(`/tracks/${input.trackId}`),
2662
- () => ({ removed: true, alreadyRemoved: false, trackId: input.trackId }),
2663
- () => ({ removed: true, alreadyRemoved: true, trackId: input.trackId })
2733
+ () => capsuleDelete(`/tracks/${input.id}`),
2734
+ () => ({ removed: true, alreadyRemoved: false, id: input.id }),
2735
+ () => ({ removed: true, alreadyRemoved: true, id: input.id })
2664
2736
  );
2665
2737
  }
2666
2738
 
@@ -2747,28 +2819,31 @@ async function uploadAttachment(input) {
2747
2819
 
2748
2820
  // src/tools/saved-filters.ts
2749
2821
  import { z as z23 } from "zod";
2750
- var EntitySchema = z23.enum(["parties", "opportunities", "kases"]).describe(
2751
- "Which entity type the filter operates over. Use 'kases' for projects (Capsule's legacy name)."
2752
- );
2822
+ var EntitySchema = z23.enum(["parties", "opportunities", "projects"]).describe("Which entity type the filter operates over.");
2753
2823
  var listSavedFiltersSchema = z23.object({
2754
2824
  entity: EntitySchema
2755
2825
  });
2756
2826
  async function listSavedFilters(input) {
2757
- const { data } = await capsuleGetCached(`/${input.entity}/filters`);
2827
+ const { data } = await capsuleGetCached(
2828
+ `/${ENTITY_PATH[input.entity]}/filters`
2829
+ );
2758
2830
  return data;
2759
2831
  }
2760
2832
  var runSavedFilterSchema = z23.object({
2761
2833
  entity: EntitySchema,
2762
2834
  id: positiveId.describe("The saved filter id (from list_saved_filters)."),
2763
- embed: z23.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2835
+ embed: embedParam(RECORD_EMBEDS),
2764
2836
  ...paginationFields
2765
2837
  });
2766
2838
  async function runSavedFilter(input) {
2767
- return capsuleGetList(`/${input.entity}/filters/${input.id}/results`, {
2768
- page: input.page,
2769
- perPage: input.perPage,
2770
- embed: input.embed
2771
- });
2839
+ return capsuleGetList(
2840
+ `/${ENTITY_PATH[input.entity]}/filters/${input.id}/results`,
2841
+ {
2842
+ page: input.page,
2843
+ perPage: input.perPage,
2844
+ embed: input.embed
2845
+ }
2846
+ );
2772
2847
  }
2773
2848
 
2774
2849
  // src/server.ts
@@ -2779,7 +2854,7 @@ function createCapsuleMcpServer(opts) {
2779
2854
  const server2 = new McpServer(
2780
2855
  {
2781
2856
  name: "capsulemcp",
2782
- version: "1.8.1",
2857
+ version: "2.0.1",
2783
2858
  description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
2784
2859
  websiteUrl: "https://github.com/soil-dev/capsulemcp",
2785
2860
  icons: ICONS
@@ -2839,7 +2914,7 @@ function createCapsuleMcpServer(opts) {
2839
2914
  registerTool(
2840
2915
  server2,
2841
2916
  "list_party_projects",
2842
- "List projects (cases) linked to a given party. Returns the same record shape as get_project, filtered to one party \u2014 use this to answer 'what cases is X involved in?' without enumerating all projects. Accepts optional embed (e.g. 'tags,fields'). For the opportunity-side analogue, use list_party_opportunities.",
2917
+ "List projects linked to a given party. Returns the same record shape as get_project, filtered to one party \u2014 use this to answer 'what projects is X involved in?' without enumerating all projects. Accepts optional embed (e.g. 'tags,fields'). For the opportunity-side analogue, use list_party_opportunities.",
2843
2918
  listPartyProjectsSchema,
2844
2919
  listPartyProjects
2845
2920
  );
@@ -2853,7 +2928,7 @@ function createCapsuleMcpServer(opts) {
2853
2928
  registerTool(
2854
2929
  server2,
2855
2930
  "list_custom_fields",
2856
- "List custom field DEFINITIONS for an entity type (parties, opportunities, or projects/kases). Returns the schema \u2014 name, type, options for list-type fields, etc. \u2014 NOT the values on any specific record. To read values on a record, use get_party / get_opportunity / get_project with embed=fields.",
2931
+ "List custom field DEFINITIONS for an entity type (parties, opportunities, or projects). Returns the schema \u2014 name, type, options for list-type fields, etc. \u2014 NOT the values on any specific record. To read values on a record, use get_party / get_opportunity / get_project with embed=fields.",
2857
2932
  listCustomFieldsSchema,
2858
2933
  listCustomFields
2859
2934
  );
@@ -2896,7 +2971,7 @@ function createCapsuleMcpServer(opts) {
2896
2971
  registerTool(
2897
2972
  server2,
2898
2973
  "delete_party",
2899
- "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).",
2974
+ "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).",
2900
2975
  deletePartySchema,
2901
2976
  deleteParty
2902
2977
  );
@@ -2995,14 +3070,14 @@ function createCapsuleMcpServer(opts) {
2995
3070
  registerTool(
2996
3071
  server2,
2997
3072
  "list_additional_parties",
2998
- "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).",
3073
+ "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'.",
2999
3074
  listAdditionalPartiesSchema,
3000
3075
  listAdditionalParties
3001
3076
  );
3002
3077
  registerTool(
3003
3078
  server2,
3004
3079
  "list_associated_projects",
3005
- "List projects (cases) associated with a given opportunity. Returns the same record shape as list_projects, filtered to one opportunity. The inverse direction (project \u2192 opportunity) is on each project's `opportunity` field directly, so this tool is only needed for opportunity \u2192 projects discovery \u2014 use list_party_projects for party \u2192 projects.",
3080
+ "List projects associated with a given opportunity. Returns the same record shape as list_projects, filtered to one opportunity. The inverse direction (project \u2192 opportunity) is on each project's `opportunity` field directly, so this tool is only needed for opportunity \u2192 projects discovery \u2014 use list_party_projects for party \u2192 projects.",
3006
3081
  listAssociatedProjectsSchema,
3007
3082
  listAssociatedProjects
3008
3083
  );
@@ -3036,38 +3111,45 @@ function createCapsuleMcpServer(opts) {
3036
3111
  deleteOpportunity
3037
3112
  );
3038
3113
  }
3114
+ registerTool(
3115
+ server2,
3116
+ "search_projects",
3117
+ "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.",
3118
+ searchProjectsSchema,
3119
+ searchProjects
3120
+ );
3039
3121
  registerTool(
3040
3122
  server2,
3041
3123
  "list_projects",
3042
- "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.",
3124
+ "List projects 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.",
3043
3125
  listProjectsSchema,
3044
3126
  listProjects
3045
3127
  );
3046
3128
  registerTool(
3047
3129
  server2,
3048
3130
  "filter_projects",
3049
- "Filter projects (cases) by structured conditions (date ranges, status, tags, owner). Use this \u2014 not list_projects \u2014 for questions like 'most recent project', 'projects opened this month'. Capsule's API does not support ad-hoc sort, but for 'most recent X' you can filter by a date field and pick the highest-id row \u2014 Capsule IDs are monotonic, so newest id = newest record.",
3131
+ "Filter projects by structured conditions (date ranges, status, tags, owner). Use this \u2014 not list_projects \u2014 for questions like 'most recent project', 'projects opened this month'. Capsule's API does not support ad-hoc sort, but for 'most recent X' you can filter by a date field and pick the highest-id row \u2014 Capsule IDs are monotonic, so newest id = newest record.",
3050
3132
  filterProjectsSchema,
3051
3133
  filterProjects
3052
3134
  );
3053
3135
  registerTool(
3054
3136
  server2,
3055
3137
  "get_project",
3056
- "Fetch a single project (Capsule's term: 'case') by its numeric id. Returns the full record including name, description, status (OPEN/CLOSED), owner, stage, board, opportunityId (if linked), and timestamps. Use embed='tags,fields' to include attached tags and custom field values in one round-trip. For batch fetches of up to 50 projects at once, use get_projects instead. For the project's timeline (notes, captured emails, completed-task records) use list_project_entries.",
3138
+ "Fetch a single project by its numeric id. Returns the full record including name, description, status (OPEN/CLOSED), owner, stage, board, opportunityId (if linked), and timestamps. Use embed='tags,fields' to include attached tags and custom field values in one round-trip. For batch fetches of up to 50 projects at once, use get_projects instead. For the project's timeline (notes, captured emails, completed-task records) use list_project_entries.",
3057
3139
  getProjectSchema,
3058
3140
  getProject
3059
3141
  );
3060
3142
  registerTool(
3061
3143
  server2,
3062
3144
  "get_projects",
3063
- "Batch-fetch up to 50 projects (cases) by ID. For 1\u201310 ids this is a single Capsule round trip; for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged.",
3145
+ "Batch-fetch up to 50 projects by ID. For 1\u201310 ids this is a single Capsule round trip; for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged.",
3064
3146
  getProjectsSchema,
3065
3147
  getProjects
3066
3148
  );
3067
3149
  registerTool(
3068
3150
  server2,
3069
3151
  "list_deleted_projects",
3070
- "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.",
3152
+ "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.",
3071
3153
  listDeletedProjectsSchema,
3072
3154
  listDeletedProjects
3073
3155
  );
@@ -3075,7 +3157,7 @@ function createCapsuleMcpServer(opts) {
3075
3157
  registerTool(
3076
3158
  server2,
3077
3159
  "create_project",
3078
- "Create a new project (case) in Capsule CRM linked to a party. Requires partyId and name; description, status, owner, and starting board/stage are optional. To pin a project to a specific board+stage on creation, pass stageId (which uniquely identifies a stage within a board). Discover valid ids via list_boards + list_stages. Returns the created project including its assigned id.",
3160
+ "Create a new project in Capsule CRM linked to a party. Requires partyId and name; description, status, owner, and starting board/stage are optional. To pin a project to a specific board+stage on creation, pass stageId (which uniquely identifies a stage within a board). Discover valid ids via list_boards + list_stages. Returns the created project including its assigned id.",
3079
3161
  createProjectSchema,
3080
3162
  createProject
3081
3163
  );
@@ -3096,7 +3178,7 @@ function createCapsuleMcpServer(opts) {
3096
3178
  registerTool(
3097
3179
  server2,
3098
3180
  "delete_project",
3099
- "DESTRUCTIVE & IRREVERSIBLE: permanently delete a project (case). Prefer update_project with status='CLOSED' to close a project while preserving history. Requires confirm=true. Always read the project first with get_project 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 project was already gone.",
3181
+ "DESTRUCTIVE & IRREVERSIBLE: permanently delete a project. Prefer update_project with status='CLOSED' to close a project while preserving history. Requires confirm=true. Always read the project first with get_project 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 project was already gone.",
3100
3182
  deleteProjectSchema,
3101
3183
  deleteProject
3102
3184
  );
@@ -3211,7 +3293,7 @@ function createCapsuleMcpServer(opts) {
3211
3293
  registerTool(
3212
3294
  server2,
3213
3295
  "list_project_entries",
3214
- "List timeline entries (notes, captured emails, completed-task records) for a project (case). Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to answer 'what's the latest on case X?' For party or opportunity timelines, use list_party_entries or list_opportunity_entries respectively.",
3296
+ "List timeline entries (notes, captured emails, completed-task records) for a project. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to answer 'what's the latest on project X?' For party or opportunity timelines, use list_party_entries or list_opportunity_entries respectively.",
3215
3297
  listProjectEntriesSchema,
3216
3298
  listProjectEntries
3217
3299
  );
@@ -3362,14 +3444,14 @@ function createCapsuleMcpServer(opts) {
3362
3444
  registerTool(
3363
3445
  server2,
3364
3446
  "list_boards",
3365
- "List all project (case) boards defined in Capsule. A board is a grouping of stages that projects flow through \u2014 the project equivalent of an opportunity pipeline. Returns each board's id, name, and stages. Use this to discover boardId when creating a project, then pick a starting stage via list_stages. Like pipelines, boards are stable per account.",
3447
+ "List all project boards defined in Capsule. A board is a grouping of stages that projects flow through \u2014 the project equivalent of an opportunity pipeline. Returns each board's id, name, and stages. Use this to discover boardId when creating a project, then pick a starting stage via list_stages. Like pipelines, boards are stable per account.",
3366
3448
  listBoardsSchema,
3367
3449
  listBoards
3368
3450
  );
3369
3451
  registerTool(
3370
3452
  server2,
3371
3453
  "list_stages",
3372
- "List project (case) stages. Without arguments returns every stage across every board (each entry carries a `.board` reference so you can tell them apart). Pass `boardId` to scope the result to one specific board's stages. Use this to discover the numeric `stage.id` that `create_project` / `update_project` consume \u2014 stage names alone won't do, Capsule resolves by id. For opportunity (deal) stages, use `list_pipelines` instead \u2014 opportunities don't have stages in the project sense.",
3454
+ "List project stages. Without arguments returns every stage across every board (each entry carries a `.board` reference so you can tell them apart). Pass `boardId` to scope the result to one specific board's stages. Use this to discover the numeric `stage.id` that `create_project` / `update_project` consume \u2014 stage names alone won't do, Capsule resolves by id. For opportunity (deal) stages, use `list_pipelines` instead \u2014 opportunities don't have stages in the project sense.",
3373
3455
  listStagesSchema,
3374
3456
  listStages
3375
3457
  );
@@ -3382,14 +3464,14 @@ function createCapsuleMcpServer(opts) {
3382
3464
  );
3383
3465
  registerTool(
3384
3466
  server2,
3385
- "list_lostreasons",
3467
+ "list_lost_reasons",
3386
3468
  "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.",
3387
3469
  listLostReasonsSchema,
3388
3470
  listLostReasons
3389
3471
  );
3390
3472
  registerTool(
3391
3473
  server2,
3392
- "list_activitytypes",
3474
+ "list_activity_types",
3393
3475
  "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.",
3394
3476
  listActivityTypesSchema,
3395
3477
  listActivityTypes
@@ -3417,10 +3499,10 @@ function createCapsuleMcpServer(opts) {
3417
3499
  );
3418
3500
  registerTool(
3419
3501
  server2,
3420
- "show_track",
3502
+ "get_track",
3421
3503
  "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.",
3422
- showTrackSchema,
3423
- showTrack
3504
+ getTrackSchema,
3505
+ getTrack
3424
3506
  );
3425
3507
  registerTool(
3426
3508
  server2,
@@ -3453,7 +3535,7 @@ function createCapsuleMcpServer(opts) {
3453
3535
  registerTool(
3454
3536
  server2,
3455
3537
  "list_tags",
3456
- "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?'",
3538
+ "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?'",
3457
3539
  listTagsSchema,
3458
3540
  listTags
3459
3541
  );
@@ -3461,21 +3543,21 @@ function createCapsuleMcpServer(opts) {
3461
3543
  registerTool(
3462
3544
  server2,
3463
3545
  "add_tag",
3464
- "Attach a tag to a party, opportunity, or project (kase) by NAME. Capsule resolves to an existing tag in the tenant or creates a fresh one with this name. Matching is case-insensitive \u2014 'VIP' and 'vip' attach the same tag, preserving the canonical casing from whichever variant was created first. To avoid creating a genuinely-distinct near-duplicate (e.g. 'VIP' vs 'V.I.P.'), call list_tags first and reuse the exact name. Idempotent \u2014 re-attaching an already-attached tag is harmless. To DETACH a tag, use remove_tag_by_id with the tag's id (read via get_party/get_opportunity/get_project with embed='tags').",
3546
+ "Attach a tag to a party, opportunity, or project by NAME. Capsule resolves to an existing tag in the tenant or creates a fresh one with this name. Matching is case-insensitive \u2014 'VIP' and 'vip' attach the same tag, preserving the canonical casing from whichever variant was created first. To avoid creating a genuinely-distinct near-duplicate (e.g. 'VIP' vs 'V.I.P.'), call list_tags first and reuse the exact name. Idempotent \u2014 re-attaching an already-attached tag is harmless. To DETACH a tag, use remove_tag_by_id with the tag's id (read via get_party/get_opportunity/get_project with embed='tags').",
3465
3547
  addTagSchema,
3466
3548
  addTag
3467
3549
  );
3468
3550
  registerTool(
3469
3551
  server2,
3470
3552
  "remove_tag_by_id",
3471
- "Detach a tag from a party, opportunity, or project (kase). Atomic \u2014 one PUT to Capsule. Reversible \u2014 no `confirm: true` gate (re-attach with add_tag using the same tag name). The `tagId` parameter is the tag's id, readable via get_party/get_opportunity/get_project with embed='tags' (list_tags returns the same ids and also works, but reading via embed first confirms the tag is actually attached to this entity). The tag definition itself remains in the tenant for other entities that still share it. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, entity, entityId, tagId, ...<updated entity>}` on a fresh detach or `{removed: true, alreadyRemoved: true, entity, entityId, tagId}` if the tag was already detached (Capsule's 422 'tag not found to delete' is caught and converted).",
3553
+ "Detach a tag from a party, opportunity, or project. Atomic \u2014 one PUT to Capsule. Reversible \u2014 no `confirm: true` gate (re-attach with add_tag using the same tag name). The `tagId` parameter is the tag's id, readable via get_party/get_opportunity/get_project with embed='tags' (list_tags returns the same ids and also works, but reading via embed first confirms the tag is actually attached to this entity). The tag definition itself remains in the tenant for other entities that still share it. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, entity, entityId, tagId, ...<updated entity>}` on a fresh detach or `{removed: true, alreadyRemoved: true, entity, entityId, tagId}` if the tag was already detached (Capsule's 422 'tag not found to delete' is caught and converted).",
3472
3554
  removeTagByIdSchema,
3473
3555
  removeTagById
3474
3556
  );
3475
3557
  registerTool(
3476
3558
  server2,
3477
3559
  "delete_tag_definition",
3478
- "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).",
3560
+ "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).",
3479
3561
  deleteTagDefinitionSchema,
3480
3562
  deleteTagDefinition
3481
3563
  );