capsulemcp 1.6.3 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/http.js +261 -103
- package/dist/index.js +233 -103
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
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
|
-
- **
|
|
7
|
+
- **88 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
|
-
- **MCP tool annotations**: 49 read tools carry `readOnlyHint: true`,
|
|
10
|
+
- **MCP tool annotations**: 49 read tools carry `readOnlyHint: true`, 8 destructive ones carry `destructiveHint: true` — clients that honor these hints can auto-approve safe reads while still prompting for writes/destructive calls
|
|
11
11
|
- **Apache 2.0**
|
|
12
12
|
|
|
13
13
|
## Pick your install
|
|
@@ -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.
|
|
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.7.0"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.7.0"` — 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
|
|
|
@@ -67,7 +67,7 @@ That's it. The first launch fetches the package from npm (a few seconds); subseq
|
|
|
67
67
|
| Tracks (workflow instances) | `list_track_definitions`, `list_entity_tracks`, `show_track` | `apply_track`, `update_track`, `remove_track` |
|
|
68
68
|
| Saved filters | `list_saved_filters`, `run_saved_filter` | — |
|
|
69
69
|
| Custom fields (schema) | `list_custom_fields`, `get_custom_field` | — |
|
|
70
|
-
| Tags | `list_tags` | `add_tag`, `remove_tag_by_id` |
|
|
70
|
+
| Tags | `list_tags` | `add_tag`, `remove_tag_by_id`, `delete_tag_definition` |
|
|
71
71
|
| Users & teams | `list_users`, `get_current_user`, `list_teams` | — |
|
|
72
72
|
| Reference metadata | `list_lostreasons`, `list_activitytypes`, `list_categories`, `list_goals`, `get_site` | — |
|
|
73
73
|
|
package/dist/http.js
CHANGED
|
@@ -1256,12 +1256,12 @@ function isDestructive(name) {
|
|
|
1256
1256
|
}
|
|
1257
1257
|
function inferAnnotations(name) {
|
|
1258
1258
|
if (READ_PREFIXES.some((p) => name.startsWith(p))) {
|
|
1259
|
-
return { readOnlyHint: true };
|
|
1259
|
+
return { readOnlyHint: true, destructiveHint: false };
|
|
1260
1260
|
}
|
|
1261
1261
|
if (isDestructive(name)) {
|
|
1262
|
-
return { destructiveHint: true };
|
|
1262
|
+
return { readOnlyHint: false, destructiveHint: true };
|
|
1263
1263
|
}
|
|
1264
|
-
return
|
|
1264
|
+
return { readOnlyHint: false, destructiveHint: false };
|
|
1265
1265
|
}
|
|
1266
1266
|
function argFieldNames(input) {
|
|
1267
1267
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
|
|
@@ -1555,6 +1555,35 @@ 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
|
+
|
|
1568
|
+
// src/capsule/multi-get.ts
|
|
1569
|
+
var MULTI_GET_MAX_IDS = 10;
|
|
1570
|
+
async function chunkedMultiGet(base, responseKey, ids, params) {
|
|
1571
|
+
if (ids.length <= MULTI_GET_MAX_IDS) {
|
|
1572
|
+
const { data } = await capsuleGet(
|
|
1573
|
+
`${base}/${ids.join(",")}`,
|
|
1574
|
+
params
|
|
1575
|
+
);
|
|
1576
|
+
return data;
|
|
1577
|
+
}
|
|
1578
|
+
const chunks = chunk(ids, MULTI_GET_MAX_IDS);
|
|
1579
|
+
const responses = await Promise.all(
|
|
1580
|
+
chunks.map(
|
|
1581
|
+
(chunkIds) => capsuleGet(`${base}/${chunkIds.join(",")}`, params)
|
|
1582
|
+
)
|
|
1583
|
+
);
|
|
1584
|
+
return { [responseKey]: responses.flatMap((r) => r.data[responseKey] ?? []) };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1558
1587
|
// src/tools/custom-field-helpers.ts
|
|
1559
1588
|
import { z as z6 } from "zod";
|
|
1560
1589
|
var CustomFieldWriteSchema = z6.object({
|
|
@@ -1677,20 +1706,7 @@ var getPartiesSchema = z7.object({
|
|
|
1677
1706
|
embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1678
1707
|
});
|
|
1679
1708
|
async function getParties(input) {
|
|
1680
|
-
|
|
1681
|
-
if (ids.length <= 10) {
|
|
1682
|
-
const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
|
|
1683
|
-
embed
|
|
1684
|
-
});
|
|
1685
|
-
return data;
|
|
1686
|
-
}
|
|
1687
|
-
const chunks = chunk(ids, 10);
|
|
1688
|
-
const responses = await Promise.all(
|
|
1689
|
-
chunks.map(
|
|
1690
|
-
(chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
|
|
1691
|
-
)
|
|
1692
|
-
);
|
|
1693
|
-
return { parties: responses.flatMap((r) => r.data.parties) };
|
|
1709
|
+
return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
|
|
1694
1710
|
}
|
|
1695
1711
|
var listPartyOpportunitiesSchema = z7.object({
|
|
1696
1712
|
partyId: positiveId,
|
|
@@ -1730,8 +1746,11 @@ var PartyWriteBaseSchema = {
|
|
|
1730
1746
|
websites: z7.array(WebsiteSchema).optional().describe(
|
|
1731
1747
|
"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
1748
|
),
|
|
1733
|
-
ownerId: positiveId.optional().describe(
|
|
1734
|
-
"
|
|
1749
|
+
ownerId: positiveId.nullable().optional().describe(
|
|
1750
|
+
"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."
|
|
1751
|
+
),
|
|
1752
|
+
teamId: positiveId.nullable().optional().describe(
|
|
1753
|
+
"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
1754
|
)
|
|
1736
1755
|
};
|
|
1737
1756
|
var createPartySchema = z7.object({
|
|
@@ -1744,13 +1763,25 @@ var createPartySchema = z7.object({
|
|
|
1744
1763
|
organisationId: positiveId.optional().describe("Link person to an existing organisation ID"),
|
|
1745
1764
|
// organisation
|
|
1746
1765
|
name: z7.string().optional(),
|
|
1747
|
-
...PartyWriteBaseSchema
|
|
1766
|
+
...PartyWriteBaseSchema,
|
|
1767
|
+
ownerId: positiveId.optional().describe(
|
|
1768
|
+
"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`."
|
|
1769
|
+
),
|
|
1770
|
+
teamId: positiveId.optional().describe(
|
|
1771
|
+
"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."
|
|
1772
|
+
),
|
|
1773
|
+
fields: z7.array(CustomFieldWriteSchema).optional().describe(
|
|
1774
|
+
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."
|
|
1775
|
+
)
|
|
1748
1776
|
});
|
|
1749
1777
|
async function createParty(input) {
|
|
1750
|
-
const { ownerId, organisationId, ...rest } = input;
|
|
1778
|
+
const { ownerId, teamId, organisationId, fields, ...rest } = input;
|
|
1751
1779
|
const body = { ...rest };
|
|
1752
1780
|
setRef(body, "owner", ownerId);
|
|
1781
|
+
setRef(body, "team", teamId);
|
|
1753
1782
|
setRef(body, "organisation", organisationId);
|
|
1783
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
1784
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
1754
1785
|
return capsulePost("/parties", { party: body });
|
|
1755
1786
|
}
|
|
1756
1787
|
var updatePartySchema = z7.object({
|
|
@@ -1767,12 +1798,17 @@ var updatePartySchema = z7.object({
|
|
|
1767
1798
|
...PartyWriteBaseSchema
|
|
1768
1799
|
});
|
|
1769
1800
|
async function updateParty(input) {
|
|
1770
|
-
const { id, ownerId, organisationId, fields, ...rest } = input;
|
|
1801
|
+
const { id, ownerId, teamId, organisationId, fields, ...rest } = input;
|
|
1771
1802
|
const body = {};
|
|
1772
1803
|
for (const [k, v] of Object.entries(rest)) {
|
|
1773
1804
|
if (v !== void 0) body[k] = v;
|
|
1774
1805
|
}
|
|
1775
|
-
|
|
1806
|
+
let resolvedTeamId = teamId;
|
|
1807
|
+
if (ownerId !== void 0 && teamId === void 0) {
|
|
1808
|
+
({ teamId: resolvedTeamId } = await readEntityRefs(`/parties/${id}`, "party"));
|
|
1809
|
+
}
|
|
1810
|
+
setNullableRef(body, "owner", ownerId);
|
|
1811
|
+
setNullableRef(body, "team", resolvedTeamId);
|
|
1776
1812
|
setNullableRef(body, "organisation", organisationId);
|
|
1777
1813
|
const mappedFields = mapFieldsForBody(fields);
|
|
1778
1814
|
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
@@ -1940,18 +1976,6 @@ async function removePartyWebsiteById(input) {
|
|
|
1940
1976
|
|
|
1941
1977
|
// src/tools/opportunities.ts
|
|
1942
1978
|
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
1979
|
var OpportunityValueSchema = z8.object({
|
|
1956
1980
|
amount: z8.number().nonnegative(),
|
|
1957
1981
|
currency: z8.string({
|
|
@@ -1993,23 +2017,7 @@ var getOpportunitiesSchema = z8.object({
|
|
|
1993
2017
|
embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1994
2018
|
});
|
|
1995
2019
|
async function getOpportunities(input) {
|
|
1996
|
-
|
|
1997
|
-
if (ids.length <= 10) {
|
|
1998
|
-
const { data } = await capsuleGet(
|
|
1999
|
-
`/opportunities/${ids.join(",")}`,
|
|
2000
|
-
{ embed }
|
|
2001
|
-
);
|
|
2002
|
-
return data;
|
|
2003
|
-
}
|
|
2004
|
-
const chunks = chunk(ids, 10);
|
|
2005
|
-
const responses = await Promise.all(
|
|
2006
|
-
chunks.map(
|
|
2007
|
-
(chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
|
|
2008
|
-
embed
|
|
2009
|
-
})
|
|
2010
|
-
)
|
|
2011
|
-
);
|
|
2012
|
-
return { opportunities: responses.flatMap((r) => r.data.opportunities) };
|
|
2020
|
+
return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
|
|
2013
2021
|
}
|
|
2014
2022
|
var createOpportunitySchema = z8.object({
|
|
2015
2023
|
name: z8.string().min(1),
|
|
@@ -2022,14 +2030,17 @@ var createOpportunitySchema = z8.object({
|
|
|
2022
2030
|
expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
2023
2031
|
probability: z8.number().int().min(0).max(100).optional(),
|
|
2024
2032
|
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.
|
|
2033
|
+
"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
2034
|
),
|
|
2027
2035
|
teamId: positiveId.optional().describe(
|
|
2028
2036
|
"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)."
|
|
2037
|
+
),
|
|
2038
|
+
fields: z8.array(CustomFieldWriteSchema).optional().describe(
|
|
2039
|
+
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
2040
|
)
|
|
2030
2041
|
});
|
|
2031
2042
|
async function createOpportunity(input) {
|
|
2032
|
-
const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
|
|
2043
|
+
const { partyId, milestoneId, ownerId, teamId, fields, ...rest } = input;
|
|
2033
2044
|
const body = {
|
|
2034
2045
|
...rest,
|
|
2035
2046
|
party: { id: partyId },
|
|
@@ -2037,13 +2048,15 @@ async function createOpportunity(input) {
|
|
|
2037
2048
|
};
|
|
2038
2049
|
setRef(body, "owner", ownerId);
|
|
2039
2050
|
setRef(body, "team", teamId);
|
|
2051
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
2052
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
2040
2053
|
return capsulePost("/opportunities", { opportunity: body });
|
|
2041
2054
|
}
|
|
2042
2055
|
var updateOpportunitySchema = z8.object({
|
|
2043
2056
|
id: positiveId,
|
|
2044
2057
|
name: z8.string().min(1).optional(),
|
|
2045
2058
|
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."
|
|
2059
|
+
"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
2060
|
),
|
|
2048
2061
|
milestoneId: positiveId.optional().describe(
|
|
2049
2062
|
"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 +2070,8 @@ var updateOpportunitySchema = z8.object({
|
|
|
2057
2070
|
lostReasonId: positiveId.optional().describe(
|
|
2058
2071
|
"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
2072
|
),
|
|
2060
|
-
ownerId: positiveId.optional().describe(
|
|
2061
|
-
"Reassign owner
|
|
2073
|
+
ownerId: positiveId.nullable().optional().describe(
|
|
2074
|
+
"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
2075
|
),
|
|
2063
2076
|
teamId: positiveId.nullable().optional().describe(
|
|
2064
2077
|
"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 +2090,7 @@ async function updateOpportunity(input) {
|
|
|
2077
2090
|
if (ownerId !== void 0 && teamId === void 0) {
|
|
2078
2091
|
({ teamId: resolvedTeamId } = await readEntityRefs(`/opportunities/${id}`, "opportunity"));
|
|
2079
2092
|
}
|
|
2080
|
-
|
|
2093
|
+
setNullableRef(body, "owner", ownerId);
|
|
2081
2094
|
setNullableRef(body, "team", resolvedTeamId);
|
|
2082
2095
|
setRef(body, "lostReason", lostReasonId);
|
|
2083
2096
|
const mappedFields = mapFieldsForBody(fields);
|
|
@@ -2132,20 +2145,7 @@ var getProjectsSchema = z9.object({
|
|
|
2132
2145
|
embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
2133
2146
|
});
|
|
2134
2147
|
async function getProjects(input) {
|
|
2135
|
-
|
|
2136
|
-
if (ids.length <= 10) {
|
|
2137
|
-
const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
|
|
2138
|
-
embed
|
|
2139
|
-
});
|
|
2140
|
-
return data;
|
|
2141
|
-
}
|
|
2142
|
-
const chunks = chunk(ids, 10);
|
|
2143
|
-
const responses = await Promise.all(
|
|
2144
|
-
chunks.map(
|
|
2145
|
-
(chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
|
|
2146
|
-
)
|
|
2147
|
-
);
|
|
2148
|
-
return { kases: responses.flatMap((r) => r.data.kases) };
|
|
2148
|
+
return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
|
|
2149
2149
|
}
|
|
2150
2150
|
var createProjectSchema = z9.object({
|
|
2151
2151
|
name: z9.string().min(1),
|
|
@@ -2161,10 +2161,13 @@ var createProjectSchema = z9.object({
|
|
|
2161
2161
|
stageId: positiveId.optional().describe(
|
|
2162
2162
|
"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
2163
|
),
|
|
2164
|
-
expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD")
|
|
2164
|
+
expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
2165
|
+
fields: z9.array(CustomFieldWriteSchema).optional().describe(
|
|
2166
|
+
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."
|
|
2167
|
+
)
|
|
2165
2168
|
});
|
|
2166
2169
|
async function createProject(input) {
|
|
2167
|
-
const { partyId, ownerId, teamId, status, stageId, ...rest } = input;
|
|
2170
|
+
const { partyId, ownerId, teamId, status, stageId, fields, ...rest } = input;
|
|
2168
2171
|
const body = {
|
|
2169
2172
|
...rest,
|
|
2170
2173
|
status: status ?? "OPEN",
|
|
@@ -2173,6 +2176,8 @@ async function createProject(input) {
|
|
|
2173
2176
|
setRef(body, "owner", ownerId);
|
|
2174
2177
|
setRef(body, "team", teamId);
|
|
2175
2178
|
if (stageId) body["stage"] = stageId;
|
|
2179
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
2180
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
2176
2181
|
return capsulePost("/kases", { kase: body });
|
|
2177
2182
|
}
|
|
2178
2183
|
var updateProjectSchema = z9.object({
|
|
@@ -2181,7 +2186,7 @@ var updateProjectSchema = z9.object({
|
|
|
2181
2186
|
description: z9.string().optional(),
|
|
2182
2187
|
status: z9.enum(["OPEN", "CLOSED"]).optional(),
|
|
2183
2188
|
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."
|
|
2189
|
+
"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
2190
|
),
|
|
2186
2191
|
ownerId: positiveId.nullable().optional().describe(
|
|
2187
2192
|
"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 +2194,8 @@ var updateProjectSchema = z9.object({
|
|
|
2189
2194
|
teamId: positiveId.nullable().optional().describe(
|
|
2190
2195
|
"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
2196
|
),
|
|
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."
|
|
2197
|
+
stageId: positiveId.nullable().optional().describe(
|
|
2198
|
+
"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
2199
|
),
|
|
2195
2200
|
expectedCloseOn: z9.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
2196
2201
|
fields: z9.array(CustomFieldWriteSchema).optional().describe(
|
|
@@ -2213,11 +2218,18 @@ async function updateProject(input) {
|
|
|
2213
2218
|
}
|
|
2214
2219
|
setNullableRef(body, "owner", ownerId);
|
|
2215
2220
|
setNullableRef(body, "team", resolvedTeamId);
|
|
2216
|
-
if (resolvedStageId) body["stage"] =
|
|
2221
|
+
if (resolvedStageId === null) body["stage"] = null;
|
|
2222
|
+
else if (resolvedStageId !== void 0) body["stage"] = resolvedStageId;
|
|
2217
2223
|
const mappedFields = mapFieldsForBody(fields);
|
|
2218
2224
|
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
2219
2225
|
return capsulePut(`/kases/${id}`, { kase: body });
|
|
2220
2226
|
}
|
|
2227
|
+
var { schema: batchUpdateProjectSchema, handler: batchUpdateProject } = defineBatch({
|
|
2228
|
+
toolName: "batch_update_project",
|
|
2229
|
+
itemSchema: updateProjectSchema,
|
|
2230
|
+
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.",
|
|
2231
|
+
itemHandler: updateProject
|
|
2232
|
+
});
|
|
2221
2233
|
var { schema: deleteProjectSchema, handler: deleteProject } = defineDelete({
|
|
2222
2234
|
toolName: "delete_project",
|
|
2223
2235
|
pathPrefix: "/kases",
|
|
@@ -2265,16 +2277,7 @@ var getTasksSchema = z10.object({
|
|
|
2265
2277
|
)
|
|
2266
2278
|
});
|
|
2267
2279
|
async function getTasks(input) {
|
|
2268
|
-
|
|
2269
|
-
if (ids.length <= 10) {
|
|
2270
|
-
const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
|
|
2271
|
-
return data;
|
|
2272
|
-
}
|
|
2273
|
-
const chunks = chunk(ids, 10);
|
|
2274
|
-
const responses = await Promise.all(
|
|
2275
|
-
chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
|
|
2276
|
-
);
|
|
2277
|
-
return { tasks: responses.flatMap((r) => r.data.tasks) };
|
|
2280
|
+
return chunkedMultiGet("/tasks", "tasks", input.ids);
|
|
2278
2281
|
}
|
|
2279
2282
|
var createTaskSchema = z10.object({
|
|
2280
2283
|
description: z10.string().min(1),
|
|
@@ -2317,7 +2320,7 @@ var updateTaskSchema = z10.object({
|
|
|
2317
2320
|
"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
2321
|
),
|
|
2319
2322
|
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."
|
|
2323
|
+
"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
2324
|
),
|
|
2322
2325
|
opportunityId: positiveId.nullable().optional().describe(
|
|
2323
2326
|
"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."
|
|
@@ -2375,14 +2378,102 @@ var listEntriesPagination = {
|
|
|
2375
2378
|
};
|
|
2376
2379
|
var listPartyEntriesSchema = z11.object({
|
|
2377
2380
|
partyId: positiveId,
|
|
2378
|
-
...listEntriesPagination
|
|
2381
|
+
...listEntriesPagination,
|
|
2382
|
+
includeLinkedPersons: z11.boolean().optional().describe(
|
|
2383
|
+
"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."
|
|
2384
|
+
)
|
|
2379
2385
|
});
|
|
2386
|
+
async function fanOutPartyEntries(partyIds, embed, perPage) {
|
|
2387
|
+
const concurrency = getBatchConcurrency();
|
|
2388
|
+
const results = new Array(partyIds.length);
|
|
2389
|
+
let cursor = 0;
|
|
2390
|
+
async function worker() {
|
|
2391
|
+
while (true) {
|
|
2392
|
+
const i = cursor;
|
|
2393
|
+
cursor += 1;
|
|
2394
|
+
if (i >= partyIds.length) return;
|
|
2395
|
+
const id = partyIds[i];
|
|
2396
|
+
const { data, nextPage } = await capsuleGet(
|
|
2397
|
+
`/parties/${id}/entries`,
|
|
2398
|
+
{
|
|
2399
|
+
embed,
|
|
2400
|
+
page: 1,
|
|
2401
|
+
perPage
|
|
2402
|
+
}
|
|
2403
|
+
);
|
|
2404
|
+
results[i] = { entries: data.entries, nextPage };
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
const workers = [];
|
|
2408
|
+
for (let w = 0; w < Math.min(concurrency, partyIds.length); w++) {
|
|
2409
|
+
workers.push(worker());
|
|
2410
|
+
}
|
|
2411
|
+
await Promise.all(workers);
|
|
2412
|
+
return results;
|
|
2413
|
+
}
|
|
2414
|
+
function mergedTimelineCandidatePerParty(page, perPage) {
|
|
2415
|
+
return Math.min(page * perPage, 100);
|
|
2416
|
+
}
|
|
2417
|
+
function mergedTimelineNextPage(page, perPage, mergedLength, upstreamHasNextPage) {
|
|
2418
|
+
const requestedWindowEnd = page * perPage;
|
|
2419
|
+
if (mergedLength > requestedWindowEnd) return page + 1;
|
|
2420
|
+
const nextWindowWithinCap = requestedWindowEnd < 100;
|
|
2421
|
+
if (nextWindowWithinCap && upstreamHasNextPage) return page + 1;
|
|
2422
|
+
return void 0;
|
|
2423
|
+
}
|
|
2380
2424
|
async function listPartyEntries(input) {
|
|
2381
|
-
const {
|
|
2382
|
-
|
|
2383
|
-
{
|
|
2425
|
+
const { partyId, embed, page, perPage, includeLinkedPersons } = input;
|
|
2426
|
+
if (!includeLinkedPersons) {
|
|
2427
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
2428
|
+
`/parties/${partyId}/entries`,
|
|
2429
|
+
{ embed, page, perPage }
|
|
2430
|
+
);
|
|
2431
|
+
return { ...data, nextPage: nextPage2 };
|
|
2432
|
+
}
|
|
2433
|
+
const { data: peopleData } = await capsuleGet(
|
|
2434
|
+
`/parties/${partyId}/people`,
|
|
2435
|
+
{ page: 1, perPage: 100 }
|
|
2384
2436
|
);
|
|
2385
|
-
|
|
2437
|
+
const peopleIds = (peopleData.parties ?? []).map((p) => p.id);
|
|
2438
|
+
if (peopleIds.length === 0) {
|
|
2439
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
2440
|
+
`/parties/${partyId}/entries`,
|
|
2441
|
+
{ embed, page, perPage }
|
|
2442
|
+
);
|
|
2443
|
+
return { ...data, nextPage: nextPage2 };
|
|
2444
|
+
}
|
|
2445
|
+
const targetIds = [partyId, ...peopleIds];
|
|
2446
|
+
const perPartyPages = await fanOutPartyEntries(
|
|
2447
|
+
targetIds,
|
|
2448
|
+
embed,
|
|
2449
|
+
mergedTimelineCandidatePerParty(page, perPage)
|
|
2450
|
+
);
|
|
2451
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2452
|
+
const merged = [];
|
|
2453
|
+
for (const { entries } of perPartyPages) {
|
|
2454
|
+
for (const raw of entries) {
|
|
2455
|
+
const e = raw;
|
|
2456
|
+
if (typeof e?.id !== "number") continue;
|
|
2457
|
+
if (seen.has(e.id)) continue;
|
|
2458
|
+
seen.add(e.id);
|
|
2459
|
+
merged.push(e);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
merged.sort((a, b) => {
|
|
2463
|
+
const ax = a.entryAt ?? "";
|
|
2464
|
+
const bx = b.entryAt ?? "";
|
|
2465
|
+
if (ax !== bx) return bx.localeCompare(ax);
|
|
2466
|
+
return b.id - a.id;
|
|
2467
|
+
});
|
|
2468
|
+
const start = (page - 1) * perPage;
|
|
2469
|
+
const slice = merged.slice(start, start + perPage);
|
|
2470
|
+
const nextPage = mergedTimelineNextPage(
|
|
2471
|
+
page,
|
|
2472
|
+
perPage,
|
|
2473
|
+
merged.length,
|
|
2474
|
+
perPartyPages.some((p) => p.nextPage !== void 0)
|
|
2475
|
+
);
|
|
2476
|
+
return { entries: slice, ...nextPage !== void 0 ? { nextPage } : {} };
|
|
2386
2477
|
}
|
|
2387
2478
|
var listOpportunityEntriesSchema = z11.object({
|
|
2388
2479
|
opportunityId: positiveId,
|
|
@@ -2605,6 +2696,28 @@ async function removeTagById(input) {
|
|
|
2605
2696
|
invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
|
|
2606
2697
|
return result;
|
|
2607
2698
|
}
|
|
2699
|
+
var deleteTagDefinitionSchema = z14.object({
|
|
2700
|
+
entity: TagEntity,
|
|
2701
|
+
tagId: positiveId.describe(
|
|
2702
|
+
"The tag definition's id (from list_tags, or embed='tags' on a record). NOT an entity id."
|
|
2703
|
+
),
|
|
2704
|
+
confirm: confirmFlag().describe(
|
|
2705
|
+
"Must be set to true. DESTRUCTIVE & tenant-wide: permanently deletes the tag DEFINITION from this entity type's tag namespace, removing it from EVERY record that shares it \u2014 not just one. To detach a tag from a single record while keeping the definition, use remove_tag_by_id instead. Irreversible (the definition is gone; re-creating by name via add_tag mints a new id). Idempotent on retry."
|
|
2706
|
+
)
|
|
2707
|
+
});
|
|
2708
|
+
async function deleteTagDefinition(input) {
|
|
2709
|
+
const { entity, tagId, confirm } = input;
|
|
2710
|
+
if (confirm !== true) {
|
|
2711
|
+
throw new Error("delete_tag_definition requires confirm: true");
|
|
2712
|
+
}
|
|
2713
|
+
const result = await idempotent(
|
|
2714
|
+
() => capsuleDelete(`/${entity}/tags/${tagId}`),
|
|
2715
|
+
() => ({ deleted: true, alreadyDeleted: false, entity, tagId }),
|
|
2716
|
+
() => ({ deleted: true, alreadyDeleted: true, entity, tagId })
|
|
2717
|
+
);
|
|
2718
|
+
invalidateByPrefix(TAG_LIST_PATH[entity], "delete_tag_definition");
|
|
2719
|
+
return result;
|
|
2720
|
+
}
|
|
2608
2721
|
var { schema: batchAddTagSchema, handler: batchAddTag } = defineBatch({
|
|
2609
2722
|
toolName: "batch_add_tag",
|
|
2610
2723
|
itemSchema: addTagSchema,
|
|
@@ -3110,7 +3223,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3110
3223
|
const server = new McpServer(
|
|
3111
3224
|
{
|
|
3112
3225
|
name: "capsulemcp",
|
|
3113
|
-
version: "1.
|
|
3226
|
+
version: "1.7.0",
|
|
3114
3227
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
3115
3228
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
3116
3229
|
icons: ICONS
|
|
@@ -3206,14 +3319,14 @@ function createCapsuleMcpServer(opts) {
|
|
|
3206
3319
|
registerTool(
|
|
3207
3320
|
server,
|
|
3208
3321
|
"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.",
|
|
3322
|
+
"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
3323
|
createPartySchema,
|
|
3211
3324
|
createParty
|
|
3212
3325
|
);
|
|
3213
3326
|
registerTool(
|
|
3214
3327
|
server,
|
|
3215
3328
|
"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
|
|
3329
|
+
"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
3330
|
updatePartySchema,
|
|
3218
3331
|
updateParty
|
|
3219
3332
|
);
|
|
@@ -3348,7 +3461,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3348
3461
|
registerTool(
|
|
3349
3462
|
server,
|
|
3350
3463
|
"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'.",
|
|
3464
|
+
"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
3465
|
updateOpportunitySchema,
|
|
3353
3466
|
updateOpportunity
|
|
3354
3467
|
);
|
|
@@ -3413,10 +3526,17 @@ function createCapsuleMcpServer(opts) {
|
|
|
3413
3526
|
registerTool(
|
|
3414
3527
|
server,
|
|
3415
3528
|
"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'.",
|
|
3529
|
+
"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
3530
|
updateProjectSchema,
|
|
3418
3531
|
updateProject
|
|
3419
3532
|
);
|
|
3533
|
+
registerBatchTool(
|
|
3534
|
+
server,
|
|
3535
|
+
"batch_update_project",
|
|
3536
|
+
"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.",
|
|
3537
|
+
batchUpdateProjectSchema,
|
|
3538
|
+
batchUpdateProject
|
|
3539
|
+
);
|
|
3420
3540
|
registerTool(
|
|
3421
3541
|
server,
|
|
3422
3542
|
"delete_project",
|
|
@@ -3521,7 +3641,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3521
3641
|
registerTool(
|
|
3522
3642
|
server,
|
|
3523
3643
|
"list_party_entries",
|
|
3524
|
-
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively.",
|
|
3644
|
+
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively. IMPORTANT for organisations: pass `includeLinkedPersons: true` to surface entries filed against the org's linked people (sales-conversation emails almost always land on a person row, not the org row \u2014 Capsule's API files each entry against exactly one party). Without this flag, an org with active customer-facing email will appear quiet here even though its `lastContactedAt` is current. For any 'what's new with $ORG?' query, set `includeLinkedPersons: true`.",
|
|
3525
3645
|
listPartyEntriesSchema,
|
|
3526
3646
|
listPartyEntries
|
|
3527
3647
|
);
|
|
@@ -3558,9 +3678,12 @@ function createCapsuleMcpServer(opts) {
|
|
|
3558
3678
|
"Download an attachment by id. Returns image content for image/* types (Claude can describe it natively); decoded text for text/* and application/json (small files); JSON metadata + base64 payload for other binary types (PDF, Office docs, etc.). Files exceeding maxSizeBytes (default 5MB) return metadata only with a `truncated: true` flag.",
|
|
3559
3679
|
getAttachmentSchema.shape,
|
|
3560
3680
|
// get_attachment is read-only — downloads a binary, never mutates.
|
|
3561
|
-
// Mirrors the auto-inferred `readOnlyHint: true
|
|
3562
|
-
// `registerTool` applies to every other `get_*` tool.
|
|
3563
|
-
|
|
3681
|
+
// Mirrors the auto-inferred `{readOnlyHint: true, destructiveHint:
|
|
3682
|
+
// false}` that `registerTool` applies to every other `get_*` tool.
|
|
3683
|
+
// Explicit destructiveHint: false is load-bearing — MCP spec
|
|
3684
|
+
// defaults destructiveHint to `true`, so omitting it would (in
|
|
3685
|
+
// some client implementations) classify this read as destructive.
|
|
3686
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
3564
3687
|
async (input) => {
|
|
3565
3688
|
const result = await getAttachment(input);
|
|
3566
3689
|
if (result.truncated) {
|
|
@@ -3791,6 +3914,13 @@ function createCapsuleMcpServer(opts) {
|
|
|
3791
3914
|
removeTagByIdSchema,
|
|
3792
3915
|
removeTagById
|
|
3793
3916
|
);
|
|
3917
|
+
registerTool(
|
|
3918
|
+
server,
|
|
3919
|
+
"delete_tag_definition",
|
|
3920
|
+
"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).",
|
|
3921
|
+
deleteTagDefinitionSchema,
|
|
3922
|
+
deleteTagDefinition
|
|
3923
|
+
);
|
|
3794
3924
|
registerBatchTool(
|
|
3795
3925
|
server,
|
|
3796
3926
|
"batch_add_tag",
|
|
@@ -3908,6 +4038,34 @@ function createApp(opts) {
|
|
|
3908
4038
|
};
|
|
3909
4039
|
app2.get("/icon.svg", iconHandler);
|
|
3910
4040
|
app2.get("/favicon.ico", iconHandler);
|
|
4041
|
+
const LANDING_HTML = `<!doctype html>
|
|
4042
|
+
<html lang="en">
|
|
4043
|
+
<head>
|
|
4044
|
+
<meta charset="utf-8">
|
|
4045
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4046
|
+
<title>capsulemcp</title>
|
|
4047
|
+
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
|
4048
|
+
<link rel="apple-touch-icon" href="/icon.svg">
|
|
4049
|
+
<meta name="description" content="Model Context Protocol server for Capsule CRM. MCP endpoint: /mcp">
|
|
4050
|
+
<style>
|
|
4051
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:42em;margin:3em auto;padding:0 1em;color:#222;line-height:1.5}
|
|
4052
|
+
h1{font-size:1.6em;margin-bottom:0.2em}
|
|
4053
|
+
code{background:#f3f3f3;padding:0.1em 0.35em;border-radius:3px;font-size:0.95em}
|
|
4054
|
+
a{color:#1e3a8a}
|
|
4055
|
+
.muted{color:#666;font-size:0.92em}
|
|
4056
|
+
</style>
|
|
4057
|
+
</head>
|
|
4058
|
+
<body>
|
|
4059
|
+
<h1>capsulemcp</h1>
|
|
4060
|
+
<p>This is the HTTP+OAuth deployment of <a href="https://github.com/soil-dev/capsulemcp">capsulemcp</a>, a Model Context Protocol (MCP) server for Capsule CRM.</p>
|
|
4061
|
+
<p>The MCP endpoint is at <code>/mcp</code>. Use Claude.ai's Custom Connector flow (or any MCP-compatible client) to connect — this URL is not navigable by hand.</p>
|
|
4062
|
+
<p class="muted">Source: <a href="https://github.com/soil-dev/capsulemcp">github.com/soil-dev/capsulemcp</a> · License: Apache-2.0</p>
|
|
4063
|
+
</body>
|
|
4064
|
+
</html>
|
|
4065
|
+
`;
|
|
4066
|
+
app2.get("/", (_req, res) => {
|
|
4067
|
+
res.set("Content-Type", "text/html; charset=utf-8").set("Cache-Control", "public, max-age=3600").send(LANDING_HTML);
|
|
4068
|
+
});
|
|
3911
4069
|
const guardOrigin = (req, res, next) => {
|
|
3912
4070
|
const origin = req.get("Origin");
|
|
3913
4071
|
if (!origin) {
|
package/dist/index.js
CHANGED
|
@@ -753,12 +753,12 @@ function isDestructive(name) {
|
|
|
753
753
|
}
|
|
754
754
|
function inferAnnotations(name) {
|
|
755
755
|
if (READ_PREFIXES.some((p) => name.startsWith(p))) {
|
|
756
|
-
return { readOnlyHint: true };
|
|
756
|
+
return { readOnlyHint: true, destructiveHint: false };
|
|
757
757
|
}
|
|
758
758
|
if (isDestructive(name)) {
|
|
759
|
-
return { destructiveHint: true };
|
|
759
|
+
return { readOnlyHint: false, destructiveHint: true };
|
|
760
760
|
}
|
|
761
|
-
return
|
|
761
|
+
return { readOnlyHint: false, destructiveHint: false };
|
|
762
762
|
}
|
|
763
763
|
function argFieldNames(input) {
|
|
764
764
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
|
|
@@ -1052,6 +1052,35 @@ 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
|
+
|
|
1065
|
+
// src/capsule/multi-get.ts
|
|
1066
|
+
var MULTI_GET_MAX_IDS = 10;
|
|
1067
|
+
async function chunkedMultiGet(base, responseKey, ids, params) {
|
|
1068
|
+
if (ids.length <= MULTI_GET_MAX_IDS) {
|
|
1069
|
+
const { data } = await capsuleGet(
|
|
1070
|
+
`${base}/${ids.join(",")}`,
|
|
1071
|
+
params
|
|
1072
|
+
);
|
|
1073
|
+
return data;
|
|
1074
|
+
}
|
|
1075
|
+
const chunks = chunk(ids, MULTI_GET_MAX_IDS);
|
|
1076
|
+
const responses = await Promise.all(
|
|
1077
|
+
chunks.map(
|
|
1078
|
+
(chunkIds) => capsuleGet(`${base}/${chunkIds.join(",")}`, params)
|
|
1079
|
+
)
|
|
1080
|
+
);
|
|
1081
|
+
return { [responseKey]: responses.flatMap((r) => r.data[responseKey] ?? []) };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1055
1084
|
// src/tools/custom-field-helpers.ts
|
|
1056
1085
|
import { z as z5 } from "zod";
|
|
1057
1086
|
var CustomFieldWriteSchema = z5.object({
|
|
@@ -1174,20 +1203,7 @@ var getPartiesSchema = z6.object({
|
|
|
1174
1203
|
embed: z6.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1175
1204
|
});
|
|
1176
1205
|
async function getParties(input) {
|
|
1177
|
-
|
|
1178
|
-
if (ids.length <= 10) {
|
|
1179
|
-
const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
|
|
1180
|
-
embed
|
|
1181
|
-
});
|
|
1182
|
-
return data;
|
|
1183
|
-
}
|
|
1184
|
-
const chunks = chunk(ids, 10);
|
|
1185
|
-
const responses = await Promise.all(
|
|
1186
|
-
chunks.map(
|
|
1187
|
-
(chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
|
|
1188
|
-
)
|
|
1189
|
-
);
|
|
1190
|
-
return { parties: responses.flatMap((r) => r.data.parties) };
|
|
1206
|
+
return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
|
|
1191
1207
|
}
|
|
1192
1208
|
var listPartyOpportunitiesSchema = z6.object({
|
|
1193
1209
|
partyId: positiveId,
|
|
@@ -1227,8 +1243,11 @@ var PartyWriteBaseSchema = {
|
|
|
1227
1243
|
websites: z6.array(WebsiteSchema).optional().describe(
|
|
1228
1244
|
"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
1245
|
),
|
|
1230
|
-
ownerId: positiveId.optional().describe(
|
|
1231
|
-
"
|
|
1246
|
+
ownerId: positiveId.nullable().optional().describe(
|
|
1247
|
+
"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."
|
|
1248
|
+
),
|
|
1249
|
+
teamId: positiveId.nullable().optional().describe(
|
|
1250
|
+
"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
1251
|
)
|
|
1233
1252
|
};
|
|
1234
1253
|
var createPartySchema = z6.object({
|
|
@@ -1241,13 +1260,25 @@ var createPartySchema = z6.object({
|
|
|
1241
1260
|
organisationId: positiveId.optional().describe("Link person to an existing organisation ID"),
|
|
1242
1261
|
// organisation
|
|
1243
1262
|
name: z6.string().optional(),
|
|
1244
|
-
...PartyWriteBaseSchema
|
|
1263
|
+
...PartyWriteBaseSchema,
|
|
1264
|
+
ownerId: positiveId.optional().describe(
|
|
1265
|
+
"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`."
|
|
1266
|
+
),
|
|
1267
|
+
teamId: positiveId.optional().describe(
|
|
1268
|
+
"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."
|
|
1269
|
+
),
|
|
1270
|
+
fields: z6.array(CustomFieldWriteSchema).optional().describe(
|
|
1271
|
+
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."
|
|
1272
|
+
)
|
|
1245
1273
|
});
|
|
1246
1274
|
async function createParty(input) {
|
|
1247
|
-
const { ownerId, organisationId, ...rest } = input;
|
|
1275
|
+
const { ownerId, teamId, organisationId, fields, ...rest } = input;
|
|
1248
1276
|
const body = { ...rest };
|
|
1249
1277
|
setRef(body, "owner", ownerId);
|
|
1278
|
+
setRef(body, "team", teamId);
|
|
1250
1279
|
setRef(body, "organisation", organisationId);
|
|
1280
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
1281
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
1251
1282
|
return capsulePost("/parties", { party: body });
|
|
1252
1283
|
}
|
|
1253
1284
|
var updatePartySchema = z6.object({
|
|
@@ -1264,12 +1295,17 @@ var updatePartySchema = z6.object({
|
|
|
1264
1295
|
...PartyWriteBaseSchema
|
|
1265
1296
|
});
|
|
1266
1297
|
async function updateParty(input) {
|
|
1267
|
-
const { id, ownerId, organisationId, fields, ...rest } = input;
|
|
1298
|
+
const { id, ownerId, teamId, organisationId, fields, ...rest } = input;
|
|
1268
1299
|
const body = {};
|
|
1269
1300
|
for (const [k, v] of Object.entries(rest)) {
|
|
1270
1301
|
if (v !== void 0) body[k] = v;
|
|
1271
1302
|
}
|
|
1272
|
-
|
|
1303
|
+
let resolvedTeamId = teamId;
|
|
1304
|
+
if (ownerId !== void 0 && teamId === void 0) {
|
|
1305
|
+
({ teamId: resolvedTeamId } = await readEntityRefs(`/parties/${id}`, "party"));
|
|
1306
|
+
}
|
|
1307
|
+
setNullableRef(body, "owner", ownerId);
|
|
1308
|
+
setNullableRef(body, "team", resolvedTeamId);
|
|
1273
1309
|
setNullableRef(body, "organisation", organisationId);
|
|
1274
1310
|
const mappedFields = mapFieldsForBody(fields);
|
|
1275
1311
|
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
@@ -1437,18 +1473,6 @@ async function removePartyWebsiteById(input) {
|
|
|
1437
1473
|
|
|
1438
1474
|
// src/tools/opportunities.ts
|
|
1439
1475
|
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
1476
|
var OpportunityValueSchema = z7.object({
|
|
1453
1477
|
amount: z7.number().nonnegative(),
|
|
1454
1478
|
currency: z7.string({
|
|
@@ -1490,23 +1514,7 @@ var getOpportunitiesSchema = z7.object({
|
|
|
1490
1514
|
embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1491
1515
|
});
|
|
1492
1516
|
async function getOpportunities(input) {
|
|
1493
|
-
|
|
1494
|
-
if (ids.length <= 10) {
|
|
1495
|
-
const { data } = await capsuleGet(
|
|
1496
|
-
`/opportunities/${ids.join(",")}`,
|
|
1497
|
-
{ embed }
|
|
1498
|
-
);
|
|
1499
|
-
return data;
|
|
1500
|
-
}
|
|
1501
|
-
const chunks = chunk(ids, 10);
|
|
1502
|
-
const responses = await Promise.all(
|
|
1503
|
-
chunks.map(
|
|
1504
|
-
(chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
|
|
1505
|
-
embed
|
|
1506
|
-
})
|
|
1507
|
-
)
|
|
1508
|
-
);
|
|
1509
|
-
return { opportunities: responses.flatMap((r) => r.data.opportunities) };
|
|
1517
|
+
return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
|
|
1510
1518
|
}
|
|
1511
1519
|
var createOpportunitySchema = z7.object({
|
|
1512
1520
|
name: z7.string().min(1),
|
|
@@ -1519,14 +1527,17 @@ var createOpportunitySchema = z7.object({
|
|
|
1519
1527
|
expectedCloseOn: z7.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
1520
1528
|
probability: z7.number().int().min(0).max(100).optional(),
|
|
1521
1529
|
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.
|
|
1530
|
+
"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
1531
|
),
|
|
1524
1532
|
teamId: positiveId.optional().describe(
|
|
1525
1533
|
"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)."
|
|
1534
|
+
),
|
|
1535
|
+
fields: z7.array(CustomFieldWriteSchema).optional().describe(
|
|
1536
|
+
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
1537
|
)
|
|
1527
1538
|
});
|
|
1528
1539
|
async function createOpportunity(input) {
|
|
1529
|
-
const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
|
|
1540
|
+
const { partyId, milestoneId, ownerId, teamId, fields, ...rest } = input;
|
|
1530
1541
|
const body = {
|
|
1531
1542
|
...rest,
|
|
1532
1543
|
party: { id: partyId },
|
|
@@ -1534,13 +1545,15 @@ async function createOpportunity(input) {
|
|
|
1534
1545
|
};
|
|
1535
1546
|
setRef(body, "owner", ownerId);
|
|
1536
1547
|
setRef(body, "team", teamId);
|
|
1548
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
1549
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
1537
1550
|
return capsulePost("/opportunities", { opportunity: body });
|
|
1538
1551
|
}
|
|
1539
1552
|
var updateOpportunitySchema = z7.object({
|
|
1540
1553
|
id: positiveId,
|
|
1541
1554
|
name: z7.string().min(1).optional(),
|
|
1542
1555
|
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."
|
|
1556
|
+
"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
1557
|
),
|
|
1545
1558
|
milestoneId: positiveId.optional().describe(
|
|
1546
1559
|
"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 +1567,8 @@ var updateOpportunitySchema = z7.object({
|
|
|
1554
1567
|
lostReasonId: positiveId.optional().describe(
|
|
1555
1568
|
"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
1569
|
),
|
|
1557
|
-
ownerId: positiveId.optional().describe(
|
|
1558
|
-
"Reassign owner
|
|
1570
|
+
ownerId: positiveId.nullable().optional().describe(
|
|
1571
|
+
"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
1572
|
),
|
|
1560
1573
|
teamId: positiveId.nullable().optional().describe(
|
|
1561
1574
|
"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 +1587,7 @@ async function updateOpportunity(input) {
|
|
|
1574
1587
|
if (ownerId !== void 0 && teamId === void 0) {
|
|
1575
1588
|
({ teamId: resolvedTeamId } = await readEntityRefs(`/opportunities/${id}`, "opportunity"));
|
|
1576
1589
|
}
|
|
1577
|
-
|
|
1590
|
+
setNullableRef(body, "owner", ownerId);
|
|
1578
1591
|
setNullableRef(body, "team", resolvedTeamId);
|
|
1579
1592
|
setRef(body, "lostReason", lostReasonId);
|
|
1580
1593
|
const mappedFields = mapFieldsForBody(fields);
|
|
@@ -1629,20 +1642,7 @@ var getProjectsSchema = z8.object({
|
|
|
1629
1642
|
embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1630
1643
|
});
|
|
1631
1644
|
async function getProjects(input) {
|
|
1632
|
-
|
|
1633
|
-
if (ids.length <= 10) {
|
|
1634
|
-
const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
|
|
1635
|
-
embed
|
|
1636
|
-
});
|
|
1637
|
-
return data;
|
|
1638
|
-
}
|
|
1639
|
-
const chunks = chunk(ids, 10);
|
|
1640
|
-
const responses = await Promise.all(
|
|
1641
|
-
chunks.map(
|
|
1642
|
-
(chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
|
|
1643
|
-
)
|
|
1644
|
-
);
|
|
1645
|
-
return { kases: responses.flatMap((r) => r.data.kases) };
|
|
1645
|
+
return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
|
|
1646
1646
|
}
|
|
1647
1647
|
var createProjectSchema = z8.object({
|
|
1648
1648
|
name: z8.string().min(1),
|
|
@@ -1658,10 +1658,13 @@ var createProjectSchema = z8.object({
|
|
|
1658
1658
|
stageId: positiveId.optional().describe(
|
|
1659
1659
|
"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
1660
|
),
|
|
1661
|
-
expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD")
|
|
1661
|
+
expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
1662
|
+
fields: z8.array(CustomFieldWriteSchema).optional().describe(
|
|
1663
|
+
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."
|
|
1664
|
+
)
|
|
1662
1665
|
});
|
|
1663
1666
|
async function createProject(input) {
|
|
1664
|
-
const { partyId, ownerId, teamId, status, stageId, ...rest } = input;
|
|
1667
|
+
const { partyId, ownerId, teamId, status, stageId, fields, ...rest } = input;
|
|
1665
1668
|
const body = {
|
|
1666
1669
|
...rest,
|
|
1667
1670
|
status: status ?? "OPEN",
|
|
@@ -1670,6 +1673,8 @@ async function createProject(input) {
|
|
|
1670
1673
|
setRef(body, "owner", ownerId);
|
|
1671
1674
|
setRef(body, "team", teamId);
|
|
1672
1675
|
if (stageId) body["stage"] = stageId;
|
|
1676
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
1677
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
1673
1678
|
return capsulePost("/kases", { kase: body });
|
|
1674
1679
|
}
|
|
1675
1680
|
var updateProjectSchema = z8.object({
|
|
@@ -1678,7 +1683,7 @@ var updateProjectSchema = z8.object({
|
|
|
1678
1683
|
description: z8.string().optional(),
|
|
1679
1684
|
status: z8.enum(["OPEN", "CLOSED"]).optional(),
|
|
1680
1685
|
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."
|
|
1686
|
+
"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
1687
|
),
|
|
1683
1688
|
ownerId: positiveId.nullable().optional().describe(
|
|
1684
1689
|
"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 +1691,8 @@ var updateProjectSchema = z8.object({
|
|
|
1686
1691
|
teamId: positiveId.nullable().optional().describe(
|
|
1687
1692
|
"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
1693
|
),
|
|
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."
|
|
1694
|
+
stageId: positiveId.nullable().optional().describe(
|
|
1695
|
+
"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
1696
|
),
|
|
1692
1697
|
expectedCloseOn: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
1693
1698
|
fields: z8.array(CustomFieldWriteSchema).optional().describe(
|
|
@@ -1710,11 +1715,18 @@ async function updateProject(input) {
|
|
|
1710
1715
|
}
|
|
1711
1716
|
setNullableRef(body, "owner", ownerId);
|
|
1712
1717
|
setNullableRef(body, "team", resolvedTeamId);
|
|
1713
|
-
if (resolvedStageId) body["stage"] =
|
|
1718
|
+
if (resolvedStageId === null) body["stage"] = null;
|
|
1719
|
+
else if (resolvedStageId !== void 0) body["stage"] = resolvedStageId;
|
|
1714
1720
|
const mappedFields = mapFieldsForBody(fields);
|
|
1715
1721
|
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
1716
1722
|
return capsulePut(`/kases/${id}`, { kase: body });
|
|
1717
1723
|
}
|
|
1724
|
+
var { schema: batchUpdateProjectSchema, handler: batchUpdateProject } = defineBatch({
|
|
1725
|
+
toolName: "batch_update_project",
|
|
1726
|
+
itemSchema: updateProjectSchema,
|
|
1727
|
+
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.",
|
|
1728
|
+
itemHandler: updateProject
|
|
1729
|
+
});
|
|
1718
1730
|
var { schema: deleteProjectSchema, handler: deleteProject } = defineDelete({
|
|
1719
1731
|
toolName: "delete_project",
|
|
1720
1732
|
pathPrefix: "/kases",
|
|
@@ -1762,16 +1774,7 @@ var getTasksSchema = z9.object({
|
|
|
1762
1774
|
)
|
|
1763
1775
|
});
|
|
1764
1776
|
async function getTasks(input) {
|
|
1765
|
-
|
|
1766
|
-
if (ids.length <= 10) {
|
|
1767
|
-
const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
|
|
1768
|
-
return data;
|
|
1769
|
-
}
|
|
1770
|
-
const chunks = chunk(ids, 10);
|
|
1771
|
-
const responses = await Promise.all(
|
|
1772
|
-
chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
|
|
1773
|
-
);
|
|
1774
|
-
return { tasks: responses.flatMap((r) => r.data.tasks) };
|
|
1777
|
+
return chunkedMultiGet("/tasks", "tasks", input.ids);
|
|
1775
1778
|
}
|
|
1776
1779
|
var createTaskSchema = z9.object({
|
|
1777
1780
|
description: z9.string().min(1),
|
|
@@ -1814,7 +1817,7 @@ var updateTaskSchema = z9.object({
|
|
|
1814
1817
|
"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
1818
|
),
|
|
1816
1819
|
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."
|
|
1820
|
+
"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
1821
|
),
|
|
1819
1822
|
opportunityId: positiveId.nullable().optional().describe(
|
|
1820
1823
|
"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."
|
|
@@ -1872,14 +1875,102 @@ var listEntriesPagination = {
|
|
|
1872
1875
|
};
|
|
1873
1876
|
var listPartyEntriesSchema = z10.object({
|
|
1874
1877
|
partyId: positiveId,
|
|
1875
|
-
...listEntriesPagination
|
|
1878
|
+
...listEntriesPagination,
|
|
1879
|
+
includeLinkedPersons: z10.boolean().optional().describe(
|
|
1880
|
+
"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."
|
|
1881
|
+
)
|
|
1876
1882
|
});
|
|
1883
|
+
async function fanOutPartyEntries(partyIds, embed, perPage) {
|
|
1884
|
+
const concurrency = getBatchConcurrency();
|
|
1885
|
+
const results = new Array(partyIds.length);
|
|
1886
|
+
let cursor = 0;
|
|
1887
|
+
async function worker() {
|
|
1888
|
+
while (true) {
|
|
1889
|
+
const i = cursor;
|
|
1890
|
+
cursor += 1;
|
|
1891
|
+
if (i >= partyIds.length) return;
|
|
1892
|
+
const id = partyIds[i];
|
|
1893
|
+
const { data, nextPage } = await capsuleGet(
|
|
1894
|
+
`/parties/${id}/entries`,
|
|
1895
|
+
{
|
|
1896
|
+
embed,
|
|
1897
|
+
page: 1,
|
|
1898
|
+
perPage
|
|
1899
|
+
}
|
|
1900
|
+
);
|
|
1901
|
+
results[i] = { entries: data.entries, nextPage };
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
const workers = [];
|
|
1905
|
+
for (let w = 0; w < Math.min(concurrency, partyIds.length); w++) {
|
|
1906
|
+
workers.push(worker());
|
|
1907
|
+
}
|
|
1908
|
+
await Promise.all(workers);
|
|
1909
|
+
return results;
|
|
1910
|
+
}
|
|
1911
|
+
function mergedTimelineCandidatePerParty(page, perPage) {
|
|
1912
|
+
return Math.min(page * perPage, 100);
|
|
1913
|
+
}
|
|
1914
|
+
function mergedTimelineNextPage(page, perPage, mergedLength, upstreamHasNextPage) {
|
|
1915
|
+
const requestedWindowEnd = page * perPage;
|
|
1916
|
+
if (mergedLength > requestedWindowEnd) return page + 1;
|
|
1917
|
+
const nextWindowWithinCap = requestedWindowEnd < 100;
|
|
1918
|
+
if (nextWindowWithinCap && upstreamHasNextPage) return page + 1;
|
|
1919
|
+
return void 0;
|
|
1920
|
+
}
|
|
1877
1921
|
async function listPartyEntries(input) {
|
|
1878
|
-
const {
|
|
1879
|
-
|
|
1880
|
-
{
|
|
1922
|
+
const { partyId, embed, page, perPage, includeLinkedPersons } = input;
|
|
1923
|
+
if (!includeLinkedPersons) {
|
|
1924
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
1925
|
+
`/parties/${partyId}/entries`,
|
|
1926
|
+
{ embed, page, perPage }
|
|
1927
|
+
);
|
|
1928
|
+
return { ...data, nextPage: nextPage2 };
|
|
1929
|
+
}
|
|
1930
|
+
const { data: peopleData } = await capsuleGet(
|
|
1931
|
+
`/parties/${partyId}/people`,
|
|
1932
|
+
{ page: 1, perPage: 100 }
|
|
1881
1933
|
);
|
|
1882
|
-
|
|
1934
|
+
const peopleIds = (peopleData.parties ?? []).map((p) => p.id);
|
|
1935
|
+
if (peopleIds.length === 0) {
|
|
1936
|
+
const { data, nextPage: nextPage2 } = await capsuleGet(
|
|
1937
|
+
`/parties/${partyId}/entries`,
|
|
1938
|
+
{ embed, page, perPage }
|
|
1939
|
+
);
|
|
1940
|
+
return { ...data, nextPage: nextPage2 };
|
|
1941
|
+
}
|
|
1942
|
+
const targetIds = [partyId, ...peopleIds];
|
|
1943
|
+
const perPartyPages = await fanOutPartyEntries(
|
|
1944
|
+
targetIds,
|
|
1945
|
+
embed,
|
|
1946
|
+
mergedTimelineCandidatePerParty(page, perPage)
|
|
1947
|
+
);
|
|
1948
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1949
|
+
const merged = [];
|
|
1950
|
+
for (const { entries } of perPartyPages) {
|
|
1951
|
+
for (const raw of entries) {
|
|
1952
|
+
const e = raw;
|
|
1953
|
+
if (typeof e?.id !== "number") continue;
|
|
1954
|
+
if (seen.has(e.id)) continue;
|
|
1955
|
+
seen.add(e.id);
|
|
1956
|
+
merged.push(e);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
merged.sort((a, b) => {
|
|
1960
|
+
const ax = a.entryAt ?? "";
|
|
1961
|
+
const bx = b.entryAt ?? "";
|
|
1962
|
+
if (ax !== bx) return bx.localeCompare(ax);
|
|
1963
|
+
return b.id - a.id;
|
|
1964
|
+
});
|
|
1965
|
+
const start = (page - 1) * perPage;
|
|
1966
|
+
const slice = merged.slice(start, start + perPage);
|
|
1967
|
+
const nextPage = mergedTimelineNextPage(
|
|
1968
|
+
page,
|
|
1969
|
+
perPage,
|
|
1970
|
+
merged.length,
|
|
1971
|
+
perPartyPages.some((p) => p.nextPage !== void 0)
|
|
1972
|
+
);
|
|
1973
|
+
return { entries: slice, ...nextPage !== void 0 ? { nextPage } : {} };
|
|
1883
1974
|
}
|
|
1884
1975
|
var listOpportunityEntriesSchema = z10.object({
|
|
1885
1976
|
opportunityId: positiveId,
|
|
@@ -2102,6 +2193,28 @@ async function removeTagById(input) {
|
|
|
2102
2193
|
invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
|
|
2103
2194
|
return result;
|
|
2104
2195
|
}
|
|
2196
|
+
var deleteTagDefinitionSchema = z13.object({
|
|
2197
|
+
entity: TagEntity,
|
|
2198
|
+
tagId: positiveId.describe(
|
|
2199
|
+
"The tag definition's id (from list_tags, or embed='tags' on a record). NOT an entity id."
|
|
2200
|
+
),
|
|
2201
|
+
confirm: confirmFlag().describe(
|
|
2202
|
+
"Must be set to true. DESTRUCTIVE & tenant-wide: permanently deletes the tag DEFINITION from this entity type's tag namespace, removing it from EVERY record that shares it \u2014 not just one. To detach a tag from a single record while keeping the definition, use remove_tag_by_id instead. Irreversible (the definition is gone; re-creating by name via add_tag mints a new id). Idempotent on retry."
|
|
2203
|
+
)
|
|
2204
|
+
});
|
|
2205
|
+
async function deleteTagDefinition(input) {
|
|
2206
|
+
const { entity, tagId, confirm } = input;
|
|
2207
|
+
if (confirm !== true) {
|
|
2208
|
+
throw new Error("delete_tag_definition requires confirm: true");
|
|
2209
|
+
}
|
|
2210
|
+
const result = await idempotent(
|
|
2211
|
+
() => capsuleDelete(`/${entity}/tags/${tagId}`),
|
|
2212
|
+
() => ({ deleted: true, alreadyDeleted: false, entity, tagId }),
|
|
2213
|
+
() => ({ deleted: true, alreadyDeleted: true, entity, tagId })
|
|
2214
|
+
);
|
|
2215
|
+
invalidateByPrefix(TAG_LIST_PATH[entity], "delete_tag_definition");
|
|
2216
|
+
return result;
|
|
2217
|
+
}
|
|
2105
2218
|
var { schema: batchAddTagSchema, handler: batchAddTag } = defineBatch({
|
|
2106
2219
|
toolName: "batch_add_tag",
|
|
2107
2220
|
itemSchema: addTagSchema,
|
|
@@ -2607,7 +2720,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
2607
2720
|
const server2 = new McpServer(
|
|
2608
2721
|
{
|
|
2609
2722
|
name: "capsulemcp",
|
|
2610
|
-
version: "1.
|
|
2723
|
+
version: "1.7.0",
|
|
2611
2724
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
2612
2725
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
2613
2726
|
icons: ICONS
|
|
@@ -2703,14 +2816,14 @@ function createCapsuleMcpServer(opts) {
|
|
|
2703
2816
|
registerTool(
|
|
2704
2817
|
server2,
|
|
2705
2818
|
"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.",
|
|
2819
|
+
"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
2820
|
createPartySchema,
|
|
2708
2821
|
createParty
|
|
2709
2822
|
);
|
|
2710
2823
|
registerTool(
|
|
2711
2824
|
server2,
|
|
2712
2825
|
"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
|
|
2826
|
+
"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
2827
|
updatePartySchema,
|
|
2715
2828
|
updateParty
|
|
2716
2829
|
);
|
|
@@ -2845,7 +2958,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
2845
2958
|
registerTool(
|
|
2846
2959
|
server2,
|
|
2847
2960
|
"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'.",
|
|
2961
|
+
"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
2962
|
updateOpportunitySchema,
|
|
2850
2963
|
updateOpportunity
|
|
2851
2964
|
);
|
|
@@ -2910,10 +3023,17 @@ function createCapsuleMcpServer(opts) {
|
|
|
2910
3023
|
registerTool(
|
|
2911
3024
|
server2,
|
|
2912
3025
|
"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'.",
|
|
3026
|
+
"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
3027
|
updateProjectSchema,
|
|
2915
3028
|
updateProject
|
|
2916
3029
|
);
|
|
3030
|
+
registerBatchTool(
|
|
3031
|
+
server2,
|
|
3032
|
+
"batch_update_project",
|
|
3033
|
+
"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.",
|
|
3034
|
+
batchUpdateProjectSchema,
|
|
3035
|
+
batchUpdateProject
|
|
3036
|
+
);
|
|
2917
3037
|
registerTool(
|
|
2918
3038
|
server2,
|
|
2919
3039
|
"delete_project",
|
|
@@ -3018,7 +3138,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3018
3138
|
registerTool(
|
|
3019
3139
|
server2,
|
|
3020
3140
|
"list_party_entries",
|
|
3021
|
-
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively.",
|
|
3141
|
+
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively. IMPORTANT for organisations: pass `includeLinkedPersons: true` to surface entries filed against the org's linked people (sales-conversation emails almost always land on a person row, not the org row \u2014 Capsule's API files each entry against exactly one party). Without this flag, an org with active customer-facing email will appear quiet here even though its `lastContactedAt` is current. For any 'what's new with $ORG?' query, set `includeLinkedPersons: true`.",
|
|
3022
3142
|
listPartyEntriesSchema,
|
|
3023
3143
|
listPartyEntries
|
|
3024
3144
|
);
|
|
@@ -3055,9 +3175,12 @@ function createCapsuleMcpServer(opts) {
|
|
|
3055
3175
|
"Download an attachment by id. Returns image content for image/* types (Claude can describe it natively); decoded text for text/* and application/json (small files); JSON metadata + base64 payload for other binary types (PDF, Office docs, etc.). Files exceeding maxSizeBytes (default 5MB) return metadata only with a `truncated: true` flag.",
|
|
3056
3176
|
getAttachmentSchema.shape,
|
|
3057
3177
|
// get_attachment is read-only — downloads a binary, never mutates.
|
|
3058
|
-
// Mirrors the auto-inferred `readOnlyHint: true
|
|
3059
|
-
// `registerTool` applies to every other `get_*` tool.
|
|
3060
|
-
|
|
3178
|
+
// Mirrors the auto-inferred `{readOnlyHint: true, destructiveHint:
|
|
3179
|
+
// false}` that `registerTool` applies to every other `get_*` tool.
|
|
3180
|
+
// Explicit destructiveHint: false is load-bearing — MCP spec
|
|
3181
|
+
// defaults destructiveHint to `true`, so omitting it would (in
|
|
3182
|
+
// some client implementations) classify this read as destructive.
|
|
3183
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
3061
3184
|
async (input) => {
|
|
3062
3185
|
const result = await getAttachment(input);
|
|
3063
3186
|
if (result.truncated) {
|
|
@@ -3288,6 +3411,13 @@ function createCapsuleMcpServer(opts) {
|
|
|
3288
3411
|
removeTagByIdSchema,
|
|
3289
3412
|
removeTagById
|
|
3290
3413
|
);
|
|
3414
|
+
registerTool(
|
|
3415
|
+
server2,
|
|
3416
|
+
"delete_tag_definition",
|
|
3417
|
+
"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).",
|
|
3418
|
+
deleteTagDefinitionSchema,
|
|
3419
|
+
deleteTagDefinition
|
|
3420
|
+
);
|
|
3291
3421
|
registerBatchTool(
|
|
3292
3422
|
server2,
|
|
3293
3423
|
"batch_add_tag",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capsulemcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
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",
|