capsulemcp 1.6.3 → 1.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  A [Model Context Protocol](https://modelcontextprotocol.io) server for [Capsule CRM](https://capsulecrm.com). Connect Claude (Desktop, Code, or web Projects via Custom Connector) to your CRM and let it answer natural-language questions across the full record graph: contacts, organisations, opportunities, projects, tasks, and timeline activity. Beyond the basics it covers structured filters with field/operator conditions, saved searches with sort, workflow tracks (templates and instances), file attachments (read + write), audit of deleted records, and batch fetches up to 50 records per call.
6
6
 
7
- - **86 tools** across the Capsule resource graph (49 in read-only mode) — full read coverage plus careful, confirm-gated writes; 5 batched-write tools (`batch_*`) for mass-update workflows
7
+ - **87 tools** across the Capsule resource graph (49 in read-only mode) — full read coverage plus careful, confirm-gated writes; 6 batched-write tools (`batch_*`) for mass-update workflows
8
8
  - **Two transports**: stdio for local installs (Claude Desktop / Code), HTTP+OAuth for hosted Custom Connectors
9
9
  - **Read-only mode** as a one-env-var flag; works alongside read-scoped Capsule tokens
10
10
  - **MCP tool annotations**: 49 read tools carry `readOnlyHint: true`, 7 destructive ones carry `destructiveHint: true` — clients that honor these hints can auto-approve safe reads while still prompting for writes/destructive calls
@@ -48,7 +48,7 @@ For most individual users the install is a single JSON snippet pasted into Claud
48
48
 
49
49
  3. Restart Claude Desktop. The Capsule tools appear in the tool picker.
50
50
 
51
- That's it. The first launch fetches the package from npm (a few seconds); subsequent launches are instant from the npx cache. To pin a specific version, use `"capsulemcp@1.6.3"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.6.3"` — same arguments, just installs from a git clone rather than the npm registry. See [INSTALL.md](INSTALL.md) for the Claude Code path, manual install, and troubleshooting.
51
+ That's it. The first launch fetches the package from npm (a few seconds); subsequent launches are instant from the npx cache. To pin a specific version, use `"capsulemcp@1.6.5"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.6.5"` — same arguments, just installs from a git clone rather than the npm registry. See [INSTALL.md](INSTALL.md) for the Claude Code path, manual install, and troubleshooting.
52
52
 
53
53
  ## Tools
54
54
 
package/dist/http.js CHANGED
@@ -1555,6 +1555,16 @@ function defineDelete(args) {
1555
1555
  return { schema, handler };
1556
1556
  }
1557
1557
 
1558
+ // src/tools/preserve-refs.ts
1559
+ async function readEntityRefs(path, responseKey) {
1560
+ const { data } = await capsuleGet(path);
1561
+ const entity = data[responseKey];
1562
+ return {
1563
+ teamId: entity?.team?.id ?? void 0,
1564
+ stageId: entity?.stage?.id ?? void 0
1565
+ };
1566
+ }
1567
+
1558
1568
  // src/tools/custom-field-helpers.ts
1559
1569
  import { z as z6 } from "zod";
1560
1570
  var CustomFieldWriteSchema = z6.object({
@@ -1730,8 +1740,11 @@ var PartyWriteBaseSchema = {
1730
1740
  websites: z7.array(WebsiteSchema).optional().describe(
1731
1741
  "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."
1732
1742
  ),
1733
- ownerId: positiveId.optional().describe(
1734
- "Assign to user ID. On create_party, defaults to the API-token owner when omitted. Once set, this connector cannot clear the owner back to null \u2014 use Capsule's web UI for that. Discover IDs via list_users."
1743
+ ownerId: positiveId.nullable().optional().describe(
1744
+ "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."
1745
+ ),
1746
+ teamId: positiveId.nullable().optional().describe(
1747
+ "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)."
1735
1748
  )
1736
1749
  };
1737
1750
  var createPartySchema = z7.object({
@@ -1744,13 +1757,25 @@ var createPartySchema = z7.object({
1744
1757
  organisationId: positiveId.optional().describe("Link person to an existing organisation ID"),
1745
1758
  // organisation
1746
1759
  name: z7.string().optional(),
1747
- ...PartyWriteBaseSchema
1760
+ ...PartyWriteBaseSchema,
1761
+ ownerId: positiveId.optional().describe(
1762
+ "Assign to user ID. Defaults to the API-token owner when omitted. To create a team-owned party with no specific user, first create the party, then call update_party with `ownerId: null` and `teamId`."
1763
+ ),
1764
+ teamId: positiveId.optional().describe(
1765
+ "Assign to team ID (discover via list_teams). Omit to leave team unset on create. To clear an existing team or create a team-owned party with no specific owner, use update_party after creation."
1766
+ ),
1767
+ fields: z7.array(CustomFieldWriteSchema).optional().describe(
1768
+ 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."
1769
+ )
1748
1770
  });
1749
1771
  async function createParty(input) {
1750
- const { ownerId, organisationId, ...rest } = input;
1772
+ const { ownerId, teamId, organisationId, fields, ...rest } = input;
1751
1773
  const body = { ...rest };
1752
1774
  setRef(body, "owner", ownerId);
1775
+ setRef(body, "team", teamId);
1753
1776
  setRef(body, "organisation", organisationId);
1777
+ const mappedFields = mapFieldsForBody(fields);
1778
+ if (mappedFields !== void 0) body["fields"] = mappedFields;
1754
1779
  return capsulePost("/parties", { party: body });
1755
1780
  }
1756
1781
  var updatePartySchema = z7.object({
@@ -1767,12 +1792,17 @@ var updatePartySchema = z7.object({
1767
1792
  ...PartyWriteBaseSchema
1768
1793
  });
1769
1794
  async function updateParty(input) {
1770
- const { id, ownerId, organisationId, fields, ...rest } = input;
1795
+ const { id, ownerId, teamId, organisationId, fields, ...rest } = input;
1771
1796
  const body = {};
1772
1797
  for (const [k, v] of Object.entries(rest)) {
1773
1798
  if (v !== void 0) body[k] = v;
1774
1799
  }
1775
- setRef(body, "owner", ownerId);
1800
+ let resolvedTeamId = teamId;
1801
+ if (ownerId !== void 0 && teamId === void 0) {
1802
+ ({ teamId: resolvedTeamId } = await readEntityRefs(`/parties/${id}`, "party"));
1803
+ }
1804
+ setNullableRef(body, "owner", ownerId);
1805
+ setNullableRef(body, "team", resolvedTeamId);
1776
1806
  setNullableRef(body, "organisation", organisationId);
1777
1807
  const mappedFields = mapFieldsForBody(fields);
1778
1808
  if (mappedFields !== void 0) body["fields"] = mappedFields;
@@ -1940,18 +1970,6 @@ async function removePartyWebsiteById(input) {
1940
1970
 
1941
1971
  // src/tools/opportunities.ts
1942
1972
  import { z as z8 } from "zod";
1943
-
1944
- // src/tools/preserve-refs.ts
1945
- async function readEntityRefs(path, responseKey) {
1946
- const { data } = await capsuleGet(path);
1947
- const entity = data[responseKey];
1948
- return {
1949
- teamId: entity?.team?.id ?? void 0,
1950
- stageId: entity?.stage?.id ?? void 0
1951
- };
1952
- }
1953
-
1954
- // src/tools/opportunities.ts
1955
1973
  var OpportunityValueSchema = z8.object({
1956
1974
  amount: z8.number().nonnegative(),
1957
1975
  currency: z8.string({
@@ -2022,14 +2040,17 @@ var createOpportunitySchema = z8.object({
2022
2040
  expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2023
2041
  probability: z8.number().int().min(0).max(100).optional(),
2024
2042
  ownerId: positiveId.optional().describe(
2025
- "Assign to user ID. Defaults to the API-token owner when omitted \u2014 note that opportunities do NOT inherit owner from the linked party, even though one might expect it. Once set, this connector cannot clear the owner back to null (use Capsule's web UI). Discover IDs via list_users. WARNING: tenant pipeline / milestone-reached automation can mutate this field post-create \u2014 see the `milestoneId` description for details and the chained-PUT workaround."
2043
+ "Assign to user ID. Defaults to the API-token owner when omitted \u2014 note that opportunities do NOT inherit owner from the linked party, even though one might expect it. To clear owner later, call update_opportunity with `ownerId: null`. Discover IDs via list_users. WARNING: tenant pipeline / milestone-reached automation can mutate this field post-create \u2014 see the `milestoneId` description for details and the chained-PUT workaround."
2026
2044
  ),
2027
2045
  teamId: positiveId.optional().describe(
2028
2046
  "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)."
2047
+ ),
2048
+ fields: z8.array(CustomFieldWriteSchema).optional().describe(
2049
+ 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."
2029
2050
  )
2030
2051
  });
2031
2052
  async function createOpportunity(input) {
2032
- const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
2053
+ const { partyId, milestoneId, ownerId, teamId, fields, ...rest } = input;
2033
2054
  const body = {
2034
2055
  ...rest,
2035
2056
  party: { id: partyId },
@@ -2037,13 +2058,15 @@ async function createOpportunity(input) {
2037
2058
  };
2038
2059
  setRef(body, "owner", ownerId);
2039
2060
  setRef(body, "team", teamId);
2061
+ const mappedFields = mapFieldsForBody(fields);
2062
+ if (mappedFields !== void 0) body["fields"] = mappedFields;
2040
2063
  return capsulePost("/opportunities", { opportunity: body });
2041
2064
  }
2042
2065
  var updateOpportunitySchema = z8.object({
2043
2066
  id: positiveId,
2044
2067
  name: z8.string().min(1).optional(),
2045
2068
  partyId: positiveId.optional().describe(
2046
- "Reassign the opportunity to a different primary party. Capsule requires every opportunity to have a party \u2014 passing `null` is rejected with 422 'party is required' (use Capsule's web UI if you need to dissolve the link entirely). Discover ids via search_parties / filter_parties. No defensive read-modify-write needed: this connector verified empirically (v1.6.3 wire-trace) that `party` is a standalone PUT field on /opportunities and does not interact with the asymmetric owner/team semantic from NOTES-ON-CAPSULE-API.md \xA727."
2069
+ "Reassign the opportunity to a different primary party. Capsule requires every opportunity to have a party \u2014 passing `null` is rejected with 422 'party is required' (use Capsule's web UI if you need to dissolve the link entirely). Discover ids via search_parties / filter_parties. No defensive read-modify-write needed: this connector verified empirically (v1.6.3 wire-trace) that `party` is a standalone PUT field on /opportunities and does not interact with the asymmetric owner/team semantic from NOTES-ON-CAPSULE-API.md \xA727. NOTE: parent-ref nullability differs by entity \u2014 `update_task.partyId` IS nullable (orphan task), but opportunities and projects must always have a parent party. The same applies to `update_project.partyId`."
2047
2070
  ),
2048
2071
  milestoneId: positiveId.optional().describe(
2049
2072
  "Move the opportunity to this milestone. Side effects depend on the target: closing milestones (Won/Lost) auto-set `closedOn` to today and `probability` to the milestone default (100/0), preserving `lastOpenMilestone` as the previous open stage; moving back to an open milestone clears `closedOn` and re-applies the milestone's default probability (Won/Lost is reversible \u2014 no separate reopen tool). WARNING: Capsule does NOT validate that the new milestone belongs to the opportunity's current pipeline. Passing a milestoneId from a different pipeline silently relocates the opportunity across pipelines, and `lastOpenMilestone` may then reference a milestone in the previous pipeline. Verify against the opportunity's current pipeline (read the opp first, list its pipeline's milestones via list_milestones) before passing a cross-pipeline id. NOTE: changing `milestoneId` can fire **pipeline / milestone-reached automations** that mutate `owner` / `team` on the destination milestone (same shape as `create_opportunity` \u2014 see its `milestoneId` description for the owner-clearing automation caveat). If a milestone-change-and-owner-set in the same call lands with `owner: null`, follow up with a second `update_opportunity` (or `batch_update_opportunity`) carrying both `ownerId` and `teamId` \u2014 milestone-reached triggers only fire on the transition, so a subsequent PUT preserves your values."
@@ -2057,8 +2080,8 @@ var updateOpportunitySchema = z8.object({
2057
2080
  lostReasonId: positiveId.optional().describe(
2058
2081
  "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."
2059
2082
  ),
2060
- ownerId: positiveId.optional().describe(
2061
- "Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that. When you supply `ownerId` and omit `teamId`, the connector fetches the opportunity's current team and includes it in the PUT body to preserve it across the owner change. Without this defensive read, Capsule's PUT would clear the existing team (see NOTES-ON-CAPSULE-API.md \xA727 \u2014 same asymmetric semantic as /kases). Supply `teamId` explicitly on the same call to change the team instead."
2083
+ ownerId: positiveId.nullable().optional().describe(
2084
+ "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)."
2062
2085
  ),
2063
2086
  teamId: positiveId.nullable().optional().describe(
2064
2087
  "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."
@@ -2077,7 +2100,7 @@ async function updateOpportunity(input) {
2077
2100
  if (ownerId !== void 0 && teamId === void 0) {
2078
2101
  ({ teamId: resolvedTeamId } = await readEntityRefs(`/opportunities/${id}`, "opportunity"));
2079
2102
  }
2080
- setRef(body, "owner", ownerId);
2103
+ setNullableRef(body, "owner", ownerId);
2081
2104
  setNullableRef(body, "team", resolvedTeamId);
2082
2105
  setRef(body, "lostReason", lostReasonId);
2083
2106
  const mappedFields = mapFieldsForBody(fields);
@@ -2161,10 +2184,13 @@ var createProjectSchema = z9.object({
2161
2184
  stageId: positiveId.optional().describe(
2162
2185
  "Stage (board column) to place the project on. Discover IDs via list_stages \u2014 each stage belongs to one Board, so picking a stageId implicitly picks the board. If omitted, the project is created with no stage assignment (and won't appear on any board). NOTE: tenant-specific board automation rules may run on project creation and mutate `owner` / `team` fields. See `create_project.ownerId` / `create_project.teamId` for the automation caveat. Capsule's create endpoint itself preserves the `ownerId` / `teamId` you supply \u2014 any clearing you observe traces to board automations, not the API."
2163
2186
  ),
2164
- expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD")
2187
+ expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2188
+ fields: z9.array(CustomFieldWriteSchema).optional().describe(
2189
+ 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."
2190
+ )
2165
2191
  });
2166
2192
  async function createProject(input) {
2167
- const { partyId, ownerId, teamId, status, stageId, ...rest } = input;
2193
+ const { partyId, ownerId, teamId, status, stageId, fields, ...rest } = input;
2168
2194
  const body = {
2169
2195
  ...rest,
2170
2196
  status: status ?? "OPEN",
@@ -2173,6 +2199,8 @@ async function createProject(input) {
2173
2199
  setRef(body, "owner", ownerId);
2174
2200
  setRef(body, "team", teamId);
2175
2201
  if (stageId) body["stage"] = stageId;
2202
+ const mappedFields = mapFieldsForBody(fields);
2203
+ if (mappedFields !== void 0) body["fields"] = mappedFields;
2176
2204
  return capsulePost("/kases", { kase: body });
2177
2205
  }
2178
2206
  var updateProjectSchema = z9.object({
@@ -2181,7 +2209,7 @@ var updateProjectSchema = z9.object({
2181
2209
  description: z9.string().optional(),
2182
2210
  status: z9.enum(["OPEN", "CLOSED"]).optional(),
2183
2211
  partyId: positiveId.optional().describe(
2184
- "Reassign the project to a different primary party. Capsule requires every project to have a party \u2014 passing `null` is rejected with 422 'party is required' (verified empirically in v1.6.3 wire-trace). Discover ids via search_parties / filter_parties."
2212
+ "Reassign the project to a different primary party. Capsule requires every project to have a party \u2014 passing `null` is rejected with 422 'party is required' (verified empirically in v1.6.3 wire-trace). Discover ids via search_parties / filter_parties. NOTE: parent-ref nullability differs by entity \u2014 `update_task.partyId` IS nullable (orphan task), but opportunities and projects must always have a parent party. The same applies to `update_opportunity.partyId`."
2185
2213
  ),
2186
2214
  ownerId: positiveId.nullable().optional().describe(
2187
2215
  "Reassign owner: pass a user ID to set, or `null` to unassign (matches the 'Unassign' option in Capsule's web UI). When you supply `ownerId` and omit `teamId` and/or `stageId`, the connector fetches the project's current omitted fields and includes them in the PUT body \u2014 this preserves them across the owner change (without it, Capsule's PUT would clear team; stage carry is defensive against the symmetric clear). Supply `teamId` and/or `stageId` explicitly on the same call to change them instead. `teamId: null` clears the team as part of an owner change. Constraints (Capsule enforces, 422 on violation): owner must be a member of the team if both are set; a project must always have at least one of {owner, team} set (cannot clear both)."
@@ -2189,8 +2217,8 @@ var updateProjectSchema = z9.object({
2189
2217
  teamId: positiveId.nullable().optional().describe(
2190
2218
  "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'."
2191
2219
  ),
2192
- stageId: positiveId.optional().describe(
2193
- "Move the project to this stage (board column). 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."
2220
+ stageId: positiveId.nullable().optional().describe(
2221
+ "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."
2194
2222
  ),
2195
2223
  expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
2196
2224
  fields: z9.array(CustomFieldWriteSchema).optional().describe(
@@ -2213,11 +2241,18 @@ async function updateProject(input) {
2213
2241
  }
2214
2242
  setNullableRef(body, "owner", ownerId);
2215
2243
  setNullableRef(body, "team", resolvedTeamId);
2216
- if (resolvedStageId) body["stage"] = resolvedStageId;
2244
+ if (resolvedStageId === null) body["stage"] = null;
2245
+ else if (resolvedStageId !== void 0) body["stage"] = resolvedStageId;
2217
2246
  const mappedFields = mapFieldsForBody(fields);
2218
2247
  if (mappedFields !== void 0) body["fields"] = mappedFields;
2219
2248
  return capsulePut(`/kases/${id}`, { kase: body });
2220
2249
  }
2250
+ var { schema: batchUpdateProjectSchema, handler: batchUpdateProject } = defineBatch({
2251
+ toolName: "batch_update_project",
2252
+ itemSchema: updateProjectSchema,
2253
+ itemDescription: "Array of 1\u201350 update_project inputs. Each item is the same shape as a single update_project call \u2014 id is required, every other field is optional. Capped at 50 so a single tool call can't burn an outsized share of Capsule's hourly per-token rate budget (~4000 req/h). Mirrors batch_update_party and batch_update_opportunity \u2014 same shape across the three entity types.",
2254
+ itemHandler: updateProject
2255
+ });
2221
2256
  var { schema: deleteProjectSchema, handler: deleteProject } = defineDelete({
2222
2257
  toolName: "delete_project",
2223
2258
  pathPrefix: "/kases",
@@ -2317,7 +2352,7 @@ var updateTaskSchema = z10.object({
2317
2352
  "Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
2318
2353
  ),
2319
2354
  partyId: positiveId.nullable().optional().describe(
2320
- "Re-link the task to a party by id, or `null` to orphan it. Mutually exclusive with `opportunityId` / `projectId` \u2014 Capsule enforces 'task can be related to at most one entity' server-side (422 if two parent-refs are set at once, verified in v1.6.3 wire-trace). To swap parent type atomically, pass the old one as `null` and the new one as an id in the same call."
2355
+ "Re-link the task to a party by id, or `null` to orphan it. Mutually exclusive with `opportunityId` / `projectId` \u2014 Capsule enforces 'task can be related to at most one entity' server-side (422 if two parent-refs are set at once, verified in v1.6.3 wire-trace). To swap parent type atomically, pass the old one as `null` and the new one as an id in the same call. NOTE: orphaning is unique to tasks \u2014 `update_opportunity.partyId` and `update_project.partyId` are NOT nullable (Capsule rejects with 422 'party is required'). Tasks are the only entity in Capsule's data model that can exist without any parent."
2321
2356
  ),
2322
2357
  opportunityId: positiveId.nullable().optional().describe(
2323
2358
  "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."
@@ -3110,7 +3145,7 @@ function createCapsuleMcpServer(opts) {
3110
3145
  const server = new McpServer(
3111
3146
  {
3112
3147
  name: "capsulemcp",
3113
- version: "1.6.3",
3148
+ version: "1.6.5",
3114
3149
  description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
3115
3150
  websiteUrl: "https://github.com/soil-dev/capsulemcp",
3116
3151
  icons: ICONS
@@ -3206,14 +3241,14 @@ function createCapsuleMcpServer(opts) {
3206
3241
  registerTool(
3207
3242
  server,
3208
3243
  "create_party",
3209
- "Create a new person or organisation in Capsule CRM. For type='person', firstName or lastName is required (one suffices); the `name` field is silently ignored. For type='organisation', `name` is required and firstName/lastName/title/jobTitle are silently ignored. Passing organisationId pointing at a non-organisation party (e.g. another person's id) returns 404 'organisation not found' \u2014 Capsule filters lookups by type.",
3244
+ "Create a new person or organisation in Capsule CRM. For type='person', firstName or lastName is required (one suffices); the `name` field is silently ignored. For type='organisation', `name` is required and firstName/lastName/title/jobTitle are silently ignored. Passing organisationId pointing at a non-organisation party (e.g. another person's id) returns 404 'organisation not found' \u2014 Capsule filters lookups by type. Accepts `ownerId` and `teamId` to set ownership at create time; both are optional and Capsule defaults `owner` to the API-token user when omitted (team has no default).",
3210
3245
  createPartySchema,
3211
3246
  createParty
3212
3247
  );
3213
3248
  registerTool(
3214
3249
  server,
3215
3250
  "update_party",
3216
- "Update top-level fields on an existing party (about, firstName/lastName/name/title/jobTitle, ownerId, organisationId). For PERSON parties, organisationId links to an organisation or null unlinks the person from its organisation; for ORGANISATION parties Capsule silently ignores organisationId. Only the fields you provide are changed. Child arrays (emailAddresses / phoneNumbers / addresses / websites) on this tool are APPEND-ONLY: items are merged into the existing list, not replaced. For surgical changes \u2014 replacing one email, removing one phone number, fixing the type on one address \u2014 use the dedicated atomic tools: add_party_email_address / remove_party_email_address_by_id (and the phone/address/website equivalents).",
3251
+ "Update top-level fields on an existing party (about, firstName/lastName/name/title/jobTitle, ownerId, teamId, organisationId). `ownerId` and `teamId` both accept `null` to unassign \u2014 the combination `{ownerId: null, teamId: <id>}` puts a party into 'team-owned, no specific user' state (the common pattern when transferring ownership to a team after a user departs). For PERSON parties, organisationId links to an organisation or null unlinks; for ORGANISATION parties Capsule silently ignores organisationId. Only the fields you provide are changed. Child arrays (emailAddresses / phoneNumbers / addresses / websites) on this tool are APPEND-ONLY: items are merged into the existing list, not replaced. For surgical changes \u2014 replacing one email, removing one phone number, fixing the type on one address \u2014 use the dedicated atomic tools: add_party_email_address / remove_party_email_address_by_id (and the phone/address/website equivalents).",
3217
3252
  updatePartySchema,
3218
3253
  updateParty
3219
3254
  );
@@ -3348,7 +3383,7 @@ function createCapsuleMcpServer(opts) {
3348
3383
  registerTool(
3349
3384
  server,
3350
3385
  "update_opportunity",
3351
- "Update fields on an existing opportunity, including the parent-reference field `partyId` to reassign the opp to a different primary party. Only the fields you provide are changed. Closed (Won/Lost) opportunities ARE editable \u2014 Capsule does not enforce closed-record immutability, so `value`, `description`, etc. can be changed on a Won opp without warning. If the workflow needs historical revenue numbers to be stable, enforce that caller-side. Capsule requires every opportunity to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required'.",
3386
+ "Update fields on an existing opportunity, including the parent-reference field `partyId` to reassign the opp to a different primary party. `ownerId` and `teamId` both accept `null` to unassign (verified empirically in v1.6.5 wire-trace \u2014 brings update_opportunity into parity with update_party and update_project). The combination `{ownerId: null, teamId: <id>}` puts an opportunity into 'team-owned, no specific user' state, matching the pattern available on parties and projects. Only the fields you provide are changed. Closed (Won/Lost) opportunities ARE editable \u2014 Capsule does not enforce closed-record immutability, so `value`, `description`, etc. can be changed on a Won opp without warning. If the workflow needs historical revenue numbers to be stable, enforce that caller-side. Capsule requires every opportunity to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required' (Unlike `update_task.partyId` which IS nullable \u2014 tasks can be orphaned in Capsule's model).",
3352
3387
  updateOpportunitySchema,
3353
3388
  updateOpportunity
3354
3389
  );
@@ -3413,10 +3448,17 @@ function createCapsuleMcpServer(opts) {
3413
3448
  registerTool(
3414
3449
  server,
3415
3450
  "update_project",
3416
- "Update fields on an existing project, including the parent-reference field `partyId` to reassign the project to a different primary party. Only the fields you provide are changed. Use status='CLOSED' to close a project. CLOSED projects remain fully editable \u2014 Capsule does not enforce closed-record immutability. Stage moves and description edits on a CLOSED project are accepted without warning. Capsule requires every project to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required'.",
3451
+ "Update fields on an existing project, including the parent-reference field `partyId` to reassign the project to a different primary party. `ownerId`, `teamId`, and `stageId` all accept `null` to unassign (the latter removes the project from all stages \u2014 verified empirically in v1.6.5 wire-trace). Constraint: a project must always have at least one of {owner, team} set, so `teamId: null` on a project with no owner returns 422. Only the fields you provide are changed. Use status='CLOSED' to close a project. CLOSED projects remain fully editable \u2014 Capsule does not enforce closed-record immutability. Stage moves and description edits on a CLOSED project are accepted without warning. Capsule requires every project to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required' (Unlike `update_task.partyId` which IS nullable \u2014 tasks can be orphaned in Capsule's model).",
3417
3452
  updateProjectSchema,
3418
3453
  updateProject
3419
3454
  );
3455
+ registerBatchTool(
3456
+ server,
3457
+ "batch_update_project",
3458
+ "Update 1\u201350 projects in parallel. Same input shape as update_project but wrapped in an `items` array. Use this \u2014 not N sequential update_project calls \u2014 for mass stage transitions (e.g. move a board column of projects to a new stage), bulk owner reassignments after a personnel change, or batch closures. Mirrors batch_update_party and batch_update_opportunity \u2014 identical fan-out shape across the three entity types. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. Partial failures possible; Capsule has no rollback.",
3459
+ batchUpdateProjectSchema,
3460
+ batchUpdateProject
3461
+ );
3420
3462
  registerTool(
3421
3463
  server,
3422
3464
  "delete_project",
package/dist/index.js CHANGED
@@ -1052,6 +1052,16 @@ function defineDelete(args) {
1052
1052
  return { schema, handler };
1053
1053
  }
1054
1054
 
1055
+ // src/tools/preserve-refs.ts
1056
+ async function readEntityRefs(path, responseKey) {
1057
+ const { data } = await capsuleGet(path);
1058
+ const entity = data[responseKey];
1059
+ return {
1060
+ teamId: entity?.team?.id ?? void 0,
1061
+ stageId: entity?.stage?.id ?? void 0
1062
+ };
1063
+ }
1064
+
1055
1065
  // src/tools/custom-field-helpers.ts
1056
1066
  import { z as z5 } from "zod";
1057
1067
  var CustomFieldWriteSchema = z5.object({
@@ -1227,8 +1237,11 @@ var PartyWriteBaseSchema = {
1227
1237
  websites: z6.array(WebsiteSchema).optional().describe(
1228
1238
  "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."
1229
1239
  ),
1230
- ownerId: positiveId.optional().describe(
1231
- "Assign to user ID. On create_party, defaults to the API-token owner when omitted. Once set, this connector cannot clear the owner back to null \u2014 use Capsule's web UI for that. Discover IDs via list_users."
1240
+ ownerId: positiveId.nullable().optional().describe(
1241
+ "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."
1242
+ ),
1243
+ teamId: positiveId.nullable().optional().describe(
1244
+ "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)."
1232
1245
  )
1233
1246
  };
1234
1247
  var createPartySchema = z6.object({
@@ -1241,13 +1254,25 @@ var createPartySchema = z6.object({
1241
1254
  organisationId: positiveId.optional().describe("Link person to an existing organisation ID"),
1242
1255
  // organisation
1243
1256
  name: z6.string().optional(),
1244
- ...PartyWriteBaseSchema
1257
+ ...PartyWriteBaseSchema,
1258
+ ownerId: positiveId.optional().describe(
1259
+ "Assign to user ID. Defaults to the API-token owner when omitted. To create a team-owned party with no specific user, first create the party, then call update_party with `ownerId: null` and `teamId`."
1260
+ ),
1261
+ teamId: positiveId.optional().describe(
1262
+ "Assign to team ID (discover via list_teams). Omit to leave team unset on create. To clear an existing team or create a team-owned party with no specific owner, use update_party after creation."
1263
+ ),
1264
+ fields: z6.array(CustomFieldWriteSchema).optional().describe(
1265
+ 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."
1266
+ )
1245
1267
  });
1246
1268
  async function createParty(input) {
1247
- const { ownerId, organisationId, ...rest } = input;
1269
+ const { ownerId, teamId, organisationId, fields, ...rest } = input;
1248
1270
  const body = { ...rest };
1249
1271
  setRef(body, "owner", ownerId);
1272
+ setRef(body, "team", teamId);
1250
1273
  setRef(body, "organisation", organisationId);
1274
+ const mappedFields = mapFieldsForBody(fields);
1275
+ if (mappedFields !== void 0) body["fields"] = mappedFields;
1251
1276
  return capsulePost("/parties", { party: body });
1252
1277
  }
1253
1278
  var updatePartySchema = z6.object({
@@ -1264,12 +1289,17 @@ var updatePartySchema = z6.object({
1264
1289
  ...PartyWriteBaseSchema
1265
1290
  });
1266
1291
  async function updateParty(input) {
1267
- const { id, ownerId, organisationId, fields, ...rest } = input;
1292
+ const { id, ownerId, teamId, organisationId, fields, ...rest } = input;
1268
1293
  const body = {};
1269
1294
  for (const [k, v] of Object.entries(rest)) {
1270
1295
  if (v !== void 0) body[k] = v;
1271
1296
  }
1272
- setRef(body, "owner", ownerId);
1297
+ let resolvedTeamId = teamId;
1298
+ if (ownerId !== void 0 && teamId === void 0) {
1299
+ ({ teamId: resolvedTeamId } = await readEntityRefs(`/parties/${id}`, "party"));
1300
+ }
1301
+ setNullableRef(body, "owner", ownerId);
1302
+ setNullableRef(body, "team", resolvedTeamId);
1273
1303
  setNullableRef(body, "organisation", organisationId);
1274
1304
  const mappedFields = mapFieldsForBody(fields);
1275
1305
  if (mappedFields !== void 0) body["fields"] = mappedFields;
@@ -1437,18 +1467,6 @@ async function removePartyWebsiteById(input) {
1437
1467
 
1438
1468
  // src/tools/opportunities.ts
1439
1469
  import { z as z7 } from "zod";
1440
-
1441
- // src/tools/preserve-refs.ts
1442
- async function readEntityRefs(path, responseKey) {
1443
- const { data } = await capsuleGet(path);
1444
- const entity = data[responseKey];
1445
- return {
1446
- teamId: entity?.team?.id ?? void 0,
1447
- stageId: entity?.stage?.id ?? void 0
1448
- };
1449
- }
1450
-
1451
- // src/tools/opportunities.ts
1452
1470
  var OpportunityValueSchema = z7.object({
1453
1471
  amount: z7.number().nonnegative(),
1454
1472
  currency: z7.string({
@@ -1519,14 +1537,17 @@ var createOpportunitySchema = z7.object({
1519
1537
  expectedCloseOn: z7.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
1520
1538
  probability: z7.number().int().min(0).max(100).optional(),
1521
1539
  ownerId: positiveId.optional().describe(
1522
- "Assign to user ID. Defaults to the API-token owner when omitted \u2014 note that opportunities do NOT inherit owner from the linked party, even though one might expect it. Once set, this connector cannot clear the owner back to null (use Capsule's web UI). Discover IDs via list_users. WARNING: tenant pipeline / milestone-reached automation can mutate this field post-create \u2014 see the `milestoneId` description for details and the chained-PUT workaround."
1540
+ "Assign to user ID. Defaults to the API-token owner when omitted \u2014 note that opportunities do NOT inherit owner from the linked party, even though one might expect it. To clear owner later, call update_opportunity with `ownerId: null`. Discover IDs via list_users. WARNING: tenant pipeline / milestone-reached automation can mutate this field post-create \u2014 see the `milestoneId` description for details and the chained-PUT workaround."
1523
1541
  ),
1524
1542
  teamId: positiveId.optional().describe(
1525
1543
  "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)."
1544
+ ),
1545
+ fields: z7.array(CustomFieldWriteSchema).optional().describe(
1546
+ 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."
1526
1547
  )
1527
1548
  });
1528
1549
  async function createOpportunity(input) {
1529
- const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
1550
+ const { partyId, milestoneId, ownerId, teamId, fields, ...rest } = input;
1530
1551
  const body = {
1531
1552
  ...rest,
1532
1553
  party: { id: partyId },
@@ -1534,13 +1555,15 @@ async function createOpportunity(input) {
1534
1555
  };
1535
1556
  setRef(body, "owner", ownerId);
1536
1557
  setRef(body, "team", teamId);
1558
+ const mappedFields = mapFieldsForBody(fields);
1559
+ if (mappedFields !== void 0) body["fields"] = mappedFields;
1537
1560
  return capsulePost("/opportunities", { opportunity: body });
1538
1561
  }
1539
1562
  var updateOpportunitySchema = z7.object({
1540
1563
  id: positiveId,
1541
1564
  name: z7.string().min(1).optional(),
1542
1565
  partyId: positiveId.optional().describe(
1543
- "Reassign the opportunity to a different primary party. Capsule requires every opportunity to have a party \u2014 passing `null` is rejected with 422 'party is required' (use Capsule's web UI if you need to dissolve the link entirely). Discover ids via search_parties / filter_parties. No defensive read-modify-write needed: this connector verified empirically (v1.6.3 wire-trace) that `party` is a standalone PUT field on /opportunities and does not interact with the asymmetric owner/team semantic from NOTES-ON-CAPSULE-API.md \xA727."
1566
+ "Reassign the opportunity to a different primary party. Capsule requires every opportunity to have a party \u2014 passing `null` is rejected with 422 'party is required' (use Capsule's web UI if you need to dissolve the link entirely). Discover ids via search_parties / filter_parties. No defensive read-modify-write needed: this connector verified empirically (v1.6.3 wire-trace) that `party` is a standalone PUT field on /opportunities and does not interact with the asymmetric owner/team semantic from NOTES-ON-CAPSULE-API.md \xA727. NOTE: parent-ref nullability differs by entity \u2014 `update_task.partyId` IS nullable (orphan task), but opportunities and projects must always have a parent party. The same applies to `update_project.partyId`."
1544
1567
  ),
1545
1568
  milestoneId: positiveId.optional().describe(
1546
1569
  "Move the opportunity to this milestone. Side effects depend on the target: closing milestones (Won/Lost) auto-set `closedOn` to today and `probability` to the milestone default (100/0), preserving `lastOpenMilestone` as the previous open stage; moving back to an open milestone clears `closedOn` and re-applies the milestone's default probability (Won/Lost is reversible \u2014 no separate reopen tool). WARNING: Capsule does NOT validate that the new milestone belongs to the opportunity's current pipeline. Passing a milestoneId from a different pipeline silently relocates the opportunity across pipelines, and `lastOpenMilestone` may then reference a milestone in the previous pipeline. Verify against the opportunity's current pipeline (read the opp first, list its pipeline's milestones via list_milestones) before passing a cross-pipeline id. NOTE: changing `milestoneId` can fire **pipeline / milestone-reached automations** that mutate `owner` / `team` on the destination milestone (same shape as `create_opportunity` \u2014 see its `milestoneId` description for the owner-clearing automation caveat). If a milestone-change-and-owner-set in the same call lands with `owner: null`, follow up with a second `update_opportunity` (or `batch_update_opportunity`) carrying both `ownerId` and `teamId` \u2014 milestone-reached triggers only fire on the transition, so a subsequent PUT preserves your values."
@@ -1554,8 +1577,8 @@ var updateOpportunitySchema = z7.object({
1554
1577
  lostReasonId: positiveId.optional().describe(
1555
1578
  "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."
1556
1579
  ),
1557
- ownerId: positiveId.optional().describe(
1558
- "Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that. When you supply `ownerId` and omit `teamId`, the connector fetches the opportunity's current team and includes it in the PUT body to preserve it across the owner change. Without this defensive read, Capsule's PUT would clear the existing team (see NOTES-ON-CAPSULE-API.md \xA727 \u2014 same asymmetric semantic as /kases). Supply `teamId` explicitly on the same call to change the team instead."
1580
+ ownerId: positiveId.nullable().optional().describe(
1581
+ "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)."
1559
1582
  ),
1560
1583
  teamId: positiveId.nullable().optional().describe(
1561
1584
  "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."
@@ -1574,7 +1597,7 @@ async function updateOpportunity(input) {
1574
1597
  if (ownerId !== void 0 && teamId === void 0) {
1575
1598
  ({ teamId: resolvedTeamId } = await readEntityRefs(`/opportunities/${id}`, "opportunity"));
1576
1599
  }
1577
- setRef(body, "owner", ownerId);
1600
+ setNullableRef(body, "owner", ownerId);
1578
1601
  setNullableRef(body, "team", resolvedTeamId);
1579
1602
  setRef(body, "lostReason", lostReasonId);
1580
1603
  const mappedFields = mapFieldsForBody(fields);
@@ -1658,10 +1681,13 @@ var createProjectSchema = z8.object({
1658
1681
  stageId: positiveId.optional().describe(
1659
1682
  "Stage (board column) to place the project on. Discover IDs via list_stages \u2014 each stage belongs to one Board, so picking a stageId implicitly picks the board. If omitted, the project is created with no stage assignment (and won't appear on any board). NOTE: tenant-specific board automation rules may run on project creation and mutate `owner` / `team` fields. See `create_project.ownerId` / `create_project.teamId` for the automation caveat. Capsule's create endpoint itself preserves the `ownerId` / `teamId` you supply \u2014 any clearing you observe traces to board automations, not the API."
1660
1683
  ),
1661
- expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD")
1684
+ expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
1685
+ fields: z8.array(CustomFieldWriteSchema).optional().describe(
1686
+ 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."
1687
+ )
1662
1688
  });
1663
1689
  async function createProject(input) {
1664
- const { partyId, ownerId, teamId, status, stageId, ...rest } = input;
1690
+ const { partyId, ownerId, teamId, status, stageId, fields, ...rest } = input;
1665
1691
  const body = {
1666
1692
  ...rest,
1667
1693
  status: status ?? "OPEN",
@@ -1670,6 +1696,8 @@ async function createProject(input) {
1670
1696
  setRef(body, "owner", ownerId);
1671
1697
  setRef(body, "team", teamId);
1672
1698
  if (stageId) body["stage"] = stageId;
1699
+ const mappedFields = mapFieldsForBody(fields);
1700
+ if (mappedFields !== void 0) body["fields"] = mappedFields;
1673
1701
  return capsulePost("/kases", { kase: body });
1674
1702
  }
1675
1703
  var updateProjectSchema = z8.object({
@@ -1678,7 +1706,7 @@ var updateProjectSchema = z8.object({
1678
1706
  description: z8.string().optional(),
1679
1707
  status: z8.enum(["OPEN", "CLOSED"]).optional(),
1680
1708
  partyId: positiveId.optional().describe(
1681
- "Reassign the project to a different primary party. Capsule requires every project to have a party \u2014 passing `null` is rejected with 422 'party is required' (verified empirically in v1.6.3 wire-trace). Discover ids via search_parties / filter_parties."
1709
+ "Reassign the project to a different primary party. Capsule requires every project to have a party \u2014 passing `null` is rejected with 422 'party is required' (verified empirically in v1.6.3 wire-trace). Discover ids via search_parties / filter_parties. NOTE: parent-ref nullability differs by entity \u2014 `update_task.partyId` IS nullable (orphan task), but opportunities and projects must always have a parent party. The same applies to `update_opportunity.partyId`."
1682
1710
  ),
1683
1711
  ownerId: positiveId.nullable().optional().describe(
1684
1712
  "Reassign owner: pass a user ID to set, or `null` to unassign (matches the 'Unassign' option in Capsule's web UI). When you supply `ownerId` and omit `teamId` and/or `stageId`, the connector fetches the project's current omitted fields and includes them in the PUT body \u2014 this preserves them across the owner change (without it, Capsule's PUT would clear team; stage carry is defensive against the symmetric clear). Supply `teamId` and/or `stageId` explicitly on the same call to change them instead. `teamId: null` clears the team as part of an owner change. Constraints (Capsule enforces, 422 on violation): owner must be a member of the team if both are set; a project must always have at least one of {owner, team} set (cannot clear both)."
@@ -1686,8 +1714,8 @@ var updateProjectSchema = z8.object({
1686
1714
  teamId: positiveId.nullable().optional().describe(
1687
1715
  "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'."
1688
1716
  ),
1689
- stageId: positiveId.optional().describe(
1690
- "Move the project to this stage (board column). 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."
1717
+ stageId: positiveId.nullable().optional().describe(
1718
+ "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."
1691
1719
  ),
1692
1720
  expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
1693
1721
  fields: z8.array(CustomFieldWriteSchema).optional().describe(
@@ -1710,11 +1738,18 @@ async function updateProject(input) {
1710
1738
  }
1711
1739
  setNullableRef(body, "owner", ownerId);
1712
1740
  setNullableRef(body, "team", resolvedTeamId);
1713
- if (resolvedStageId) body["stage"] = resolvedStageId;
1741
+ if (resolvedStageId === null) body["stage"] = null;
1742
+ else if (resolvedStageId !== void 0) body["stage"] = resolvedStageId;
1714
1743
  const mappedFields = mapFieldsForBody(fields);
1715
1744
  if (mappedFields !== void 0) body["fields"] = mappedFields;
1716
1745
  return capsulePut(`/kases/${id}`, { kase: body });
1717
1746
  }
1747
+ var { schema: batchUpdateProjectSchema, handler: batchUpdateProject } = defineBatch({
1748
+ toolName: "batch_update_project",
1749
+ itemSchema: updateProjectSchema,
1750
+ itemDescription: "Array of 1\u201350 update_project inputs. Each item is the same shape as a single update_project call \u2014 id is required, every other field is optional. Capped at 50 so a single tool call can't burn an outsized share of Capsule's hourly per-token rate budget (~4000 req/h). Mirrors batch_update_party and batch_update_opportunity \u2014 same shape across the three entity types.",
1751
+ itemHandler: updateProject
1752
+ });
1718
1753
  var { schema: deleteProjectSchema, handler: deleteProject } = defineDelete({
1719
1754
  toolName: "delete_project",
1720
1755
  pathPrefix: "/kases",
@@ -1814,7 +1849,7 @@ var updateTaskSchema = z9.object({
1814
1849
  "Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
1815
1850
  ),
1816
1851
  partyId: positiveId.nullable().optional().describe(
1817
- "Re-link the task to a party by id, or `null` to orphan it. Mutually exclusive with `opportunityId` / `projectId` \u2014 Capsule enforces 'task can be related to at most one entity' server-side (422 if two parent-refs are set at once, verified in v1.6.3 wire-trace). To swap parent type atomically, pass the old one as `null` and the new one as an id in the same call."
1852
+ "Re-link the task to a party by id, or `null` to orphan it. Mutually exclusive with `opportunityId` / `projectId` \u2014 Capsule enforces 'task can be related to at most one entity' server-side (422 if two parent-refs are set at once, verified in v1.6.3 wire-trace). To swap parent type atomically, pass the old one as `null` and the new one as an id in the same call. NOTE: orphaning is unique to tasks \u2014 `update_opportunity.partyId` and `update_project.partyId` are NOT nullable (Capsule rejects with 422 'party is required'). Tasks are the only entity in Capsule's data model that can exist without any parent."
1818
1853
  ),
1819
1854
  opportunityId: positiveId.nullable().optional().describe(
1820
1855
  "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."
@@ -2607,7 +2642,7 @@ function createCapsuleMcpServer(opts) {
2607
2642
  const server2 = new McpServer(
2608
2643
  {
2609
2644
  name: "capsulemcp",
2610
- version: "1.6.3",
2645
+ version: "1.6.5",
2611
2646
  description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
2612
2647
  websiteUrl: "https://github.com/soil-dev/capsulemcp",
2613
2648
  icons: ICONS
@@ -2703,14 +2738,14 @@ function createCapsuleMcpServer(opts) {
2703
2738
  registerTool(
2704
2739
  server2,
2705
2740
  "create_party",
2706
- "Create a new person or organisation in Capsule CRM. For type='person', firstName or lastName is required (one suffices); the `name` field is silently ignored. For type='organisation', `name` is required and firstName/lastName/title/jobTitle are silently ignored. Passing organisationId pointing at a non-organisation party (e.g. another person's id) returns 404 'organisation not found' \u2014 Capsule filters lookups by type.",
2741
+ "Create a new person or organisation in Capsule CRM. For type='person', firstName or lastName is required (one suffices); the `name` field is silently ignored. For type='organisation', `name` is required and firstName/lastName/title/jobTitle are silently ignored. Passing organisationId pointing at a non-organisation party (e.g. another person's id) returns 404 'organisation not found' \u2014 Capsule filters lookups by type. Accepts `ownerId` and `teamId` to set ownership at create time; both are optional and Capsule defaults `owner` to the API-token user when omitted (team has no default).",
2707
2742
  createPartySchema,
2708
2743
  createParty
2709
2744
  );
2710
2745
  registerTool(
2711
2746
  server2,
2712
2747
  "update_party",
2713
- "Update top-level fields on an existing party (about, firstName/lastName/name/title/jobTitle, ownerId, organisationId). For PERSON parties, organisationId links to an organisation or null unlinks the person from its organisation; for ORGANISATION parties Capsule silently ignores organisationId. Only the fields you provide are changed. Child arrays (emailAddresses / phoneNumbers / addresses / websites) on this tool are APPEND-ONLY: items are merged into the existing list, not replaced. For surgical changes \u2014 replacing one email, removing one phone number, fixing the type on one address \u2014 use the dedicated atomic tools: add_party_email_address / remove_party_email_address_by_id (and the phone/address/website equivalents).",
2748
+ "Update top-level fields on an existing party (about, firstName/lastName/name/title/jobTitle, ownerId, teamId, organisationId). `ownerId` and `teamId` both accept `null` to unassign \u2014 the combination `{ownerId: null, teamId: <id>}` puts a party into 'team-owned, no specific user' state (the common pattern when transferring ownership to a team after a user departs). For PERSON parties, organisationId links to an organisation or null unlinks; for ORGANISATION parties Capsule silently ignores organisationId. Only the fields you provide are changed. Child arrays (emailAddresses / phoneNumbers / addresses / websites) on this tool are APPEND-ONLY: items are merged into the existing list, not replaced. For surgical changes \u2014 replacing one email, removing one phone number, fixing the type on one address \u2014 use the dedicated atomic tools: add_party_email_address / remove_party_email_address_by_id (and the phone/address/website equivalents).",
2714
2749
  updatePartySchema,
2715
2750
  updateParty
2716
2751
  );
@@ -2845,7 +2880,7 @@ function createCapsuleMcpServer(opts) {
2845
2880
  registerTool(
2846
2881
  server2,
2847
2882
  "update_opportunity",
2848
- "Update fields on an existing opportunity, including the parent-reference field `partyId` to reassign the opp to a different primary party. Only the fields you provide are changed. Closed (Won/Lost) opportunities ARE editable \u2014 Capsule does not enforce closed-record immutability, so `value`, `description`, etc. can be changed on a Won opp without warning. If the workflow needs historical revenue numbers to be stable, enforce that caller-side. Capsule requires every opportunity to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required'.",
2883
+ "Update fields on an existing opportunity, including the parent-reference field `partyId` to reassign the opp to a different primary party. `ownerId` and `teamId` both accept `null` to unassign (verified empirically in v1.6.5 wire-trace \u2014 brings update_opportunity into parity with update_party and update_project). The combination `{ownerId: null, teamId: <id>}` puts an opportunity into 'team-owned, no specific user' state, matching the pattern available on parties and projects. Only the fields you provide are changed. Closed (Won/Lost) opportunities ARE editable \u2014 Capsule does not enforce closed-record immutability, so `value`, `description`, etc. can be changed on a Won opp without warning. If the workflow needs historical revenue numbers to be stable, enforce that caller-side. Capsule requires every opportunity to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required' (Unlike `update_task.partyId` which IS nullable \u2014 tasks can be orphaned in Capsule's model).",
2849
2884
  updateOpportunitySchema,
2850
2885
  updateOpportunity
2851
2886
  );
@@ -2910,10 +2945,17 @@ function createCapsuleMcpServer(opts) {
2910
2945
  registerTool(
2911
2946
  server2,
2912
2947
  "update_project",
2913
- "Update fields on an existing project, including the parent-reference field `partyId` to reassign the project to a different primary party. Only the fields you provide are changed. Use status='CLOSED' to close a project. CLOSED projects remain fully editable \u2014 Capsule does not enforce closed-record immutability. Stage moves and description edits on a CLOSED project are accepted without warning. Capsule requires every project to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required'.",
2948
+ "Update fields on an existing project, including the parent-reference field `partyId` to reassign the project to a different primary party. `ownerId`, `teamId`, and `stageId` all accept `null` to unassign (the latter removes the project from all stages \u2014 verified empirically in v1.6.5 wire-trace). Constraint: a project must always have at least one of {owner, team} set, so `teamId: null` on a project with no owner returns 422. Only the fields you provide are changed. Use status='CLOSED' to close a project. CLOSED projects remain fully editable \u2014 Capsule does not enforce closed-record immutability. Stage moves and description edits on a CLOSED project are accepted without warning. Capsule requires every project to have a party \u2014 passing `partyId: null` is rejected with 422 'party is required' (Unlike `update_task.partyId` which IS nullable \u2014 tasks can be orphaned in Capsule's model).",
2914
2949
  updateProjectSchema,
2915
2950
  updateProject
2916
2951
  );
2952
+ registerBatchTool(
2953
+ server2,
2954
+ "batch_update_project",
2955
+ "Update 1\u201350 projects in parallel. Same input shape as update_project but wrapped in an `items` array. Use this \u2014 not N sequential update_project calls \u2014 for mass stage transitions (e.g. move a board column of projects to a new stage), bulk owner reassignments after a personnel change, or batch closures. Mirrors batch_update_party and batch_update_opportunity \u2014 identical fan-out shape across the three entity types. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. Partial failures possible; Capsule has no rollback.",
2956
+ batchUpdateProjectSchema,
2957
+ batchUpdateProject
2958
+ );
2917
2959
  registerTool(
2918
2960
  server2,
2919
2961
  "delete_project",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capsulemcp",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
4
4
  "description": "Model Context Protocol server for Capsule CRM. Lets Claude (Desktop, Code, or web Projects via Custom Connector) read and write your CRM in plain English. Covers contacts, opportunities, projects, tasks, timeline activity, structured filters, saved filters with sort, workflow tracks, file attachments, audit, and batch fetches.",
5
5
  "keywords": [
6
6
  "mcp",