capsulemcp 1.6.5 → 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 +184 -68
- package/dist/index.js +156 -68
- 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 [];
|
|
@@ -1565,6 +1565,25 @@ async function readEntityRefs(path, responseKey) {
|
|
|
1565
1565
|
};
|
|
1566
1566
|
}
|
|
1567
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
|
+
|
|
1568
1587
|
// src/tools/custom-field-helpers.ts
|
|
1569
1588
|
import { z as z6 } from "zod";
|
|
1570
1589
|
var CustomFieldWriteSchema = z6.object({
|
|
@@ -1687,20 +1706,7 @@ var getPartiesSchema = z7.object({
|
|
|
1687
1706
|
embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1688
1707
|
});
|
|
1689
1708
|
async function getParties(input) {
|
|
1690
|
-
|
|
1691
|
-
if (ids.length <= 10) {
|
|
1692
|
-
const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
|
|
1693
|
-
embed
|
|
1694
|
-
});
|
|
1695
|
-
return data;
|
|
1696
|
-
}
|
|
1697
|
-
const chunks = chunk(ids, 10);
|
|
1698
|
-
const responses = await Promise.all(
|
|
1699
|
-
chunks.map(
|
|
1700
|
-
(chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
|
|
1701
|
-
)
|
|
1702
|
-
);
|
|
1703
|
-
return { parties: responses.flatMap((r) => r.data.parties) };
|
|
1709
|
+
return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
|
|
1704
1710
|
}
|
|
1705
1711
|
var listPartyOpportunitiesSchema = z7.object({
|
|
1706
1712
|
partyId: positiveId,
|
|
@@ -2011,23 +2017,7 @@ var getOpportunitiesSchema = z8.object({
|
|
|
2011
2017
|
embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
2012
2018
|
});
|
|
2013
2019
|
async function getOpportunities(input) {
|
|
2014
|
-
|
|
2015
|
-
if (ids.length <= 10) {
|
|
2016
|
-
const { data } = await capsuleGet(
|
|
2017
|
-
`/opportunities/${ids.join(",")}`,
|
|
2018
|
-
{ embed }
|
|
2019
|
-
);
|
|
2020
|
-
return data;
|
|
2021
|
-
}
|
|
2022
|
-
const chunks = chunk(ids, 10);
|
|
2023
|
-
const responses = await Promise.all(
|
|
2024
|
-
chunks.map(
|
|
2025
|
-
(chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
|
|
2026
|
-
embed
|
|
2027
|
-
})
|
|
2028
|
-
)
|
|
2029
|
-
);
|
|
2030
|
-
return { opportunities: responses.flatMap((r) => r.data.opportunities) };
|
|
2020
|
+
return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
|
|
2031
2021
|
}
|
|
2032
2022
|
var createOpportunitySchema = z8.object({
|
|
2033
2023
|
name: z8.string().min(1),
|
|
@@ -2155,20 +2145,7 @@ var getProjectsSchema = z9.object({
|
|
|
2155
2145
|
embed: z9.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
2156
2146
|
});
|
|
2157
2147
|
async function getProjects(input) {
|
|
2158
|
-
|
|
2159
|
-
if (ids.length <= 10) {
|
|
2160
|
-
const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
|
|
2161
|
-
embed
|
|
2162
|
-
});
|
|
2163
|
-
return data;
|
|
2164
|
-
}
|
|
2165
|
-
const chunks = chunk(ids, 10);
|
|
2166
|
-
const responses = await Promise.all(
|
|
2167
|
-
chunks.map(
|
|
2168
|
-
(chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
|
|
2169
|
-
)
|
|
2170
|
-
);
|
|
2171
|
-
return { kases: responses.flatMap((r) => r.data.kases) };
|
|
2148
|
+
return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
|
|
2172
2149
|
}
|
|
2173
2150
|
var createProjectSchema = z9.object({
|
|
2174
2151
|
name: z9.string().min(1),
|
|
@@ -2300,16 +2277,7 @@ var getTasksSchema = z10.object({
|
|
|
2300
2277
|
)
|
|
2301
2278
|
});
|
|
2302
2279
|
async function getTasks(input) {
|
|
2303
|
-
|
|
2304
|
-
if (ids.length <= 10) {
|
|
2305
|
-
const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
|
|
2306
|
-
return data;
|
|
2307
|
-
}
|
|
2308
|
-
const chunks = chunk(ids, 10);
|
|
2309
|
-
const responses = await Promise.all(
|
|
2310
|
-
chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
|
|
2311
|
-
);
|
|
2312
|
-
return { tasks: responses.flatMap((r) => r.data.tasks) };
|
|
2280
|
+
return chunkedMultiGet("/tasks", "tasks", input.ids);
|
|
2313
2281
|
}
|
|
2314
2282
|
var createTaskSchema = z10.object({
|
|
2315
2283
|
description: z10.string().min(1),
|
|
@@ -2410,14 +2378,102 @@ var listEntriesPagination = {
|
|
|
2410
2378
|
};
|
|
2411
2379
|
var listPartyEntriesSchema = z11.object({
|
|
2412
2380
|
partyId: positiveId,
|
|
2413
|
-
...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
|
+
)
|
|
2414
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
|
+
}
|
|
2415
2424
|
async function listPartyEntries(input) {
|
|
2416
|
-
const {
|
|
2417
|
-
|
|
2418
|
-
{
|
|
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 }
|
|
2419
2436
|
);
|
|
2420
|
-
|
|
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 } : {} };
|
|
2421
2477
|
}
|
|
2422
2478
|
var listOpportunityEntriesSchema = z11.object({
|
|
2423
2479
|
opportunityId: positiveId,
|
|
@@ -2640,6 +2696,28 @@ async function removeTagById(input) {
|
|
|
2640
2696
|
invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
|
|
2641
2697
|
return result;
|
|
2642
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
|
+
}
|
|
2643
2721
|
var { schema: batchAddTagSchema, handler: batchAddTag } = defineBatch({
|
|
2644
2722
|
toolName: "batch_add_tag",
|
|
2645
2723
|
itemSchema: addTagSchema,
|
|
@@ -3145,7 +3223,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3145
3223
|
const server = new McpServer(
|
|
3146
3224
|
{
|
|
3147
3225
|
name: "capsulemcp",
|
|
3148
|
-
version: "1.
|
|
3226
|
+
version: "1.7.0",
|
|
3149
3227
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
3150
3228
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
3151
3229
|
icons: ICONS
|
|
@@ -3563,7 +3641,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3563
3641
|
registerTool(
|
|
3564
3642
|
server,
|
|
3565
3643
|
"list_party_entries",
|
|
3566
|
-
"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`.",
|
|
3567
3645
|
listPartyEntriesSchema,
|
|
3568
3646
|
listPartyEntries
|
|
3569
3647
|
);
|
|
@@ -3600,9 +3678,12 @@ function createCapsuleMcpServer(opts) {
|
|
|
3600
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.",
|
|
3601
3679
|
getAttachmentSchema.shape,
|
|
3602
3680
|
// get_attachment is read-only — downloads a binary, never mutates.
|
|
3603
|
-
// Mirrors the auto-inferred `readOnlyHint: true
|
|
3604
|
-
// `registerTool` applies to every other `get_*` tool.
|
|
3605
|
-
|
|
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 },
|
|
3606
3687
|
async (input) => {
|
|
3607
3688
|
const result = await getAttachment(input);
|
|
3608
3689
|
if (result.truncated) {
|
|
@@ -3833,6 +3914,13 @@ function createCapsuleMcpServer(opts) {
|
|
|
3833
3914
|
removeTagByIdSchema,
|
|
3834
3915
|
removeTagById
|
|
3835
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
|
+
);
|
|
3836
3924
|
registerBatchTool(
|
|
3837
3925
|
server,
|
|
3838
3926
|
"batch_add_tag",
|
|
@@ -3950,6 +4038,34 @@ function createApp(opts) {
|
|
|
3950
4038
|
};
|
|
3951
4039
|
app2.get("/icon.svg", iconHandler);
|
|
3952
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
|
+
});
|
|
3953
4069
|
const guardOrigin = (req, res, next) => {
|
|
3954
4070
|
const origin = req.get("Origin");
|
|
3955
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 [];
|
|
@@ -1062,6 +1062,25 @@ async function readEntityRefs(path, responseKey) {
|
|
|
1062
1062
|
};
|
|
1063
1063
|
}
|
|
1064
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
|
+
|
|
1065
1084
|
// src/tools/custom-field-helpers.ts
|
|
1066
1085
|
import { z as z5 } from "zod";
|
|
1067
1086
|
var CustomFieldWriteSchema = z5.object({
|
|
@@ -1184,20 +1203,7 @@ var getPartiesSchema = z6.object({
|
|
|
1184
1203
|
embed: z6.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1185
1204
|
});
|
|
1186
1205
|
async function getParties(input) {
|
|
1187
|
-
|
|
1188
|
-
if (ids.length <= 10) {
|
|
1189
|
-
const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
|
|
1190
|
-
embed
|
|
1191
|
-
});
|
|
1192
|
-
return data;
|
|
1193
|
-
}
|
|
1194
|
-
const chunks = chunk(ids, 10);
|
|
1195
|
-
const responses = await Promise.all(
|
|
1196
|
-
chunks.map(
|
|
1197
|
-
(chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
|
|
1198
|
-
)
|
|
1199
|
-
);
|
|
1200
|
-
return { parties: responses.flatMap((r) => r.data.parties) };
|
|
1206
|
+
return chunkedMultiGet("/parties", "parties", input.ids, { embed: input.embed });
|
|
1201
1207
|
}
|
|
1202
1208
|
var listPartyOpportunitiesSchema = z6.object({
|
|
1203
1209
|
partyId: positiveId,
|
|
@@ -1508,23 +1514,7 @@ var getOpportunitiesSchema = z7.object({
|
|
|
1508
1514
|
embed: z7.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1509
1515
|
});
|
|
1510
1516
|
async function getOpportunities(input) {
|
|
1511
|
-
|
|
1512
|
-
if (ids.length <= 10) {
|
|
1513
|
-
const { data } = await capsuleGet(
|
|
1514
|
-
`/opportunities/${ids.join(",")}`,
|
|
1515
|
-
{ embed }
|
|
1516
|
-
);
|
|
1517
|
-
return data;
|
|
1518
|
-
}
|
|
1519
|
-
const chunks = chunk(ids, 10);
|
|
1520
|
-
const responses = await Promise.all(
|
|
1521
|
-
chunks.map(
|
|
1522
|
-
(chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
|
|
1523
|
-
embed
|
|
1524
|
-
})
|
|
1525
|
-
)
|
|
1526
|
-
);
|
|
1527
|
-
return { opportunities: responses.flatMap((r) => r.data.opportunities) };
|
|
1517
|
+
return chunkedMultiGet("/opportunities", "opportunities", input.ids, { embed: input.embed });
|
|
1528
1518
|
}
|
|
1529
1519
|
var createOpportunitySchema = z7.object({
|
|
1530
1520
|
name: z7.string().min(1),
|
|
@@ -1652,20 +1642,7 @@ var getProjectsSchema = z8.object({
|
|
|
1652
1642
|
embed: z8.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1653
1643
|
});
|
|
1654
1644
|
async function getProjects(input) {
|
|
1655
|
-
|
|
1656
|
-
if (ids.length <= 10) {
|
|
1657
|
-
const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
|
|
1658
|
-
embed
|
|
1659
|
-
});
|
|
1660
|
-
return data;
|
|
1661
|
-
}
|
|
1662
|
-
const chunks = chunk(ids, 10);
|
|
1663
|
-
const responses = await Promise.all(
|
|
1664
|
-
chunks.map(
|
|
1665
|
-
(chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
|
|
1666
|
-
)
|
|
1667
|
-
);
|
|
1668
|
-
return { kases: responses.flatMap((r) => r.data.kases) };
|
|
1645
|
+
return chunkedMultiGet("/kases", "kases", input.ids, { embed: input.embed });
|
|
1669
1646
|
}
|
|
1670
1647
|
var createProjectSchema = z8.object({
|
|
1671
1648
|
name: z8.string().min(1),
|
|
@@ -1797,16 +1774,7 @@ var getTasksSchema = z9.object({
|
|
|
1797
1774
|
)
|
|
1798
1775
|
});
|
|
1799
1776
|
async function getTasks(input) {
|
|
1800
|
-
|
|
1801
|
-
if (ids.length <= 10) {
|
|
1802
|
-
const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
|
|
1803
|
-
return data;
|
|
1804
|
-
}
|
|
1805
|
-
const chunks = chunk(ids, 10);
|
|
1806
|
-
const responses = await Promise.all(
|
|
1807
|
-
chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
|
|
1808
|
-
);
|
|
1809
|
-
return { tasks: responses.flatMap((r) => r.data.tasks) };
|
|
1777
|
+
return chunkedMultiGet("/tasks", "tasks", input.ids);
|
|
1810
1778
|
}
|
|
1811
1779
|
var createTaskSchema = z9.object({
|
|
1812
1780
|
description: z9.string().min(1),
|
|
@@ -1907,14 +1875,102 @@ var listEntriesPagination = {
|
|
|
1907
1875
|
};
|
|
1908
1876
|
var listPartyEntriesSchema = z10.object({
|
|
1909
1877
|
partyId: positiveId,
|
|
1910
|
-
...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
|
+
)
|
|
1911
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
|
+
}
|
|
1912
1921
|
async function listPartyEntries(input) {
|
|
1913
|
-
const {
|
|
1914
|
-
|
|
1915
|
-
{
|
|
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 }
|
|
1916
1933
|
);
|
|
1917
|
-
|
|
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 } : {} };
|
|
1918
1974
|
}
|
|
1919
1975
|
var listOpportunityEntriesSchema = z10.object({
|
|
1920
1976
|
opportunityId: positiveId,
|
|
@@ -2137,6 +2193,28 @@ async function removeTagById(input) {
|
|
|
2137
2193
|
invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
|
|
2138
2194
|
return result;
|
|
2139
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
|
+
}
|
|
2140
2218
|
var { schema: batchAddTagSchema, handler: batchAddTag } = defineBatch({
|
|
2141
2219
|
toolName: "batch_add_tag",
|
|
2142
2220
|
itemSchema: addTagSchema,
|
|
@@ -2642,7 +2720,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
2642
2720
|
const server2 = new McpServer(
|
|
2643
2721
|
{
|
|
2644
2722
|
name: "capsulemcp",
|
|
2645
|
-
version: "1.
|
|
2723
|
+
version: "1.7.0",
|
|
2646
2724
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
2647
2725
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
2648
2726
|
icons: ICONS
|
|
@@ -3060,7 +3138,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3060
3138
|
registerTool(
|
|
3061
3139
|
server2,
|
|
3062
3140
|
"list_party_entries",
|
|
3063
|
-
"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`.",
|
|
3064
3142
|
listPartyEntriesSchema,
|
|
3065
3143
|
listPartyEntries
|
|
3066
3144
|
);
|
|
@@ -3097,9 +3175,12 @@ function createCapsuleMcpServer(opts) {
|
|
|
3097
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.",
|
|
3098
3176
|
getAttachmentSchema.shape,
|
|
3099
3177
|
// get_attachment is read-only — downloads a binary, never mutates.
|
|
3100
|
-
// Mirrors the auto-inferred `readOnlyHint: true
|
|
3101
|
-
// `registerTool` applies to every other `get_*` tool.
|
|
3102
|
-
|
|
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 },
|
|
3103
3184
|
async (input) => {
|
|
3104
3185
|
const result = await getAttachment(input);
|
|
3105
3186
|
if (result.truncated) {
|
|
@@ -3330,6 +3411,13 @@ function createCapsuleMcpServer(opts) {
|
|
|
3330
3411
|
removeTagByIdSchema,
|
|
3331
3412
|
removeTagById
|
|
3332
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
|
+
);
|
|
3333
3421
|
registerBatchTool(
|
|
3334
3422
|
server2,
|
|
3335
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",
|