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 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
- - **87 tools** across the Capsule resource graph (49 in read-only mode) — full read coverage plus careful, confirm-gated writes; 6 batched-write tools (`batch_*`) for mass-update workflows
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`, 7 destructive ones carry `destructiveHint: true` — clients that honor these hints can auto-approve safe reads while still prompting for writes/destructive calls
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.6.5"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.6.5"` — same arguments, just installs from a git clone rather than the npm registry. See [INSTALL.md](INSTALL.md) for the Claude Code path, manual install, and troubleshooting.
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 void 0;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids } = input;
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 { data, nextPage } = await capsuleGet(
2417
- `/parties/${input.partyId}/entries`,
2418
- { embed: input.embed, page: input.page, perPage: input.perPage }
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
- return { ...data, nextPage };
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.6.5",
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` that
3604
- // `registerTool` applies to every other `get_*` tool.
3605
- { readOnlyHint: true },
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 &mdash; 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> &middot; 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 void 0;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids, embed } = input;
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
- const { ids } = input;
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 { data, nextPage } = await capsuleGet(
1914
- `/parties/${input.partyId}/entries`,
1915
- { embed: input.embed, page: input.page, perPage: input.perPage }
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
- return { ...data, nextPage };
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.6.5",
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` that
3101
- // `registerTool` applies to every other `get_*` tool.
3102
- { readOnlyHint: true },
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.6.5",
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",