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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ versions adhere to [Semantic Versioning](https://semver.org).
"≤10 ids → single GET, else split into 10-id chunks, fan out in
parallel, concatenate" block; they're now one-line delegations.
No behavioural or API change — the "Capsule caps multi-id GET at
10" rule now lives in one place. Covered by the existing
per-handler batch tests (incl. the >10-id chunking case).
10" rule now lives in one place. Covered by per-handler batch tests,
including the >10-id chunking path for all four callers.

### Fixed

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
```

540 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:
543 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
5 changes: 3 additions & 2 deletions src/tools/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export async function getTask(input: z.infer<typeof getTaskSchema>) {

// ───────────────────────────────────────────────────────────────────────────
//
// Batch fetch up to 10 tasks by id in a single call. Capsule's path
// syntax: GET /tasks/<id1>,<id2>,... — the server caps at 10 per call.
// Batch fetch up to 50 tasks by id. Capsule's path syntax:
// GET /tasks/<id1>,<id2>,... caps at 10 ids per request, so larger
// caller batches are split into 10-id chunks and merged.

export const getTasksSchema = z.object({
ids: z
Expand Down
18 changes: 18 additions & 0 deletions tests/opportunities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,4 +409,22 @@ describe("getOpportunities (batch)", () => {
const [url] = vi.mocked(fetch).mock.calls[0]!;
expect(url).toMatch(/\/opportunities\/1,2,3($|\?)/);
});

it("splits >10 ids into chunks, propagates embed, and merges results", async () => {
mockFetch(200, { opportunities: Array.from({ length: 10 }, (_v, i) => ({ id: i + 1 })) });
mockFetch(200, { opportunities: [{ id: 11 }] });

const { getOpportunities } = await import("../src/tools/opportunities.js");
const ids = Array.from({ length: 11 }, (_v, i) => i + 1);
const result = (await getOpportunities({ ids, embed: "tags,fields" })) as {
opportunities: Array<{ id: number }>;
};

expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
const urls = vi.mocked(fetch).mock.calls.map((c) => String(c[0]));
expect(urls[0]).toContain("/opportunities/1,2,3,4,5,6,7,8,9,10");
expect(urls[1]).toContain("/opportunities/11");
expect(urls.every((u) => u.includes("embed=tags%2Cfields"))).toBe(true);
expect(result.opportunities.map((o) => o.id)).toEqual(ids);
});
});
18 changes: 18 additions & 0 deletions tests/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,4 +467,22 @@ describe("getProjects (batch)", () => {
expect(url).toMatch(/\/kases\/1,2($|\?)/);
expect(url).not.toContain("/projects/");
});

it("splits >10 ids into chunks, propagates embed, and merges kases", async () => {
mockFetch(200, { kases: Array.from({ length: 10 }, (_v, i) => ({ id: i + 1 })) });
mockFetch(200, { kases: [{ id: 11 }] });

const { getProjects } = await import("../src/tools/projects.js");
const ids = Array.from({ length: 11 }, (_v, i) => i + 1);
const result = (await getProjects({ ids, embed: "tags,fields" })) as {
kases: Array<{ id: number }>;
};

expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
const urls = vi.mocked(fetch).mock.calls.map((c) => String(c[0]));
expect(urls[0]).toContain("/kases/1,2,3,4,5,6,7,8,9,10");
expect(urls[1]).toContain("/kases/11");
expect(urls.every((u) => u.includes("embed=tags%2Cfields"))).toBe(true);
expect(result.kases.map((kase) => kase.id)).toEqual(ids);
});
});
15 changes: 15 additions & 0 deletions tests/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,19 @@ describe("getTasks (batch)", () => {
const [url] = vi.mocked(fetch).mock.calls[0]!;
expect(url).toMatch(/\/tasks\/1,2($|\?)/);
});

it("splits >10 ids into chunks and merges tasks", async () => {
mockFetch(200, { tasks: Array.from({ length: 10 }, (_v, i) => ({ id: i + 1 })) });
mockFetch(200, { tasks: [{ id: 11 }] });

const { getTasks } = await import("../src/tools/tasks.js");
const ids = Array.from({ length: 11 }, (_v, i) => i + 1);
const result = (await getTasks({ ids })) as { tasks: Array<{ id: number }> };

expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
const urls = vi.mocked(fetch).mock.calls.map((c) => String(c[0]));
expect(urls[0]).toContain("/tasks/1,2,3,4,5,6,7,8,9,10");
expect(urls[1]).toContain("/tasks/11");
expect(result.tasks.map((task) => task.id)).toEqual(ids);
});
});