Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ npm install
npm test
```

538 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:
540 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:

- **Per-tool unit tests** (e.g. `tests/parties.test.ts`): import the tool function, mock `undici.fetch`, assert on the URL, method, body, and response handling. Most tests live here.
- **MCP-protocol integration tests** (`tests/mcp-integration.test.ts`): drive a real `McpServer` through the wire protocol via the SDK's in-memory transport pair, with `undici.fetch` still mocked. Catches the layer between "tool function works" and "MCP correctly registers and dispatches the tool". Includes the `get_attachment` content-type routing logic (which lives in `server.ts`, not the tool function).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ That's it. The first launch fetches the package from npm (a few seconds); subseq
| Tracks (workflow instances) | `list_track_definitions`, `list_entity_tracks`, `show_track` | `apply_track`, `update_track`, `remove_track` |
| Saved filters | `list_saved_filters`, `run_saved_filter` | — |
| Custom fields (schema) | `list_custom_fields`, `get_custom_field` | — |
| Tags | `list_tags` | `add_tag`, `remove_tag_by_id` |
| Tags | `list_tags` | `add_tag`, `remove_tag_by_id`, `delete_tag_definition` |
| Users & teams | `list_users`, `get_current_user`, `list_teams` | — |
| Reference metadata | `list_lostreasons`, `list_activitytypes`, `list_categories`, `list_goals`, `get_site` | — |

Expand Down
16 changes: 8 additions & 8 deletions src/capsule/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@
* is correct because Capsule's pagination is stable for stable
* data.
*
* - Invalidation: `add_tag` and `remove_tag_by_id` mutate the
* tag catalogue, so they call `invalidateByPrefix("/tags")` to
* drop cached `list_tags` responses before the next read sees
* stale data. Other cached endpoints have no write-side
* counterpart in our tool surface, so no invalidation is
* wired for them.
* - Invalidation: `add_tag`, `remove_tag_by_id`, and
* `delete_tag_definition` mutate the tag catalogue, so they call
* `invalidateByPrefix("/<entity>/tags")` to drop cached
* `list_tags` responses before the next read sees stale data.
* Other cached endpoints have no write-side counterpart in our
* tool surface, so no invalidation is wired for them.
*/

import { readBool, readPositiveInt } from "../env.js";
Expand Down Expand Up @@ -179,8 +179,8 @@ export function cacheSet<T>(key: string, result: PagedResult<T>): void {

/**
* Drop every cached entry whose key starts with `GET <pathPrefix>`.
* Used by `add_tag` / `remove_tag_by_id` after a mutation to keep
* subsequent `list_tags` reads consistent within the same process.
* Used by tag mutations after a write to keep subsequent `list_tags`
* reads consistent within the same process.
* `trigger` identifies the caller (e.g. "add_tag") for log diagnostics.
*/
export function invalidateByPrefix(pathPrefix: string, trigger?: string): void {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const listPartyEntriesSchema = z.object({
"WHY THIS FLAG EXISTS: Capsule's API files each entry against exactly one party row (verified v1.6.6 wire-trace probe 4 — 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 — 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 — 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 — 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). Paging past that ceiling (`page × perPage > 100`) returns no further entries and ends 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.",
"PAGINATION CAVEAT: `page` and `perPage` apply to the MERGED window, and the merge has a hard ceiling — 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.",
),
});

Expand Down
15 changes: 15 additions & 0 deletions tests/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,19 @@ describe("tag mutation invalidates list_tags cache", () => {
await listTags({ entity: "parties", page: 1, perPage: 100 });
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3);
});

it("delete_tag_definition also invalidates the cached list", async () => {
mockFetch(200, { tags: [{ id: 5, name: "Typo" }] });
const { listTags, deleteTagDefinition } = await import("../src/tools/tags.js");
await listTags({ entity: "parties", page: 1, perPage: 100 });
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);

mockFetch(204, {});
await deleteTagDefinition({ entity: "parties", tagId: 5, confirm: true });
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);

mockFetch(200, { tags: [] });
await listTags({ entity: "parties", page: 1, perPage: 100 });
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3);
});
});
36 changes: 36 additions & 0 deletions tests/entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,42 @@ describe("listPartyEntries", () => {
// though the person had an upstream rel=next.
expect((result as { nextPage?: number }).nextPage).toBeUndefined();
});

it("truncates a merged window that crosses the 100-entry ceiling and ends the feed", async () => {
// A window straddling position 100 returns only the in-ceiling tail
// (the entries still inside the guaranteed top-100 merge), then ends
// the feed — it does not advertise a further page into unmerged
// older history. Complements the boundary test above with the
// partial-window case.
mockFetch(200, { parties: [{ id: 8 }] });
mockFetch(200, { entries: [] });
const hundred = Array.from({ length: 100 }, (_v, i) => ({
id: 2000 - i,
type: "email",
entryAt: new Date(Date.UTC(2026, 4, 27, 0, 0, 0) + (100 - i) * 1000).toISOString(),
}));
mockFetch(
200,
{ entries: hundred },
{
Link: '<https://api.capsulecrm.com/api/v2/parties/8/entries?page=2&perPage=100>; rel="next"',
},
);

const { listPartyEntries } = await import("../src/tools/entries.js");
const result = await listPartyEntries({
partyId: 7,
page: 2,
perPage: 75,
includeLinkedPersons: true,
});

// Page 2/perPage 75 asks for positions 76..150. Only 76..100 are
// inside the guaranteed top-100 merge ceiling, so return that tail
// (25 entries) and do not advertise page 3.
expect(result.entries).toHaveLength(25);
expect((result as { nextPage?: number }).nextPage).toBeUndefined();
});
});

describe("listOpportunityEntries", () => {
Expand Down