diff --git a/HOWTO.md b/HOWTO.md index 0af0068..727963d 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -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). diff --git a/README.md b/README.md index e2bd43e..c6d07bf 100644 --- a/README.md +++ b/README.md @@ -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` | — | diff --git a/src/capsule/cache.ts b/src/capsule/cache.ts index e17c8a0..29e6c3b 100644 --- a/src/capsule/cache.ts +++ b/src/capsule/cache.ts @@ -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("//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"; @@ -179,8 +179,8 @@ export function cacheSet(key: string, result: PagedResult): void { /** * Drop every cached entry whose key starts with `GET `. - * 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 { diff --git a/src/tools/entries.ts b/src/tools/entries.ts index c48e04c..5b6d0d4 100644 --- a/src/tools/entries.ts +++ b/src/tools/entries.ts @@ -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.", ), }); diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 767c4f2..5127dc8 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -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); + }); }); diff --git a/tests/entries.test.ts b/tests/entries.test.ts index 73e092a..8b8691f 100644 --- a/tests/entries.test.ts +++ b/tests/entries.test.ts @@ -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: '; 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", () => {