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/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() {
@@ -427,7 +447,8 @@ async function throwForStatus(res) {
427
447
  }
428
448
  async function handleResponse(res) {
429
449
  await throwForStatus(res);
430
- return mapAbort(res.json());
450
+ const body = await mapAbort(res.json());
451
+ return normalizeProjectKeys(body);
431
452
  }
432
453
  function buildUrl(path, params) {
433
454
  const url = new URL(`${baseUrl()}${path}`);
@@ -1154,6 +1175,7 @@ var CORE_TOOLS = /* @__PURE__ */ new Set([
1154
1175
  "create_opportunity",
1155
1176
  "update_opportunity",
1156
1177
  // Projects
1178
+ "search_projects",
1157
1179
  "filter_projects",
1158
1180
  "list_projects",
1159
1181
  "get_project",
@@ -1651,10 +1673,6 @@ function defineBatch(args) {
1651
1673
  return { schema, handler };
1652
1674
  }
1653
1675
 
1654
- // src/tools/descriptions.ts
1655
- var EMBED_TAGS_FIELDS_DESCRIPTION = "Comma-separated embeds, e.g. 'tags,fields'";
1656
- var EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION = "Comma-separated embeds, e.g. 'attachments,participants'";
1657
-
1658
1676
  // src/tools/define-delete.ts
1659
1677
  import { z as z6 } from "zod";
1660
1678
 
@@ -1680,6 +1698,26 @@ var paginationFieldsNoDefaults = {
1680
1698
  page: z5.number().int().positive().optional(),
1681
1699
  perPage: z5.number().int().min(1).max(100).optional()
1682
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"];
1683
1721
 
1684
1722
  // src/capsule/idempotent.ts
1685
1723
  var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
@@ -1844,7 +1882,7 @@ var WebsiteSchema = z8.object({
1844
1882
  }).superRefine(validateWebsiteAddress);
1845
1883
  var searchPartiesSchema = z8.object({
1846
1884
  q: z8.string().optional().describe("Free-text search query"),
1847
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
1885
+ embed: embedParam(RECORD_EMBEDS),
1848
1886
  ...paginationFields
1849
1887
  });
1850
1888
  async function searchParties(input) {
@@ -1858,7 +1896,7 @@ async function searchParties(input) {
1858
1896
  }
1859
1897
  var getPartySchema = z8.object({
1860
1898
  id: positiveId.describe("Party ID"),
1861
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1899
+ embed: embedParam(RECORD_EMBEDS)
1862
1900
  });
1863
1901
  async function getParty(input) {
1864
1902
  const { data } = await capsuleGet(`/parties/${input.id}`, {
@@ -1870,7 +1908,7 @@ var getPartiesSchema = z8.object({
1870
1908
  ids: z8.array(positiveId).min(1).max(50).describe(
1871
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."
1872
1910
  ),
1873
- embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
1911
+ embed: embedParam(RECORD_EMBEDS)
1874
1912
  });
1875
1913
  async function getParties(input) {
1876
1914
  return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
@@ -1910,7 +1948,7 @@ var PartyWriteBaseSchema = {
1910
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."
1911
1949
  ),
1912
1950
  ownerId: positiveId.nullable().optional().describe(
1913
- "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."
1951
+ "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."
1914
1952
  ),
1915
1953
  teamId: positiveId.nullable().optional().describe(
1916
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)."
@@ -1936,6 +1974,21 @@ var createPartySchema = z8.object({
1936
1974
  fields: z8.array(CustomFieldWriteSchema).optional().describe(
1937
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."
1938
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
+ }
1939
1992
  });
1940
1993
  async function createParty(input) {
1941
1994
  const { ownerId, teamId, organisationId, fields, ...rest } = input;
@@ -1986,7 +2039,7 @@ var { schema: batchUpdatePartySchema, handler: batchUpdateParty } = defineBatch(
1986
2039
  var { schema: deletePartySchema, handler: deleteParty } = defineDelete({
1987
2040
  toolName: "delete_party",
1988
2041
  pathPrefix: "/parties",
1989
- 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."
1990
2043
  });
1991
2044
  function definePartySubResourceRemove(opts) {
1992
2045
  const shape = {
@@ -2121,7 +2174,7 @@ var OpportunityValueSchema = z9.object({
2121
2174
  });
2122
2175
  var searchOpportunitiesSchema = z9.object({
2123
2176
  q: z9.string().optional().describe("Free-text search query"),
2124
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2177
+ embed: embedParam(RECORD_EMBEDS),
2125
2178
  ...paginationFields
2126
2179
  });
2127
2180
  async function searchOpportunities(input) {
@@ -2135,7 +2188,7 @@ async function searchOpportunities(input) {
2135
2188
  }
2136
2189
  var getOpportunitySchema = z9.object({
2137
2190
  id: positiveId,
2138
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2191
+ embed: embedParam(RECORD_EMBEDS)
2139
2192
  });
2140
2193
  async function getOpportunity(input) {
2141
2194
  const { data } = await capsuleGet(`/opportunities/${input.id}`, {
@@ -2147,7 +2200,7 @@ var getOpportunitiesSchema = z9.object({
2147
2200
  ids: z9.array(positiveId).min(1).max(50).describe(
2148
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."
2149
2202
  ),
2150
- embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2203
+ embed: embedParam(RECORD_EMBEDS)
2151
2204
  });
2152
2205
  async function getOpportunities(input) {
2153
2206
  return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
@@ -2169,7 +2222,7 @@ var createOpportunitySchema = z9.object({
2169
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)."
2170
2223
  ),
2171
2224
  fields: z9.array(CustomFieldWriteSchema).optional().describe(
2172
- 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."
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 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."
2173
2226
  )
2174
2227
  });
2175
2228
  async function createOpportunity(input) {
@@ -2201,10 +2254,10 @@ var updateOpportunitySchema = z9.object({
2201
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)."
2202
2255
  ),
2203
2256
  lostReasonId: positiveId.optional().describe(
2204
- "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."
2205
2258
  ),
2206
2259
  ownerId: positiveId.nullable().optional().describe(
2207
- "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)."
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 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)."
2208
2261
  ),
2209
2262
  teamId: positiveId.nullable().optional().describe(
2210
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."
@@ -2246,9 +2299,23 @@ var { schema: deleteOpportunitySchema, handler: deleteOpportunity } = defineDele
2246
2299
 
2247
2300
  // src/tools/projects.ts
2248
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
+ }
2249
2316
  var listProjectsSchema = z10.object({
2250
2317
  status: z10.enum(["OPEN", "CLOSED"]).optional(),
2251
- embed: z10.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2318
+ embed: embedParam(RECORD_EMBEDS),
2252
2319
  ...paginationFields
2253
2320
  });
2254
2321
  async function listProjects(input) {
@@ -2261,7 +2328,7 @@ async function listProjects(input) {
2261
2328
  }
2262
2329
  var getProjectSchema = z10.object({
2263
2330
  id: positiveId,
2264
- embed: z10.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2331
+ embed: embedParam(RECORD_EMBEDS)
2265
2332
  });
2266
2333
  async function getProject(input) {
2267
2334
  const { data } = await capsuleGet(`/kases/${input.id}`, {
@@ -2273,10 +2340,10 @@ var getProjectsSchema = z10.object({
2273
2340
  ids: z10.array(positiveId).min(1).max(50).describe(
2274
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."
2275
2342
  ),
2276
- embed: z10.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
2343
+ embed: embedParam(RECORD_EMBEDS)
2277
2344
  });
2278
2345
  async function getProjects(input) {
2279
- return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
2346
+ return chunkedMultiGet("/kases", "projects", input.ids, { embed: input.embed });
2280
2347
  }
2281
2348
  var createProjectSchema = z10.object({
2282
2349
  name: z10.string().min(1),
@@ -2294,7 +2361,7 @@ var createProjectSchema = z10.object({
2294
2361
  ),
2295
2362
  expectedCloseOn: z10.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2296
2363
  fields: z10.array(CustomFieldWriteSchema).optional().describe(
2297
- 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."
2364
+ 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."
2298
2365
  )
2299
2366
  });
2300
2367
  async function createProject(input) {
@@ -2326,7 +2393,7 @@ var updateProjectSchema = z10.object({
2326
2393
  "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'."
2327
2394
  ),
2328
2395
  stageId: positiveId.nullable().optional().describe(
2329
- "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."
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 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."
2330
2397
  ),
2331
2398
  expectedCloseOn: z10.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2332
2399
  fields: z10.array(CustomFieldWriteSchema).optional().describe(
@@ -2343,7 +2410,7 @@ async function updateProject(input) {
2343
2410
  let resolvedTeamId = teamId;
2344
2411
  let resolvedStageId = stageId;
2345
2412
  if (ownerId !== void 0 && (teamId === void 0 || stageId === void 0)) {
2346
- const current = await readEntityRefs(`/kases/${id}`, "kase");
2413
+ const current = await readEntityRefs(`/kases/${id}`, "project");
2347
2414
  if (teamId === void 0) resolvedTeamId = current.teamId;
2348
2415
  if (stageId === void 0) resolvedStageId = current.stageId;
2349
2416
  }
@@ -2364,7 +2431,7 @@ var { schema: batchUpdateProjectSchema, handler: batchUpdateProject } = defineBa
2364
2431
  var { schema: deleteProjectSchema, handler: deleteProject } = defineDelete({
2365
2432
  toolName: "delete_project",
2366
2433
  pathPrefix: "/kases",
2367
- confirmHint: "Must be set to true. Permanently deletes the project (case). Consider update_project status='CLOSED' instead. Irreversible."
2434
+ confirmHint: "Must be set to true. Permanently deletes the project. Consider update_project status='CLOSED' instead. Irreversible."
2368
2435
  });
2369
2436
 
2370
2437
  // src/tools/tasks.ts
@@ -2452,7 +2519,7 @@ var updateTaskSchema = z11.object({
2452
2519
  "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."
2453
2520
  ),
2454
2521
  projectId: positiveId.nullable().optional().describe(
2455
- "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."
2522
+ "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."
2456
2523
  )
2457
2524
  });
2458
2525
  async function updateTask(input) {
@@ -2494,13 +2561,13 @@ var { schema: deleteTaskSchema, handler: deleteTask } = defineDelete({
2494
2561
  import { z as z12 } from "zod";
2495
2562
  var listEntriesPagination = {
2496
2563
  ...paginationFields,
2497
- embed: z12.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
2564
+ embed: embedParam(ENTRY_EMBEDS)
2498
2565
  };
2499
2566
  var listPartyEntriesSchema = z12.object({
2500
2567
  partyId: positiveId,
2501
2568
  ...listEntriesPagination,
2502
2569
  includeLinkedPersons: z12.boolean().optional().describe(
2503
- "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."
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, 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."
2504
2571
  )
2505
2572
  });
2506
2573
  var PER_PARTY_FETCH_CAP = 100;
@@ -2602,7 +2669,7 @@ async function listProjectEntries(input) {
2602
2669
  }
2603
2670
  var getEntrySchema = z12.object({
2604
2671
  id: positiveId,
2605
- embed: z12.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
2672
+ embed: embedParam(ENTRY_EMBEDS)
2606
2673
  });
2607
2674
  async function getEntry(input) {
2608
2675
  const { data } = await capsuleGet(`/entries/${input.id}`, {
@@ -2715,16 +2782,16 @@ import { z as z15 } from "zod";
2715
2782
  var TAG_LIST_PATH = {
2716
2783
  parties: "/parties/tags",
2717
2784
  opportunities: "/opportunities/tags",
2718
- kases: "/kases/tags"
2785
+ projects: "/kases/tags"
2719
2786
  };
2720
2787
  var ENTITY_TO_WRAPPER = {
2721
2788
  parties: "party",
2722
2789
  opportunities: "opportunity",
2723
- kases: "kase"
2790
+ projects: "kase"
2724
2791
  };
2725
- var TagEntity = z15.enum(["parties", "opportunities", "kases"]).describe("Which entity type. Use 'kases' for projects (Capsule's legacy path name).");
2792
+ var TagEntity = z15.enum(["parties", "opportunities", "projects"]).describe("Which entity type.");
2726
2793
  var listTagsSchema = z15.object({
2727
- entity: z15.enum(["parties", "opportunities", "kases"]).describe("The resource type to list tags for"),
2794
+ entity: z15.enum(["parties", "opportunities", "projects"]).describe("The resource type to list tags for"),
2728
2795
  ...paginationFieldsNoDefaults
2729
2796
  });
2730
2797
  async function listTags(input) {
@@ -2736,7 +2803,7 @@ async function listTags(input) {
2736
2803
  }
2737
2804
  var addTagSchema = z15.object({
2738
2805
  entity: TagEntity,
2739
- entityId: positiveId.describe("The party/opportunity/kase id."),
2806
+ entityId: positiveId.describe("The party/opportunity/project id."),
2740
2807
  tagName: z15.string().min(1).describe(
2741
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."
2742
2809
  )
@@ -2744,7 +2811,7 @@ var addTagSchema = z15.object({
2744
2811
  async function addTag(input) {
2745
2812
  const { entity, entityId, tagName } = input;
2746
2813
  const wrapper = ENTITY_TO_WRAPPER[entity];
2747
- const result = await capsulePut(`/${entity}/${entityId}`, {
2814
+ const result = await capsulePut(`/${ENTITY_PATH[entity]}/${entityId}`, {
2748
2815
  [wrapper]: { tags: [{ name: tagName }] }
2749
2816
  });
2750
2817
  invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
@@ -2752,7 +2819,7 @@ async function addTag(input) {
2752
2819
  }
2753
2820
  var removeTagByIdSchema = z15.object({
2754
2821
  entity: TagEntity,
2755
- entityId: positiveId.describe("The party/opportunity/kase id."),
2822
+ entityId: positiveId.describe("The party/opportunity/project id."),
2756
2823
  tagId: positiveId.describe(
2757
2824
  "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."
2758
2825
  )
@@ -2761,7 +2828,7 @@ async function removeTagById(input) {
2761
2828
  const { entity, entityId, tagId } = input;
2762
2829
  const wrapper = ENTITY_TO_WRAPPER[entity];
2763
2830
  const result = await idempotentWithResult(
2764
- () => capsulePut(`/${entity}/${entityId}`, {
2831
+ () => capsulePut(`/${ENTITY_PATH[entity]}/${entityId}`, {
2765
2832
  [wrapper]: { tags: [{ id: tagId, _delete: true }] }
2766
2833
  }),
2767
2834
  (result2) => ({
@@ -2796,7 +2863,7 @@ async function deleteTagDefinition(input) {
2796
2863
  throw new Error("delete_tag_definition requires confirm: true");
2797
2864
  }
2798
2865
  const result = await idempotent(
2799
- () => capsuleDelete(`/${entity}/tags/${tagId}`),
2866
+ () => capsuleDelete(`/${ENTITY_PATH[entity]}/tags/${tagId}`),
2800
2867
  () => ({ deleted: true, alreadyDeleted: false, entity, tagId }),
2801
2868
  () => ({ deleted: true, alreadyDeleted: true, entity, tagId })
2802
2869
  );
@@ -2850,7 +2917,7 @@ var FilterInputSchema = z17.object({
2850
2917
  conditions: z17.array(FilterConditionSchema).min(1).describe(
2851
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)."
2852
2919
  ),
2853
- embed: z17.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
2920
+ embed: embedParam(RECORD_EMBEDS),
2854
2921
  ...paginationFields
2855
2922
  });
2856
2923
  async function runFilter(entityPath, input) {
@@ -2941,7 +3008,7 @@ var listEmployeesSchema = z19.object({
2941
3008
  "The organisation's party id. Returns the people whose `organisation` field links to this party."
2942
3009
  ),
2943
3010
  ...paginationFields,
2944
- embed: z19.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
3011
+ embed: embedParam(RECORD_EMBEDS)
2945
3012
  });
2946
3013
  async function listEmployees(input) {
2947
3014
  return capsuleGetList(`/parties/${input.partyId}/people`, {
@@ -2984,19 +3051,22 @@ async function listDeletedProjects(input) {
2984
3051
 
2985
3052
  // src/tools/relationships.ts
2986
3053
  import { z as z20 } from "zod";
2987
- var RelationshipEntity = z20.enum(["opportunities", "kases"]).describe("Which entity has the additional-party links. Use 'kases' for projects.");
3054
+ var RelationshipEntity = z20.enum(["opportunities", "projects"]).describe("Which entity has the additional-party links.");
2988
3055
  var listAdditionalPartiesSchema = z20.object({
2989
3056
  entity: RelationshipEntity,
2990
3057
  entityId: positiveId.describe("ID of the opportunity or project."),
2991
- embed: z20.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
3058
+ embed: embedParam(RECORD_EMBEDS),
2992
3059
  ...paginationFields
2993
3060
  });
2994
3061
  async function listAdditionalParties(input) {
2995
- return capsuleGetList(`/${input.entity}/${input.entityId}/parties`, {
2996
- embed: input.embed,
2997
- page: input.page,
2998
- perPage: input.perPage
2999
- });
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
+ }
3069
+ );
3000
3070
  }
3001
3071
  var addAdditionalPartySchema = z20.object({
3002
3072
  entity: RelationshipEntity,
@@ -3007,7 +3077,9 @@ var addAdditionalPartySchema = z20.object({
3007
3077
  });
3008
3078
  async function addAdditionalParty(input) {
3009
3079
  try {
3010
- await capsulePostNoContent(`/${input.entity}/${input.entityId}/parties/${input.partyId}`);
3080
+ await capsulePostNoContent(
3081
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/parties/${input.partyId}`
3082
+ );
3011
3083
  return {
3012
3084
  linked: true,
3013
3085
  alreadyLinked: false,
@@ -3044,7 +3116,7 @@ async function removeAdditionalParty(input) {
3044
3116
  throw new Error("remove_additional_party requires confirm: true");
3045
3117
  }
3046
3118
  return idempotent(
3047
- () => capsuleDelete(`/${input.entity}/${input.entityId}/parties/${input.partyId}`),
3119
+ () => capsuleDelete(`/${ENTITY_PATH[input.entity]}/${input.entityId}/parties/${input.partyId}`),
3048
3120
  () => ({
3049
3121
  removed: true,
3050
3122
  alreadyRemoved: false,
@@ -3063,7 +3135,7 @@ async function removeAdditionalParty(input) {
3063
3135
  }
3064
3136
  var listAssociatedProjectsSchema = z20.object({
3065
3137
  opportunityId: positiveId,
3066
- embed: z20.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
3138
+ embed: embedParam(RECORD_EMBEDS),
3067
3139
  ...paginationFields
3068
3140
  });
3069
3141
  async function listAssociatedProjects(input) {
@@ -3076,49 +3148,49 @@ async function listAssociatedProjects(input) {
3076
3148
 
3077
3149
  // src/tools/custom-fields.ts
3078
3150
  import { z as z21 } from "zod";
3079
- var CustomFieldEntity = z21.enum(["parties", "opportunities", "kases"]).describe("Which entity type's custom field schema to inspect. Use 'kases' for projects.");
3151
+ var CustomFieldEntity = z21.enum(["parties", "opportunities", "projects"]).describe("Which entity type's custom field schema to inspect.");
3080
3152
  var listCustomFieldsSchema = z21.object({
3081
3153
  entity: CustomFieldEntity
3082
3154
  });
3083
3155
  async function listCustomFields(input) {
3084
3156
  const { data } = await capsuleGetCached(
3085
- `/${input.entity}/fields/definitions`
3157
+ `/${ENTITY_PATH[input.entity]}/fields/definitions`
3086
3158
  );
3087
3159
  return data;
3088
3160
  }
3089
3161
  var getCustomFieldSchema = z21.object({
3090
3162
  entity: CustomFieldEntity,
3091
- fieldId: positiveId.describe("Custom field definition id.")
3163
+ id: positiveId.describe("Custom field definition id.")
3092
3164
  });
3093
3165
  async function getCustomField(input) {
3094
3166
  const { data } = await capsuleGetCached(
3095
- `/${input.entity}/fields/definitions/${input.fieldId}`
3167
+ `/${ENTITY_PATH[input.entity]}/fields/definitions/${input.id}`
3096
3168
  );
3097
3169
  return data;
3098
3170
  }
3099
3171
 
3100
3172
  // src/tools/tracks.ts
3101
3173
  import { z as z22 } from "zod";
3102
- var TrackEntity = z22.enum(["parties", "opportunities", "kases"]).describe("Use 'kases' for projects.");
3174
+ var TrackEntity = z22.enum(["parties", "opportunities", "projects"]).describe("Which entity type.");
3103
3175
  var listEntityTracksSchema = z22.object({
3104
3176
  entity: TrackEntity,
3105
3177
  entityId: positiveId
3106
3178
  });
3107
3179
  async function listEntityTracks(input) {
3108
3180
  const { data } = await capsuleGet(
3109
- `/${input.entity}/${input.entityId}/tracks`
3181
+ `/${ENTITY_PATH[input.entity]}/${input.entityId}/tracks`
3110
3182
  );
3111
3183
  return data;
3112
3184
  }
3113
- var showTrackSchema = z22.object({
3114
- trackId: positiveId
3185
+ var getTrackSchema = z22.object({
3186
+ id: positiveId
3115
3187
  });
3116
- async function showTrack(input) {
3117
- const { data } = await capsuleGet(`/tracks/${input.trackId}`);
3188
+ async function getTrack(input) {
3189
+ const { data } = await capsuleGet(`/tracks/${input.id}`);
3118
3190
  return data;
3119
3191
  }
3120
3192
  var applyTrackSchema = z22.object({
3121
- entity: z22.enum(["opportunities", "kases"]).describe("Which entity to apply the track to. Use 'kases' for projects."),
3193
+ entity: z22.enum(["opportunities", "projects"]).describe("Which entity to apply the track to."),
3122
3194
  entityId: positiveId,
3123
3195
  trackDefinitionId: positiveId.describe(
3124
3196
  "The trackDefinition to apply (from list_track_definitions). Auto-creates task definitions on the target entity per the track's rules."
@@ -3137,7 +3209,7 @@ async function applyTrack(input) {
3137
3209
  return capsulePost("/tracks", { track });
3138
3210
  }
3139
3211
  var updateTrackSchema = z22.object({
3140
- trackId: positiveId,
3212
+ id: positiveId,
3141
3213
  fields: z22.record(z22.string(), z22.unknown()).describe(
3142
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."
3143
3215
  )
@@ -3146,12 +3218,12 @@ async function updateTrack(input) {
3146
3218
  if (Object.keys(input.fields).length === 0) {
3147
3219
  throw new Error("update_track: provide at least one field in `fields`");
3148
3220
  }
3149
- return capsulePut(`/tracks/${input.trackId}`, {
3221
+ return capsulePut(`/tracks/${input.id}`, {
3150
3222
  track: input.fields
3151
3223
  });
3152
3224
  }
3153
3225
  var removeTrackSchema = z22.object({
3154
- trackId: positiveId,
3226
+ id: positiveId,
3155
3227
  confirm: confirmFlag().describe(
3156
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."
3157
3229
  )
@@ -3161,9 +3233,9 @@ async function removeTrack(input) {
3161
3233
  throw new Error("remove_track requires confirm: true");
3162
3234
  }
3163
3235
  return idempotent(
3164
- () => capsuleDelete(`/tracks/${input.trackId}`),
3165
- () => ({ removed: true, alreadyRemoved: false, trackId: input.trackId }),
3166
- () => ({ 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 })
3167
3239
  );
3168
3240
  }
3169
3241
 
@@ -3250,28 +3322,31 @@ async function uploadAttachment(input) {
3250
3322
 
3251
3323
  // src/tools/saved-filters.ts
3252
3324
  import { z as z24 } from "zod";
3253
- var EntitySchema = z24.enum(["parties", "opportunities", "kases"]).describe(
3254
- "Which entity type the filter operates over. Use 'kases' for projects (Capsule's legacy name)."
3255
- );
3325
+ var EntitySchema = z24.enum(["parties", "opportunities", "projects"]).describe("Which entity type the filter operates over.");
3256
3326
  var listSavedFiltersSchema = z24.object({
3257
3327
  entity: EntitySchema
3258
3328
  });
3259
3329
  async function listSavedFilters(input) {
3260
- const { data } = await capsuleGetCached(`/${input.entity}/filters`);
3330
+ const { data } = await capsuleGetCached(
3331
+ `/${ENTITY_PATH[input.entity]}/filters`
3332
+ );
3261
3333
  return data;
3262
3334
  }
3263
3335
  var runSavedFilterSchema = z24.object({
3264
3336
  entity: EntitySchema,
3265
3337
  id: positiveId.describe("The saved filter id (from list_saved_filters)."),
3266
- embed: z24.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
3338
+ embed: embedParam(RECORD_EMBEDS),
3267
3339
  ...paginationFields
3268
3340
  });
3269
3341
  async function runSavedFilter(input) {
3270
- return capsuleGetList(`/${input.entity}/filters/${input.id}/results`, {
3271
- page: input.page,
3272
- perPage: input.perPage,
3273
- embed: input.embed
3274
- });
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
+ }
3349
+ );
3275
3350
  }
3276
3351
 
3277
3352
  // src/server.ts
@@ -3282,7 +3357,7 @@ function createCapsuleMcpServer(opts) {
3282
3357
  const server = new McpServer(
3283
3358
  {
3284
3359
  name: "capsulemcp",
3285
- version: "1.8.1",
3360
+ version: "2.0.1",
3286
3361
  description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
3287
3362
  websiteUrl: "https://github.com/soil-dev/capsulemcp",
3288
3363
  icons: ICONS
@@ -3342,7 +3417,7 @@ function createCapsuleMcpServer(opts) {
3342
3417
  registerTool(
3343
3418
  server,
3344
3419
  "list_party_projects",
3345
- "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.",
3420
+ "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.",
3346
3421
  listPartyProjectsSchema,
3347
3422
  listPartyProjects
3348
3423
  );
@@ -3356,7 +3431,7 @@ function createCapsuleMcpServer(opts) {
3356
3431
  registerTool(
3357
3432
  server,
3358
3433
  "list_custom_fields",
3359
- "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.",
3434
+ "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.",
3360
3435
  listCustomFieldsSchema,
3361
3436
  listCustomFields
3362
3437
  );
@@ -3399,7 +3474,7 @@ function createCapsuleMcpServer(opts) {
3399
3474
  registerTool(
3400
3475
  server,
3401
3476
  "delete_party",
3402
- "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).",
3403
3478
  deletePartySchema,
3404
3479
  deleteParty
3405
3480
  );
@@ -3498,14 +3573,14 @@ function createCapsuleMcpServer(opts) {
3498
3573
  registerTool(
3499
3574
  server,
3500
3575
  "list_additional_parties",
3501
- "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'.",
3502
3577
  listAdditionalPartiesSchema,
3503
3578
  listAdditionalParties
3504
3579
  );
3505
3580
  registerTool(
3506
3581
  server,
3507
3582
  "list_associated_projects",
3508
- "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.",
3583
+ "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.",
3509
3584
  listAssociatedProjectsSchema,
3510
3585
  listAssociatedProjects
3511
3586
  );
@@ -3539,38 +3614,45 @@ function createCapsuleMcpServer(opts) {
3539
3614
  deleteOpportunity
3540
3615
  );
3541
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
+ );
3542
3624
  registerTool(
3543
3625
  server,
3544
3626
  "list_projects",
3545
- "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 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.",
3546
3628
  listProjectsSchema,
3547
3629
  listProjects
3548
3630
  );
3549
3631
  registerTool(
3550
3632
  server,
3551
3633
  "filter_projects",
3552
- "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.",
3634
+ "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.",
3553
3635
  filterProjectsSchema,
3554
3636
  filterProjects
3555
3637
  );
3556
3638
  registerTool(
3557
3639
  server,
3558
3640
  "get_project",
3559
- "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.",
3641
+ "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.",
3560
3642
  getProjectSchema,
3561
3643
  getProject
3562
3644
  );
3563
3645
  registerTool(
3564
3646
  server,
3565
3647
  "get_projects",
3566
- "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.",
3648
+ "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.",
3567
3649
  getProjectsSchema,
3568
3650
  getProjects
3569
3651
  );
3570
3652
  registerTool(
3571
3653
  server,
3572
3654
  "list_deleted_projects",
3573
- "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.",
3574
3656
  listDeletedProjectsSchema,
3575
3657
  listDeletedProjects
3576
3658
  );
@@ -3578,7 +3660,7 @@ function createCapsuleMcpServer(opts) {
3578
3660
  registerTool(
3579
3661
  server,
3580
3662
  "create_project",
3581
- "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.",
3663
+ "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.",
3582
3664
  createProjectSchema,
3583
3665
  createProject
3584
3666
  );
@@ -3599,7 +3681,7 @@ function createCapsuleMcpServer(opts) {
3599
3681
  registerTool(
3600
3682
  server,
3601
3683
  "delete_project",
3602
- "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.",
3684
+ "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.",
3603
3685
  deleteProjectSchema,
3604
3686
  deleteProject
3605
3687
  );
@@ -3714,7 +3796,7 @@ function createCapsuleMcpServer(opts) {
3714
3796
  registerTool(
3715
3797
  server,
3716
3798
  "list_project_entries",
3717
- "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.",
3799
+ "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.",
3718
3800
  listProjectEntriesSchema,
3719
3801
  listProjectEntries
3720
3802
  );
@@ -3865,14 +3947,14 @@ function createCapsuleMcpServer(opts) {
3865
3947
  registerTool(
3866
3948
  server,
3867
3949
  "list_boards",
3868
- "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.",
3950
+ "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.",
3869
3951
  listBoardsSchema,
3870
3952
  listBoards
3871
3953
  );
3872
3954
  registerTool(
3873
3955
  server,
3874
3956
  "list_stages",
3875
- "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.",
3957
+ "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.",
3876
3958
  listStagesSchema,
3877
3959
  listStages
3878
3960
  );
@@ -3885,14 +3967,14 @@ function createCapsuleMcpServer(opts) {
3885
3967
  );
3886
3968
  registerTool(
3887
3969
  server,
3888
- "list_lostreasons",
3970
+ "list_lost_reasons",
3889
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.",
3890
3972
  listLostReasonsSchema,
3891
3973
  listLostReasons
3892
3974
  );
3893
3975
  registerTool(
3894
3976
  server,
3895
- "list_activitytypes",
3977
+ "list_activity_types",
3896
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.",
3897
3979
  listActivityTypesSchema,
3898
3980
  listActivityTypes
@@ -3920,10 +4002,10 @@ function createCapsuleMcpServer(opts) {
3920
4002
  );
3921
4003
  registerTool(
3922
4004
  server,
3923
- "show_track",
4005
+ "get_track",
3924
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.",
3925
- showTrackSchema,
3926
- showTrack
4007
+ getTrackSchema,
4008
+ getTrack
3927
4009
  );
3928
4010
  registerTool(
3929
4011
  server,
@@ -3956,7 +4038,7 @@ function createCapsuleMcpServer(opts) {
3956
4038
  registerTool(
3957
4039
  server,
3958
4040
  "list_tags",
3959
- "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?'",
3960
4042
  listTagsSchema,
3961
4043
  listTags
3962
4044
  );
@@ -3964,21 +4046,21 @@ function createCapsuleMcpServer(opts) {
3964
4046
  registerTool(
3965
4047
  server,
3966
4048
  "add_tag",
3967
- "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').",
4049
+ "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').",
3968
4050
  addTagSchema,
3969
4051
  addTag
3970
4052
  );
3971
4053
  registerTool(
3972
4054
  server,
3973
4055
  "remove_tag_by_id",
3974
- "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).",
4056
+ "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).",
3975
4057
  removeTagByIdSchema,
3976
4058
  removeTagById
3977
4059
  );
3978
4060
  registerTool(
3979
4061
  server,
3980
4062
  "delete_tag_definition",
3981
- "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).",
3982
4064
  deleteTagDefinitionSchema,
3983
4065
  deleteTagDefinition
3984
4066
  );