Skip to content
Closed
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
20 changes: 14 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,25 @@ versions adhere to [Semantic Versioning](https://semver.org).
unit tests + the SDK round-trip test in
`tests/tool-annotations.test.ts` lock the new contract.

- **`list_party_entries.includeLinkedPersons` merged pagination now
preserves upstream `nextPage`.** The merged feed fetches enough
entries from each party to cover the requested window (up to
Capsule's per-party cap of 100) and carries forward Capsule's
`Link rel=next` signal. This avoids a false "no next page" result
when a linked person's first page is exactly full but older entries
still exist.

### Added

- **`list_party_entries.includeLinkedPersons` (optional, default
`false`).** Opt-in flag that surfaces entries filed against an
organisation's linked people in addition to the org's own
entries. Closes a long-standing workflow gap: Capsule's API files
each entry against exactly one party row (verified v1.6.6
each entry against exactly one party row (verified v1.7.0
wire-trace probe 4 — `POST /entries` rejects multi-party bodies
with 422 "entry must be linked to either a party, opportunity or
kase"), so customer-facing emails typically land on a person row
and the org's `/entries` response misses them. Pre-v1.6.6, the
and the org's `/entries` response misses them. Pre-v1.7.0, the
fix required a manual `get_party` → `list_party_entries(org)` →
`list_employees(org)` → `list_party_entries(personN)` chain. With
the flag, the connector enumerates linked persons via
Expand All @@ -86,7 +94,7 @@ versions adhere to [Semantic Versioning](https://semver.org).

Behaviour when `includeLinkedPersons: true` is passed against a
PERSON party: silent no-op — persons have no linked-people
relationship in the data model (verified v1.6.6 probe 5,
relationship in the data model (verified v1.7.0 probe 5,
`/parties/{personId}/people` returns 200 with an empty array).
The flag is safe to default-on in callers without conditional
branching.
Expand All @@ -100,14 +108,14 @@ versions adhere to [Semantic Versioning](https://semver.org).
### Documentation

- **NOTES-ON-CAPSULE-API.md §32 (new section)** documenting the
per-row entries semantic, the v166 probe outcomes, and the
per-row entries semantic, the v170 probe outcomes, and the
connector-side mitigation. Same format as the existing §27 / §31
sections.

- **`scripts/wire-trace-v166.ts`** ships as the re-runnable probe
- **`scripts/wire-trace-v170.ts`** ships as the re-runnable probe
harness — 5 probes covering the gap, cross-direction strictness,
the `/people` endpoint shape, multi-party POST rejection, and the
person-partyId no-op. Same `ZZZ-V166-*` test record tagging and
person-partyId no-op. Same `ZZZ-V170-*` test record tagging and
full cleanup pattern as v164 / v165.

## [1.6.5] — 2026-05-25
Expand Down
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
```

529 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:
530 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
14 changes: 8 additions & 6 deletions NOTES-ON-CAPSULE-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1328,9 +1328,9 @@ describe `/{id}/entries` as "the list of entries linked to this
party" without elaborating on the linked-persons case. Behaviour
verified live, not from docs.

### Verified empirically (v1.6.6 wire-trace)
### Verified empirically (v1.7.0 wire-trace)

`scripts/wire-trace-v166.ts` ran five probes against a live tenant
`scripts/wire-trace-v170.ts` ran five probes against a live tenant
to confirm the semantic before designing the mitigation:

- **Probe 1** — note filed on a linked person → org's `/entries`
Expand All @@ -1352,8 +1352,8 @@ to confirm the semantic before designing the mitigation:

### Connector-side mitigation

`list_party_entries.includeLinkedPersons` (added v1.6.6, optional,
default `false` — preserves the pre-v1.6.6 contract bit-for-bit).
`list_party_entries.includeLinkedPersons` (added v1.7.0, optional,
default `false` — preserves the pre-v1.7.0 contract bit-for-bit).
When `true` and `partyId` is an organisation:

1. Fetch `/parties/{orgId}/people` to enumerate linked persons.
Expand All @@ -1366,7 +1366,9 @@ When `true` and `partyId` is an organisation:
is belt-and-suspenders).
4. Sort by `entryAt` descending (tie-break by `id` desc so the sort
is total).
5. Slice the caller's `(page, perPage)` window over the merged feed.
5. Slice the caller's `(page, perPage)` window over the merged feed,
preserving `nextPage` when the merged candidate set or an upstream
`Link rel=next` says older entries remain.

When `partyId` is a person, `includeLinkedPersons: true` is a no-op:
the `/people` lookup returns empty (probe 5), and the connector
Expand All @@ -1377,7 +1379,7 @@ default-on in callers without conditional logic.

- [`src/tools/entries.ts`](src/tools/entries.ts) `listPartyEntries`
— the `includeLinkedPersons` branch + `fanOutPartyEntries` helper.
- [`scripts/wire-trace-v166.ts`](scripts/wire-trace-v166.ts) — the
- [`scripts/wire-trace-v170.ts`](scripts/wire-trace-v170.ts) — the
re-runnable probe harness.

---
Expand Down
17 changes: 8 additions & 9 deletions scripts/wire-trace-v166.ts → scripts/wire-trace-v170.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Wire-trace probes for the v1.6.6 candidate:
* Wire-trace probes for the v1.7.0 candidate:
* `list_party_entries.includeLinkedPersons` (org-timeline-includes-people).
*
* Question the workflow wants answered: when a user asks "what's the
Expand Down Expand Up @@ -30,12 +30,12 @@
* person parties have no linked-people relationship in the data
* model. Confirms how the API responds.
*
* Pattern mirrors scripts/wire-trace-v164.ts and -v165.ts: ZZZ-V166-*
* Pattern mirrors scripts/wire-trace-v164.ts and -v165.ts: ZZZ-V170-*
* labelled test records, full cleanup on exit, no tenant-specific
* strings or IDs committed (everything discovered at runtime). Run
* with:
*
* CAPSULE_API_TOKEN=<write-scoped> npx tsx scripts/wire-trace-v166.ts
* CAPSULE_API_TOKEN=<write-scoped> npx tsx scripts/wire-trace-v170.ts
*/

import { fetch } from "undici";
Expand Down Expand Up @@ -84,7 +84,7 @@ function partiesOf(result: ApiResult): Array<{ id: number; type?: string }> {
}

async function main() {
const tag = `ZZZ-V166-${Date.now()}`;
const tag = `ZZZ-V170-${Date.now()}`;
const createdParties: number[] = [];
const createdEntries: number[] = [];

Expand Down Expand Up @@ -187,11 +187,10 @@ async function main() {
console.log(" → does Capsule file once (under participants array) or twice?");
console.log("=========================================");

// Capsule allows entries to have multiple participants via the
// `parties` array on POST /entries. Probe whether such an entry
// surfaces in both per-party endpoint lists and whether the id
// is the same (single entry, multiple references) or different
// (one filing per participant).
// Probe whether Capsule accepts a multi-party `parties` array on
// POST /entries. The live v1.7.0-candidate run rejected this shape with
// 422, which is the evidence behind the connector's single-row
// filing assumption.
const sharedNote = await call("POST", "/entries", {
entry: {
parties: [{ id: orgId }, { id: personId }],
Expand Down
82 changes: 62 additions & 20 deletions src/tools/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ export const listPartyEntriesSchema = z.object({
.describe(
"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` — 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 — 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. " +
"WHY THIS FLAG EXISTS: Capsule's API files each entry against exactly one party row (verified v1.7.0 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). 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. The connector fetches `perPage` entries from each party then slices the caller's window; for deep pagination (`page > 1` with a large multi-person org) the slice may be approximate — the latest entries are correct, but ordering deeper into the feed can lose entries from parties with many records. For LLM-driven 'what's the latest' queries (the typical use), this is invisible.",
"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. The connector fetches enough entries from each party to cover the requested merged window (up to Capsule's per-party cap of 100) then slices the caller's window; very deep pagination with a large multi-person org can still be approximate. For LLM-driven 'what's the latest' queries (the typical use), this is invisible.",
),
});

interface PartyEntriesPage {
entries: unknown[];
nextPage: number | undefined;
}

/**
* Fetch `entries` arrays for multiple party ids in parallel,
* concurrency-capped by `getBatchConcurrency()`. Throws on the first
Expand All @@ -40,22 +45,25 @@ async function fanOutPartyEntries(
partyIds: number[],
embed: string | undefined,
perPage: number,
): Promise<unknown[][]> {
): Promise<PartyEntriesPage[]> {
const concurrency = getBatchConcurrency();
const results: unknown[][] = new Array(partyIds.length);
const results: PartyEntriesPage[] = new Array(partyIds.length);
let cursor = 0;
async function worker(): Promise<void> {
while (true) {
const i = cursor;
cursor += 1;
if (i >= partyIds.length) return;
const id = partyIds[i]!;
const { data } = await capsuleGet<{ entries: unknown[] }>(`/parties/${id}/entries`, {
embed,
page: 1,
perPage,
});
results[i] = data.entries;
const { data, nextPage } = await capsuleGet<{ entries: unknown[] }>(
`/parties/${id}/entries`,
{
embed,
page: 1,
perPage,
},
);
results[i] = { entries: data.entries, nextPage };
}
}
const workers: Promise<void>[] = [];
Expand All @@ -66,11 +74,35 @@ async function fanOutPartyEntries(
return results;
}

function mergedTimelineCandidatePerParty(page: number, perPage: number): number {
return Math.min(page * perPage, 100);
}

function mergedTimelineNextPage(
page: number,
perPage: number,
mergedLength: number,
upstreamHasNextPage: boolean,
): number | undefined {
const requestedWindowEnd = page * perPage;
if (mergedLength > requestedWindowEnd) return page + 1;

// When the requested window fits within the per-party fetch cap,
// an upstream Link rel=next means there are older entries beyond
// our candidate set even if the merged slice length is exactly
// `perPage`. Preserve that signal instead of falsely ending the
// merged feed at page 1.
const coveredRequestedWindow = requestedWindowEnd <= 100;
if (coveredRequestedWindow && upstreamHasNextPage) return page + 1;

return undefined;
}

export async function listPartyEntries(input: z.infer<typeof listPartyEntriesSchema>) {
const { partyId, embed, page, perPage, includeLinkedPersons } = input;

// Fast path: default behaviour, single GET — preserves the
// pre-v1.6.6 contract bit-for-bit.
// pre-v1.7.0 contract bit-for-bit.
if (!includeLinkedPersons) {
const { data, nextPage } = await capsuleGet<{ entries: unknown[] }>(
`/parties/${partyId}/entries`,
Expand All @@ -81,7 +113,8 @@ export async function listPartyEntries(input: z.infer<typeof listPartyEntriesSch

// Enumerate linked persons. perPage capped at 100 (Capsule's max);
// tenants with >100 linked persons on a single org see partial
// coverage. Documented in the schema description.
// coverage. The schema description calls this out and points to
// list_employees for explicit linked-person pagination.
const { data: peopleData } = await capsuleGet<{ parties?: { id: number }[] }>(
`/parties/${partyId}/people`,
{ page: 1, perPage: 100 },
Expand All @@ -99,20 +132,24 @@ export async function listPartyEntries(input: z.infer<typeof listPartyEntriesSch
}

// Fan out: org's own entries + each linked person's entries.
// Fetch `perPage` from each — enough for page=1 to be exact; deeper
// pages slice from the same candidate pool (documented approximation).
// Fetch enough from each party to cover the requested merged window
// when possible (Capsule caps perPage at 100).
const targetIds = [partyId, ...peopleIds];
const perPartyEntries = await fanOutPartyEntries(targetIds, embed, perPage);
const perPartyPages = await fanOutPartyEntries(
targetIds,
embed,
mergedTimelineCandidatePerParty(page, perPage),
);

// Merge with dedup. Capsule's API files each entry against exactly
// one party (v1.6.6 wire-trace probe 4 — POST rejects multi-party),
// one party (v1.7.0 wire-trace probe 4 — POST rejects multi-party),
// so naive concat is correctness-safe; the `Set<id>` dedup is
// defensive against captured-email SMTP routing rules we can't
// simulate in the probe and against any future API change.
const seen = new Set<number>();
const merged: Array<{ id: number; entryAt?: string }> = [];
for (const arr of perPartyEntries) {
for (const raw of arr) {
for (const { entries } of perPartyPages) {
for (const raw of entries) {
const e = raw as { id: number; entryAt?: string };
if (typeof e?.id !== "number") continue;
if (seen.has(e.id)) continue;
Expand All @@ -134,7 +171,12 @@ export async function listPartyEntries(input: z.infer<typeof listPartyEntriesSch
// Apply caller's pagination window over the merged feed.
const start = (page - 1) * perPage;
const slice = merged.slice(start, start + perPage);
const nextPage = merged.length > start + perPage ? page + 1 : undefined;
const nextPage = mergedTimelineNextPage(
page,
perPage,
merged.length,
perPartyPages.some((p) => p.nextPage !== undefined),
);

return { entries: slice, ...(nextPage !== undefined ? { nextPage } : {}) };
}
Expand Down
Loading