From fedd7663d8f6f892caa7457028a4678e7736c0c1 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sat, 20 Jun 2026 22:08:33 -0700 Subject: [PATCH 1/3] feat(sdk): AutoPager.page(cursor?) single-page accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The missing primitive for caller-driven cursor pagination: pass the prior page's next_cursor (omit for the first page) → get { items, next_cursor }. Normalizes a null/empty next_cursor to undefined (= last page) and treats an empty-string cursor as the first page. Existing iteration / toArray / forEach unchanged. This is what the MCP list tools need to expose the §6a #3 `cursor`+`limit` in, `next_cursor` out shape (next slice), and is useful to SDK users who want manual paging. SDK 95 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- sdks/typescript/src/v1/pagination.ts | 12 ++++++++++++ sdks/typescript/test/v1/pagination.test.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/sdks/typescript/src/v1/pagination.ts b/sdks/typescript/src/v1/pagination.ts index 8e099544..b21eaa49 100644 --- a/sdks/typescript/src/v1/pagination.ts +++ b/sdks/typescript/src/v1/pagination.ts @@ -58,6 +58,18 @@ export class AutoPager implements AsyncIterable { } } + /** Fetch a SINGLE page for caller-driven pagination: pass the previous + * page's `next_cursor` (omit for the first page) and get back `{ items, + * next_cursor }`. A null/undefined/empty `next_cursor` in the result means + * there are no more pages. This is the primitive behind the cursor+limit + * shape the MCP tools expose (§6a #3) and is available to SDK users who want + * manual paging instead of `for await` / `toArray`. The page size is governed + * by the `limit` baked into the list call that produced this pager. */ + async page(cursor?: string): Promise> { + const p = await this.fetchPage(cursor || undefined); + return { items: p.items ?? [], next_cursor: p.next_cursor ?? undefined }; + } + /** Collect up to `limit` items. The limit is required — it caps memory for * an inbox that could page indefinitely. */ async toArray(opts: { limit: number }): Promise { diff --git a/sdks/typescript/test/v1/pagination.test.ts b/sdks/typescript/test/v1/pagination.test.ts index 3328f4b5..e1cccec5 100644 --- a/sdks/typescript/test/v1/pagination.test.ts +++ b/sdks/typescript/test/v1/pagination.test.ts @@ -24,6 +24,24 @@ describe("AutoPager", () => { expect(await pager.toArray({ limit: 3 })).toEqual([1, 2, 3]); }); + it("page() fetches ONE page and surfaces next_cursor for caller-driven paging", async () => { + const pager = new AutoPager(pages()); + const p1 = await pager.page(); // first page (no cursor) + expect(p1.items).toEqual([1, 2]); + expect(p1.next_cursor).toBe("c2"); + const p2 = await pager.page(p1.next_cursor!); // follow the cursor + expect(p2.items).toEqual([3, 4]); + expect(p2.next_cursor).toBe("c3"); + const p3 = await pager.page(p2.next_cursor!); + expect(p3.items).toEqual([5]); + expect(p3.next_cursor).toBeUndefined(); // null → normalized to undefined = last page + }); + + it("page() treats an empty-string cursor as the first page", async () => { + const pager = new AutoPager(pages()); + expect((await pager.page("")).items).toEqual([1, 2]); + }); + it("toArray requires a positive limit", async () => { const pager = new AutoPager(pages()); await expect(pager.toArray({ limit: 0 })).rejects.toThrow(/positive limit/); From a8ac67548eb46c859055b345b41cc7948e781aaf Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sat, 20 Jun 2026 22:21:37 -0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(mcp):=20cursor=20pagination=20shape=20?= =?UTF-8?q?for=20list=20tools=20(=C2=A76a=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cursor-paginated list tools now expose ONE shape — `cursor` + `limit` in, `{ , next_cursor }` out (one page; pass next_cursor back for the next). Replaces the prior mix where `token` was accepted-but-ignored and `page_size` was a total cap that `.toArray()` walked internally — which meant an agent could never page past the cap, and `token` was a lie. - list_messages, list_conversations, list_events: schemas drop token/page_size → shared `paginationInput` (cursor+limit, in util.ts); wrapper methods return Page via the new AutoPager.page(cursor); handlers return { items, next_cursor } (next_cursor omitted on the last page). - Unchanged by design: small fixed lists (list_agents/list_domains/list_webhooks) stay non-paginated (decision 7); list_webhook_deliveries is single-page `limit` (the API has no cursor); list_pending_messages stays a cross-agent aggregate. Tests: next_cursor surfaced / omitted-on-last-page; cursor+limit forwarded; stubs updated to Page shape; over-the-wire cursor round-trip (page1→cursor→page2) across the real Streamable-HTTP transport. MCP 132 + SDK 95 green. Docs: §6a banner #3 → done; mcp/README list_messages row (cursor+limit; also fixed the pre-existing status→read_status drift in that line). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/api-v1-redesign.md | 9 ++++-- mcp/README.md | 2 +- mcp/src/client.ts | 28 +++++++++------- mcp/src/tools/events.ts | 23 ++++++------- mcp/src/tools/messages.ts | 40 +++++++++++------------ mcp/src/tools/util.ts | 20 ++++++++++++ mcp/tests/events-streamable-http.test.ts | 23 +++++++------ mcp/tests/events.test.ts | 34 +++++++++++--------- mcp/tests/http.test.ts | 41 ++++++++++++++++++++++-- mcp/tests/tools.test.ts | 37 +++++++++++++++++---- 10 files changed, 174 insertions(+), 83 deletions(-) diff --git a/docs/design/api-v1-redesign.md b/docs/design/api-v1-redesign.md index 231073af..fd2a3a7c 100644 --- a/docs/design/api-v1-redesign.md +++ b/docs/design/api-v1-redesign.md @@ -969,8 +969,13 @@ review diligence — a #206-style omission can't merge. > root cause is adding `default: ErrorEnvelope` to the send/reply/forward Huma > handlers (then regen) so every generated client parses it. **#5 attachment > download-URL, #7 idempotency-key on create tools — still PENDING** (not in -> GA). #3 (one pagination shape) is partial: the SDK AutoPager normalizes -> cursors, but MCP tool schemas still expose `token`/`page_size`. +> GA). **#3 (one pagination shape) ✅ done:** the cursor-paginated list tools +> (`list_messages`, `list_conversations`, `list_events`) now take `cursor` + +> `limit` and return `{ , next_cursor }` (one page; pass `next_cursor` +> back for the next) — the dead `token`/`page_size` are gone. Built on a new +> `AutoPager.page(cursor?)` SDK primitive. (Small fixed lists — `list_agents`/ +> `list_domains`/`list_webhooks` — stay non-paginated per decision 7; +> `list_webhook_deliveries` is single-page `limit` since the API has no cursor.) > > Scope-gating note: gating is a decision-space/UX optimization, not the > security boundary — the backend enforces scope per-handler (the OAuth/WS diff --git a/mcp/README.md b/mcp/README.md index aa30d0ef..b19cc7e6 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -149,7 +149,7 @@ shows the set your scope allows, with per-tool descriptions. | --- | --- | | `send_email` | Send a new email. When the agent has HITL enabled, the message is held and returns `status: pending_approval` instead of `sent`. | | `reply_to_message` | Reply to an inbound message. Preserves In-Reply-To / References for thread continuity. | -| `list_messages` | List inbound mail. Filter by `status` (unread / read / all), paginate with `page_size` + `token`. | +| `list_messages` | List inbound mail. Filter by `read_status` (unread / read / all); cursor-paginated (`cursor` + `limit` in, `next_cursor` out). | | `get_message` | Fetch full body, headers, and attachment metadata for one message. | ### Human-in-the-loop approval diff --git a/mcp/src/client.ts b/mcp/src/client.ts index 1eb1b38f..b0f80a7a 100644 --- a/mcp/src/client.ts +++ b/mcp/src/client.ts @@ -23,6 +23,7 @@ import type { CreateWebhookRequest, UpdateWebhookRequest, TestWebhookRequest, + Page, } from "@e2a/sdk/v1"; import type { McpConfig } from "./config.js"; import type { Scope } from "./tools/tiers.js"; @@ -211,7 +212,9 @@ export class McpClient { return this.sdk.messages.get(this.resolveAddress(explicitAddress), messageId); } - async listMessages(params: { + // Cursor pagination (§6a #3): returns ONE page + next_cursor. `limit` is the + // page size; pass a prior response's next_cursor as `cursor` for the next page. + listMessages(params: { readStatus?: "unread" | "read" | "all"; sort?: "asc" | "desc"; from?: string; @@ -220,22 +223,22 @@ export class McpClient { since?: string; until?: string; labels?: Array; + cursor?: string; limit?: number; explicitAddress?: string; - }): Promise { - const { explicitAddress, limit, ...rest } = params; - return this.sdk.messages - .list(this.resolveAddress(explicitAddress), rest) - .toArray({ limit: limit ?? DEFAULT_LIST_LIMIT }); + }): Promise> { + const { explicitAddress, cursor, ...rest } = params; + return this.sdk.messages.list(this.resolveAddress(explicitAddress), rest).page(cursor); } // ── Conversations ─────────────────────────────────────────────── listConversations( - params: { since?: string; until?: string; limit?: number }, + params: { since?: string; until?: string; cursor?: string; limit?: number }, explicitAddress?: string, - ): Promise { - return this.sdk.conversations.list(this.resolveAddress(explicitAddress), params).toArray({ limit: params.limit ?? 200 }); + ): Promise> { + const { cursor, ...rest } = params; + return this.sdk.conversations.list(this.resolveAddress(explicitAddress), rest).page(cursor); } getConversation( @@ -409,10 +412,11 @@ export class McpClient { messageId?: string; since?: string; until?: string; + cursor?: string; limit?: number; - }): Promise { - const { limit, ...rest } = params; - return this.sdk.events.list(rest).toArray({ limit: limit ?? DEFAULT_LIST_LIMIT }); + }): Promise> { + const { cursor, ...rest } = params; + return this.sdk.events.list(rest).page(cursor); } getEvent(id: string): Promise { diff --git a/mcp/src/tools/events.ts b/mcp/src/tools/events.ts index 3de0113b..8065c844 100644 --- a/mcp/src/tools/events.ts +++ b/mcp/src/tools/events.ts @@ -1,7 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpClient } from "../client.js"; import { z } from "zod"; -import { runTool, strictInputSchema } from "./util.js"; +import { runTool, strictInputSchema, paginationInput } from "./util.js"; // Slice 8: MCP tool surfaces for the customer-facing events API. // list_events — paginated listing with filters @@ -19,7 +19,7 @@ export function registerEventTools(server: McpServer, client: McpClient): void { title: "List webhook events", annotations: { readOnlyHint: true }, description: - "List the durable webhook event log in reverse-chronological order. Useful for reconciliation (\"did our webhook receiver see this event?\") and for debugging delivery state. Events past the 30-day retention boundary are not returned. Cursor-paginated via `token` / `next_token` — pass the previous response's `next_token` to walk further back. Returns each event's `data` payload plus a `delivery_status` summary of how many subscribers have received it.", + "List the durable webhook event log in reverse-chronological order. Useful for reconciliation (\"did our webhook receiver see this event?\") and for debugging delivery state. Events past the 30-day retention boundary are not returned. **Cursor-paginated:** returns one page in `events` plus a `next_cursor` when more remain — pass it back as `cursor` to walk further back. Returns each event's `data` payload plus a `delivery_status` summary of how many subscribers have received it.", inputSchema: strictInputSchema({ type: z .string() @@ -35,17 +35,12 @@ export function registerEventTools(server: McpServer, client: McpClient): void { .optional() .describe("RFC3339 timestamp; returns events with `created_at >= since`."), until: z.string().optional().describe("RFC3339; returns events with `created_at < until`."), - page_size: z.number().int().min(1).max(100).optional(), - token: z.string().optional().describe("Opaque cursor from a previous response's `next_token`."), + ...paginationInput, }), }, async (args) => - // The v1 client auto-paginates; we collect up to `page_size` rows - // (the SDK walks cursors internally). `token` is accepted in the - // schema for contract stability but no longer needed — the pager - // handles cursoring transparently. - runTool(async () => ({ - events: await client.listEvents({ + runTool(async () => { + const page = await client.listEvents({ ...(args.type !== undefined ? { type: args.type } : {}), ...(args.agent_id !== undefined ? { agentId: args.agent_id } : {}), ...(args.conversation_id !== undefined @@ -54,9 +49,11 @@ export function registerEventTools(server: McpServer, client: McpClient): void { ...(args.message_id !== undefined ? { messageId: args.message_id } : {}), ...(args.since !== undefined ? { since: args.since } : {}), ...(args.until !== undefined ? { until: args.until } : {}), - ...(args.page_size !== undefined ? { limit: args.page_size } : {}), - }), - })), + ...(args.cursor !== undefined ? { cursor: args.cursor } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), + }); + return { events: page.items, ...(page.next_cursor ? { next_cursor: page.next_cursor } : {}) }; + }), ); server.registerTool( diff --git a/mcp/src/tools/messages.ts b/mcp/src/tools/messages.ts index adb02e45..06c78e7a 100644 --- a/mcp/src/tools/messages.ts +++ b/mcp/src/tools/messages.ts @@ -3,7 +3,7 @@ import { simpleParser } from "mailparser"; import type { McpClient, SendOpts } from "../client.js"; import type { MessageView } from "@e2a/sdk/v1"; import { z } from "zod"; -import { runTool, strictInputSchema } from "./util.js"; +import { runTool, strictInputSchema, paginationInput } from "./util.js"; import { attachmentsArraySchema, type AttachmentInput } from "./attachments.js"; // Map the snake_case attachment wire shape (filename, content_type, data) @@ -264,9 +264,9 @@ export function registerMessageTools(server: McpServer, client: McpClient): void title: "List conversations for the agent", annotations: { readOnlyHint: true }, description: - "Lists the agent's conversations — groups of messages sharing a `conversation_id` — one row per conversation, sorted by most recent activity. Each row carries `message_count`, `inbound_count`, `outbound_count`, `has_unread`, and the latest message's subject + sender so you can render an inbox without drilling into each thread. The server caps the response at 100. Use this when the user wants to see threads rather than individual messages — e.g. \"what conversations are unread?\" or \"show recent threads with Alice\". To read a single conversation's messages, call `get_conversation`.", + "Lists the agent's conversations — groups of messages sharing a `conversation_id` — one row per conversation, sorted by most recent activity. Each row carries `message_count`, `inbound_count`, `outbound_count`, `has_unread`, and the latest message's subject + sender so you can render an inbox without drilling into each thread. **Cursor-paginated:** returns one page in `conversations` plus a `next_cursor` when more remain — pass it back as `cursor` for the next page. To read a single conversation's messages, call `get_conversation`.", inputSchema: strictInputSchema({ - page_size: z.number().int().positive().max(100).optional(), + ...paginationInput, since: z .string() .optional() @@ -283,16 +283,18 @@ export function registerMessageTools(server: McpServer, client: McpClient): void }), }, async (args) => - runTool(async () => ({ - conversations: await client.listConversations( + runTool(async () => { + const page = await client.listConversations( { - ...(args.page_size !== undefined ? { limit: args.page_size } : {}), + ...(args.cursor !== undefined ? { cursor: args.cursor } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.since !== undefined ? { since: args.since } : {}), ...(args.until !== undefined ? { until: args.until } : {}), }, args.email, - ), - })), + ); + return { conversations: page.items, ...(page.next_cursor ? { next_cursor: page.next_cursor } : {}) }; + }), ); server.registerTool( @@ -317,15 +319,15 @@ export function registerMessageTools(server: McpServer, client: McpClient): void title: "List inbound messages", annotations: { readOnlyHint: true }, description: - "List messages the agent has received, newest first by default. Filter by `read_status` (unread/read/all; default unread) and cap results with `page_size`. Pass `sort: \"asc\"` for FIFO order (oldest unread first) when the caller wants to drain the inbox in arrival order. **Search filters** (`from`, `subject_contains`, `conversation_id`, `since`, `until`) narrow the result set server-side — use them instead of paginating the full inbox client-side. Returns summaries only — use `get_message` for the full body.", + "List messages the agent has received, newest first by default. Filter by `read_status` (unread/read/all; default unread). **Cursor-paginated:** returns one page in `messages` plus a `next_cursor` when more remain — pass it back as `cursor` for the next page (keep the same filters + sort). Pass `sort: \"asc\"` for FIFO order (oldest unread first) to drain the inbox in arrival order. **Search filters** (`from`, `subject_contains`, `conversation_id`, `since`, `until`) narrow server-side — use them instead of paging the whole inbox. Returns summaries only — use `get_message` for the full body.", inputSchema: strictInputSchema({ read_status: z.enum(["unread", "read", "all"]).optional(), - page_size: z.number().int().positive().max(100).optional(), + ...paginationInput, sort: z .enum(["asc", "desc"]) .optional() .describe( - "Sort order by created_at. Defaults to `desc` (newest first). Pass `asc` for FIFO polling — drain the inbox in arrival order. Switching sort mid-pagination rejects the existing token.", + "Sort order by created_at. Defaults to `desc` (newest first). Pass `asc` for FIFO polling — drain the inbox in arrival order. Switching sort mid-pagination rejects the existing cursor.", ), from: z .string() @@ -364,18 +366,15 @@ export function registerMessageTools(server: McpServer, client: McpClient): void .describe( "AND-match filter on labels. A row is returned only if ALL given labels are present. Use lowercase strings matching `[a-z0-9:_-]+`; `e2a:*` system labels can be filtered even though setting them is server-only.", ), - token: z.string().optional().describe("Pagination token from a previous response."), email: z.string().optional(), }), }, async (args) => - // The v1 client auto-paginates; `token` is accepted in the schema - // for contract stability but unused — the pager walks cursors - // internally up to `page_size` rows. - runTool(async () => ({ - messages: await client.listMessages({ + runTool(async () => { + const page = await client.listMessages({ ...(args.read_status !== undefined ? { readStatus: args.read_status } : {}), - ...(args.page_size !== undefined ? { limit: args.page_size } : {}), + ...(args.cursor !== undefined ? { cursor: args.cursor } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.sort !== undefined ? { sort: args.sort } : {}), ...(args.from !== undefined ? { from: args.from } : {}), ...(args.subject_contains !== undefined @@ -388,8 +387,9 @@ export function registerMessageTools(server: McpServer, client: McpClient): void ...(args.until !== undefined ? { until: args.until } : {}), ...(args.labels !== undefined ? { labels: args.labels } : {}), ...(args.email !== undefined ? { explicitAddress: args.email } : {}), - }), - })), + }); + return { messages: page.items, ...(page.next_cursor ? { next_cursor: page.next_cursor } : {}) }; + }), ); server.registerTool( diff --git a/mcp/src/tools/util.ts b/mcp/src/tools/util.ts index b7243213..35645ebc 100644 --- a/mcp/src/tools/util.ts +++ b/mcp/src/tools/util.ts @@ -15,6 +15,26 @@ export function strictInputSchema(shape: S) { return z.object(shape).strict(); } +// paginationInput is the ONE pagination shape for every cursor-paginated list +// tool (§6a #3): `cursor` + `limit` in, and the tool returns `{ , +// next_cursor }`. Spread it into the tool's input schema. This replaces the old +// mix of `token` / `page_size` / bare `limit`. +export const paginationInput = { + cursor: z + .string() + .optional() + .describe( + "Pagination cursor. Pass the `next_cursor` from a previous response to fetch the next page; omit for the first page. The response includes `next_cursor` when more pages remain (absent on the last page).", + ), + limit: z + .number() + .int() + .positive() + .max(100) + .optional() + .describe("Max items in this page (1–100). Defaults to a server-chosen page size."), +} as const; + export async function runTool(fn: () => Promise): Promise { try { const result = await fn(); diff --git a/mcp/tests/events-streamable-http.test.ts b/mcp/tests/events-streamable-http.test.ts index a3300daf..f1dd36e9 100644 --- a/mcp/tests/events-streamable-http.test.ts +++ b/mcp/tests/events-streamable-http.test.ts @@ -17,16 +17,19 @@ function makeStubClient(): McpClient { getMessage: vi.fn(async () => ({ messageId: "m" })), getAgent: vi.fn(async () => ({ id: "x@y", email: "x@y" })), listAgents: vi.fn(async () => [{ email: "bot@example.com" }]), - listEvents: vi.fn(async () => [ - { - id: "evt_http", - type: "email.received", - schemaVersion: 1, - createdAt: "2026-06-01T12:00:00Z", - status: "processed", - data: { from: "alice@example.com" }, - }, - ]), + listEvents: vi.fn(async () => ({ + items: [ + { + id: "evt_http", + type: "email.received", + schemaVersion: 1, + createdAt: "2026-06-01T12:00:00Z", + status: "processed", + data: { from: "alice@example.com" }, + }, + ], + next_cursor: undefined, + })), getEvent: vi.fn(async (id: string) => ({ id, type: "email.received", diff --git a/mcp/tests/events.test.ts b/mcp/tests/events.test.ts index 3cba4174..33d74529 100644 --- a/mcp/tests/events.test.ts +++ b/mcp/tests/events.test.ts @@ -14,17 +14,20 @@ function makeStubClient(): McpClient { const stub = { agentEmail: "bot@example.com", scope: "account" as const, - // Events methods on the wrapper. - listEvents: vi.fn(async (_params?: Record) => [ - { - id: "evt_abc", - type: "email.received", - schemaVersion: 1, - createdAt: "2026-06-01T12:00:00Z", - status: "processed", - data: { from: "alice@example.com" }, - }, - ]), + // Events methods on the wrapper. listEvents is cursor-paginated → Page. + listEvents: vi.fn(async (_params?: Record) => ({ + items: [ + { + id: "evt_abc", + type: "email.received", + schemaVersion: 1, + createdAt: "2026-06-01T12:00:00Z", + status: "processed", + data: { from: "alice@example.com" }, + }, + ], + next_cursor: undefined, + })), getEvent: vi.fn(async (id: string) => ({ id, type: "email.received", @@ -84,7 +87,7 @@ describe("MCP events tools", () => { expect(stub.listEvents).toHaveBeenCalledOnce(); }); - it("forwards all filter params to the wrapper (page_size → limit)", async () => { + it("forwards all filter params + cursor/limit to the wrapper", async () => { const client = await buildClient(stub); await client.callTool({ name: "list_events", @@ -95,12 +98,10 @@ describe("MCP events tools", () => { message_id: "msg_z", since: "2026-06-01T00:00:00Z", until: "2026-06-02T00:00:00Z", - page_size: 25, - token: "opaque", + limit: 25, + cursor: "c_prev", }, }); - // `token` is accepted in the schema for contract stability but the - // auto-pager handles cursoring internally, so it is not forwarded. expect(stub.listEvents).toHaveBeenCalledWith({ type: "email.received", agentId: "ag_x", @@ -109,6 +110,7 @@ describe("MCP events tools", () => { since: "2026-06-01T00:00:00Z", until: "2026-06-02T00:00:00Z", limit: 25, + cursor: "c_prev", }); }); }); diff --git a/mcp/tests/http.test.ts b/mcp/tests/http.test.ts index 5621eaea..e47d342b 100644 --- a/mcp/tests/http.test.ts +++ b/mcp/tests/http.test.ts @@ -17,7 +17,7 @@ function makeStubClient(): McpClient { getAgent: vi.fn(async (e: string) => ({ id: e, email: e })), send: vi.fn(async () => ({ messageId: "msg_sent", status: "sent" })), reply: vi.fn(async () => ({ messageId: "msg_reply", status: "sent" })), - listMessages: vi.fn(async () => []), + listMessages: vi.fn(async () => ({ items: [], next_cursor: undefined })), listAgents: vi.fn(async () => []), createAgent: vi.fn(async () => ({ email: "x@y", id: "x", domain: "y" })), listPendingMessages: vi.fn(async () => []), @@ -323,6 +323,43 @@ describe("HTTP MCP server", () => { await transport.close(); }); + it("over the wire, list_messages paginates by cursor (§6a #3)", async () => { + // E2E for the cursor shape across the real Streamable-HTTP/JSON-RPC + // transport: page 1 returns items + next_cursor; passing that cursor back + // returns page 2 with no next_cursor (last page). Proves the cursor + // round-trips over the wire, not just in-process. + await close(); + const pgStub = makeStubClient(); + pgStub.listMessages = vi.fn(async (params: { cursor?: string }) => + params?.cursor === "c2" + ? { items: [{ messageId: "m3" }], next_cursor: undefined } + : { items: [{ messageId: "m1" }, { messageId: "m2" }], next_cursor: "c2" }, + ) as McpClient["listMessages"]; + const { close: c, port } = await startHttpServer(0, { + baseUrl: "http://e2a.local", + allowedHosts: ["127.0.0.1", "localhost"], + clientFactory: () => pgStub, + }); + close = c; + url = `http://127.0.0.1:${port}/mcp`; + + const { client, transport } = await connect(); + const page1 = JSON.parse( + ((await client.callTool({ name: "list_messages", arguments: { limit: 2 } })) + .content as Array<{ text: string }>)[0].text, + ); + expect(page1.messages).toHaveLength(2); + expect(page1.next_cursor).toBe("c2"); + + const page2 = JSON.parse( + ((await client.callTool({ name: "list_messages", arguments: { cursor: page1.next_cursor } })) + .content as Array<{ text: string }>)[0].text, + ); + expect(page2.messages).toEqual([{ messageId: "m3" }]); + expect(page2).not.toHaveProperty("next_cursor"); // last page + await transport.close(); + }); + describe("session-init scope + agent resolution", () => { // buildSessionClient resolves the credential's scope and bound agent from // whoami (GET /account): agent scope pins the bound agent (whoami @@ -349,7 +386,7 @@ describe("HTTP MCP server", () => { agentAddress: opts.agentAddress, }; }), - listMessages: vi.fn(async () => []), + listMessages: vi.fn(async () => ({ items: [], next_cursor: undefined })), listAgents: vi.fn(async () => []), } as unknown as McpClient; } diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index 65e288d4..c37fc67e 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -60,9 +60,10 @@ function makeStubClient( reply: vi.fn(async () => ({ messageId: "msg_reply", status: "sent" })), forward: vi.fn(async () => ({ messageId: "msg_fwd", status: "sent" })), updateMessageLabels: vi.fn(async () => ({ messageId: "msg_in", labels: ["urgent"] })), - listConversations: vi.fn(async () => [{ conversationId: "conv_1" }]), + // Cursor-paginated lists return a Page { items, next_cursor }. + listConversations: vi.fn(async () => ({ items: [{ conversationId: "conv_1" }], next_cursor: undefined })), getConversation: vi.fn(async () => ({ conversationId: "conv_1", messages: [] })), - listMessages: vi.fn(async () => []), + listMessages: vi.fn(async () => ({ items: [], next_cursor: undefined })), listAgents: vi.fn(async () => [{ email: "bot@example.com" }]), // whoami → client.whoami() returns an AccountView (the authenticated // account identity), NOT an agent record. No default-agent resolution. @@ -396,13 +397,13 @@ describe("e2a MCP server", () => { ); }); - it("list_conversations forwards args to client.listConversations", async () => { + it("list_conversations forwards cursor/limit + filters to client.listConversations", async () => { await client.callTool({ name: "list_conversations", - arguments: { page_size: 20, since: "2026-05-01T00:00:00Z" }, + arguments: { limit: 20, cursor: "c_prev", since: "2026-05-01T00:00:00Z" }, }); expect(stub.listConversations).toHaveBeenCalledWith( - { limit: 20, since: "2026-05-01T00:00:00Z" }, + { limit: 20, cursor: "c_prev", since: "2026-05-01T00:00:00Z" }, undefined, ); }); @@ -415,17 +416,39 @@ describe("e2a MCP server", () => { expect(stub.getConversation).toHaveBeenCalledWith("conv_1", undefined); }); - it("list_messages forwards filters", async () => { + it("list_messages forwards filters + cursor/limit", async () => { await client.callTool({ name: "list_messages", - arguments: { read_status: "unread", page_size: 10 }, + arguments: { read_status: "unread", limit: 10, cursor: "c_prev" }, }); expect(stub.listMessages).toHaveBeenCalledWith({ readStatus: "unread", limit: 10, + cursor: "c_prev", }); }); + it("list_messages surfaces next_cursor when more pages remain", async () => { + (stub.listMessages as ReturnType).mockResolvedValueOnce({ + items: [{ messageId: "m1" }], + next_cursor: "c_next", + }); + const res = await client.callTool({ name: "list_messages", arguments: {} }); + const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(payload.messages).toEqual([{ messageId: "m1" }]); + expect(payload.next_cursor).toBe("c_next"); + }); + + it("list_messages omits next_cursor on the last page", async () => { + (stub.listMessages as ReturnType).mockResolvedValueOnce({ + items: [{ messageId: "m1" }], + next_cursor: undefined, + }); + const res = await client.callTool({ name: "list_messages", arguments: {} }); + const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(payload).not.toHaveProperty("next_cursor"); + }); + it("get_message uses the env agent email when omitted and returns parsed shape", async () => { const res = await client.callTool({ name: "get_message", From 1c18aeb90f6c9a40e89739c2b7b001ab29763415 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sat, 20 Jun 2026 22:29:57 -0700 Subject: [PATCH 3/3] fix(mcp,sdk): apply pagination review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review: PASS. Adversarial review: SAFE (proved distinct pages, next_cursor correctly signals done, and list_pending_messages still aggregates all pages — no one-page regression). Findings applied, each with a test: - [LOW, adversarial] AutoPager.page() used `?? undefined`, which leaks an empty-string next_cursor as a truthy "more pages" — contradicting its docstring and the iterator's `!next` termination. Normalize ""/null/undefined → undefined. Test: page() with next_cursor:"" → undefined. - [should-fix, independent] only list_messages exercised the "more pages" output branch. Added next_cursor-surfacing tests for list_conversations + list_events. - [note] caller-driven loop: strengthened the cursor description — stop when next_cursor is absent. - [FYI, pre-existing, in-theme] corrected the stale ListConversationsInput comment in conversations.go that claimed single-page / next_cursor-always-null; the handler does real keyset continuation (hasMore → EncodeCursor). Comment-only; no spec change (TestSpecGoldenNoDrift green). SDK 96 + MCP 134 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/httpapi/conversations.go | 10 +++++----- mcp/src/tools/util.ts | 2 +- mcp/tests/events.test.ts | 10 ++++++++++ mcp/tests/tools.test.ts | 11 +++++++++++ sdks/typescript/src/v1/pagination.ts | 6 +++++- sdks/typescript/test/v1/pagination.test.ts | 8 ++++++++ 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/internal/httpapi/conversations.go b/internal/httpapi/conversations.go index a596208b..fe7dc92e 100644 --- a/internal/httpapi/conversations.go +++ b/internal/httpapi/conversations.go @@ -49,11 +49,11 @@ type ConversationDetailView struct { Messages []MessageSummaryView `json:"messages" nullable:"false"` } -// ListConversationsInput — since/until + cursor/limit. Conversation cursor -// *continuation* is not yet supported by the store (it takes only a limit, -// no after-key), so next_cursor is always null — faithful to the legacy -// single-page behavior. True cursoring is a tracked follow-up needing a -// store change. +// ListConversationsInput — since/until + cursor/limit. Cursor continuation IS +// supported: the handler fetches limit+1, and when there are more it keyset- +// encodes the last row's (last_message_at, conversation_id) into next_cursor +// (see handleListConversations: hasMore → EncodeCursor(conversationsCursor{...})). +// next_cursor is null only on the last page. type ListConversationsInput struct { Address string `path:"email"` Since string `query:"since" doc:"RFC3339."` diff --git a/mcp/src/tools/util.ts b/mcp/src/tools/util.ts index 35645ebc..e6e86d26 100644 --- a/mcp/src/tools/util.ts +++ b/mcp/src/tools/util.ts @@ -24,7 +24,7 @@ export const paginationInput = { .string() .optional() .describe( - "Pagination cursor. Pass the `next_cursor` from a previous response to fetch the next page; omit for the first page. The response includes `next_cursor` when more pages remain (absent on the last page).", + "Pagination cursor. Pass the `next_cursor` from a previous response to fetch the next page; omit for the first page. The response includes `next_cursor` ONLY when more pages remain — when it is absent, you have reached the last page; STOP (do not keep calling).", ), limit: z .number() diff --git a/mcp/tests/events.test.ts b/mcp/tests/events.test.ts index 33d74529..47fee95c 100644 --- a/mcp/tests/events.test.ts +++ b/mcp/tests/events.test.ts @@ -87,6 +87,16 @@ describe("MCP events tools", () => { expect(stub.listEvents).toHaveBeenCalledOnce(); }); + it("surfaces next_cursor when more pages remain", async () => { + stub.listEvents.mockResolvedValueOnce({ items: [{ id: "evt_p1" }], next_cursor: "c_next" }); + const client = await buildClient(stub); + const parsed = parseToolResult( + await client.callTool({ name: "list_events", arguments: {} }), + ); + expect((parsed.events as Array<{ id: string }>)[0].id).toBe("evt_p1"); + expect(parsed.next_cursor).toBe("c_next"); + }); + it("forwards all filter params + cursor/limit to the wrapper", async () => { const client = await buildClient(stub); await client.callTool({ diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index c37fc67e..df31b8b1 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -397,6 +397,17 @@ describe("e2a MCP server", () => { ); }); + it("list_conversations surfaces next_cursor when more pages remain", async () => { + (stub.listConversations as ReturnType).mockResolvedValueOnce({ + items: [{ conversationId: "conv_1" }], + next_cursor: "c_next", + }); + const res = await client.callTool({ name: "list_conversations", arguments: {} }); + const payload = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(payload.conversations).toEqual([{ conversationId: "conv_1" }]); + expect(payload.next_cursor).toBe("c_next"); + }); + it("list_conversations forwards cursor/limit + filters to client.listConversations", async () => { await client.callTool({ name: "list_conversations", diff --git a/sdks/typescript/src/v1/pagination.ts b/sdks/typescript/src/v1/pagination.ts index b21eaa49..7c6dcc58 100644 --- a/sdks/typescript/src/v1/pagination.ts +++ b/sdks/typescript/src/v1/pagination.ts @@ -67,7 +67,11 @@ export class AutoPager implements AsyncIterable { * by the `limit` baked into the list call that produced this pager. */ async page(cursor?: string): Promise> { const p = await this.fetchPage(cursor || undefined); - return { items: p.items ?? [], next_cursor: p.next_cursor ?? undefined }; + // Normalize null/undefined/"" → undefined (= no more pages), matching the + // iterator's own `!next` termination and this method's docstring. A bare + // `?? undefined` would leak an empty-string cursor as a truthy "more pages". + const next = p.next_cursor; + return { items: p.items ?? [], next_cursor: next ? next : undefined }; } /** Collect up to `limit` items. The limit is required — it caps memory for diff --git a/sdks/typescript/test/v1/pagination.test.ts b/sdks/typescript/test/v1/pagination.test.ts index e1cccec5..9b8c4a0e 100644 --- a/sdks/typescript/test/v1/pagination.test.ts +++ b/sdks/typescript/test/v1/pagination.test.ts @@ -42,6 +42,14 @@ describe("AutoPager", () => { expect((await pager.page("")).items).toEqual([1, 2]); }); + it("page() normalizes an empty-string next_cursor to undefined (= last page)", async () => { + // A non-conforming server could return "" instead of null; page() must treat + // it as "no more pages" so `if (next_cursor)` / `!== undefined` both work. + const pager = new AutoPager(async () => ({ items: [9], next_cursor: "" })); + const p = await pager.page(); + expect(p.next_cursor).toBeUndefined(); + }); + it("toArray requires a positive limit", async () => { const pager = new AutoPager(pages()); await expect(pager.toArray({ limit: 0 })).rejects.toThrow(/positive limit/);