diff --git a/docs/design/api-v1-redesign.md b/docs/design/api-v1-redesign.md index 231073a..fd2a3a7 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/internal/httpapi/conversations.go b/internal/httpapi/conversations.go index a596208..fe7dc92 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/README.md b/mcp/README.md index aa30d0e..b19cc7e 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 1eb1b38..b0f80a7 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 3de0113..8065c84 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 adb02e4..06c78e7 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 b724321..e6e86d2 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` ONLY when more pages remain — when it is absent, you have reached the last page; STOP (do not keep calling).", + ), + 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 a3300da..f1dd36e 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 3cba417..47fee95 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,17 @@ describe("MCP events tools", () => { expect(stub.listEvents).toHaveBeenCalledOnce(); }); - it("forwards all filter params to the wrapper (page_size → limit)", async () => { + 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({ name: "list_events", @@ -95,12 +108,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 +120,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 5621eae..e47d342 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 65e288d..df31b8b 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,24 @@ describe("e2a MCP server", () => { ); }); - it("list_conversations forwards args to client.listConversations", async () => { + 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", - 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,15 +427,37 @@ 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 () => { diff --git a/sdks/typescript/src/v1/pagination.ts b/sdks/typescript/src/v1/pagination.ts index 8e09954..7c6dcc5 100644 --- a/sdks/typescript/src/v1/pagination.ts +++ b/sdks/typescript/src/v1/pagination.ts @@ -58,6 +58,22 @@ 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); + // 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 * 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 3328f4b..9b8c4a0 100644 --- a/sdks/typescript/test/v1/pagination.test.ts +++ b/sdks/typescript/test/v1/pagination.test.ts @@ -24,6 +24,32 @@ 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("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/);