From eef9b57ee6077626b7583bedef1e5f374c15097d Mon Sep 17 00:00:00 2001 From: Raphael Fialho Date: Mon, 13 Jul 2026 13:01:13 -0300 Subject: [PATCH 1/2] fix(bling): paginate list_orders_with_items by summary page (fixes #219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the handler re-fetched every summary page in the date range on every call and sliced client-side, so paging a large range cost O(pages × batches) summary requests, sharing the ~3 req/sec budget with the per-order detail fetches. Fetch a single summary page per call (keyed by `page`), filter canceled within it, and fetch details only for that page — one summary request per call, O(pages) overall. Also names the canceled-status magic number (CANCELED_STATUS). Interface change: batchPage/batchSize → page; the response now returns page/pageSize/fetchedOnPage/nextPage (up to 100 orders per call). Co-Authored-By: Claude Opus 4.8 --- .../erp/bling/src/__tests__/index.test.ts | 25 +++++++ packages/erp/bling/src/index.ts | 70 ++++++++----------- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/packages/erp/bling/src/__tests__/index.test.ts b/packages/erp/bling/src/__tests__/index.test.ts index 374ba82..b73e741 100644 --- a/packages/erp/bling/src/__tests__/index.test.ts +++ b/packages/erp/bling/src/__tests__/index.test.ts @@ -46,4 +46,29 @@ describe("mcp-bling", () => { expect(url).toContain("bling.com.br/Api/v3"); expect(opts.headers.Authorization).toBe("Bearer test-token"); }); + + it("list_orders_with_items fetches only one summary page per call (no full-range re-fetch)", async () => { + mockFetch.mockImplementation((url: string) => { + const isSummary = String(url).includes("/pedidos/vendas?"); + const body = isSummary + ? { data: [ + { id: 1, numero: "A", data: "2026-01-01", total: 10, situacao: { valor: 1 } }, + { id: 2, numero: "B", data: "2026-01-02", total: 20, situacao: { valor: 1 } }, + ] } + : { data: { itens: [] } }; + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)) }); + }); + + await callToolHandler({ + params: { + name: "list_orders_with_items", + arguments: { dataInicial: "2026-01-01", dataFinal: "2026-01-31", page: 1 }, + }, + }); + + // The fix: exactly one summary request per call, not a re-pull of the whole range. + const summaryCalls = mockFetch.mock.calls.filter(([u]: any[]) => String(u).includes("/pedidos/vendas?")); + expect(summaryCalls).toHaveLength(1); + expect(String(summaryCalls[0][0])).toContain("pagina=1"); + }); }); diff --git a/packages/erp/bling/src/index.ts b/packages/erp/bling/src/index.ts index 22b1670..3cc9e0d 100644 --- a/packages/erp/bling/src/index.ts +++ b/packages/erp/bling/src/index.ts @@ -46,6 +46,9 @@ import { const ACCESS_TOKEN = process.env.BLING_ACCESS_TOKEN || ""; const BASE_URL = "https://www.bling.com.br/Api/v3"; +// Bling order `situacao.valor` code for a canceled order. +const CANCELED_STATUS = 2; + async function blingRequest(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${BASE_URL}${path}`, { method, @@ -161,15 +164,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ // ---------------- Sales orders ---------------- { name: "list_orders_with_items", - description: "Fetch sales orders with full line items (SKUs, qty, prices) for a date range. Returns batchSize orders at a time. Check hasMore and call again with nextBatchPage until hasMore=false.", + description: "Fetch sales orders with full line items (SKUs, qty, prices) for a date range, one summary page (up to 100 orders) at a time. Check hasMore and call again with nextPage until hasMore=false.", inputSchema: { type: "object", properties: { dataInicial: { type: "string", description: "Start date (YYYY-MM-DD)" }, dataFinal: { type: "string", description: "End date (YYYY-MM-DD)" }, excludeCanceled: { type: "boolean", description: "Exclude canceled orders. Default true." }, - batchPage: { type: "number", description: "Which batch to fetch (default 1). Increment until hasMore=false." }, - batchSize: { type: "number", description: "Orders per batch (default 25, max 30)." }, + page: { type: "number", description: "Which summary page to fetch (default 1, 100 orders/page). Increment until hasMore=false." }, }, required: ["dataInicial", "dataFinal"], }, @@ -758,42 +760,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // ---- Sales orders ---- case "list_orders_with_items": { - // Fetches a chunk of order details (batchPage * batchSize orders at a time). - // Call repeatedly incrementing batchPage until hasMore=false. + // Fetches ONE summary page (up to 100 orders) and the line-item details + // for the orders on it. Call repeatedly incrementing `page` until + // hasMore=false — each call costs a single summary request, so paging a + // large range is O(pages), not O(pages × batches). const excludeCanceled = a.excludeCanceled !== false; - const batchPage = (a.batchPage as number) || 1; - const batchSize = (a.batchSize as number) || 25; - const DELAY = 700; // ms between pairs — stays under 3 req/sec - - // 1. Get all order summaries (paginated list, fast) - const allOrders: any[] = []; - let page = 1; - while (true) { - const p = new URLSearchParams(); - p.set("pagina", String(page)); - p.set("limite", "100"); - if (a.dataInicial) p.set("dataInicial", String(a.dataInicial)); - if (a.dataFinal) p.set("dataFinal", String(a.dataFinal)); - const res: any = await blingRequest("GET", `/pedidos/vendas?${p}`); - const items: any[] = res?.data ?? []; - if (!items.length) break; - allOrders.push(...items); - if (items.length < 100) break; - page++; - await new Promise(r => setTimeout(r, 400)); - } + const page = (a.page as number) || 1; + const PAGE_SIZE = 100; // Bling summary page size + const DELAY = 700; // ms between detail pairs — stays under 3 req/sec - const orders = excludeCanceled - ? allOrders.filter((o: any) => o?.situacao?.valor !== 2) - : allOrders; + // 1. Fetch just this page of order summaries (not the whole range). + const p = new URLSearchParams(); + p.set("pagina", String(page)); + p.set("limite", String(PAGE_SIZE)); + if (a.dataInicial) p.set("dataInicial", String(a.dataInicial)); + if (a.dataFinal) p.set("dataFinal", String(a.dataFinal)); + const res: any = await blingRequest("GET", `/pedidos/vendas?${p}`); + const summaries: any[] = res?.data ?? []; + // A full page implies there may be another; a short page is the last one. + const hasMore = summaries.length === PAGE_SIZE; - const totalOrders = orders.length; - const start = (batchPage - 1) * batchSize; - const end = Math.min(start + batchSize, totalOrders); - const chunk = orders.slice(start, end); - const hasMore = end < totalOrders; + const chunk = excludeCanceled + ? summaries.filter((o: any) => o?.situacao?.valor !== CANCELED_STATUS) + : summaries; - // 2. Fetch details for this chunk (2 at a time) + // 2. Fetch details for this page (2 at a time to stay under the rate limit). const results: any[] = []; for (let i = 0; i < chunk.length; i += 2) { const pair = chunk.slice(i, i + 2); @@ -823,12 +814,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } return text({ - batchPage, - batchSize, - totalOrders, - fetchedRange: `${start + 1}–${end}`, + page, + pageSize: PAGE_SIZE, + fetchedOnPage: chunk.length, hasMore, - nextBatchPage: hasMore ? batchPage + 1 : null, + nextPage: hasMore ? page + 1 : null, orders: results, }); } From 69cc1039328a6fb9a2bf4969c7ce175fe54619da Mon Sep 17 00:00:00 2001 From: Raphael Fialho Date: Mon, 13 Jul 2026 13:41:53 -0300 Subject: [PATCH 2/2] fix(bling): bound per-call cost with pageSize + real regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the pagination change: - Add optional pageSize (default 25, max 100) used directly as the API `limite`, so a single cursor bounds both the summary request and the detailed set — a dense page no longer risks the MCP client timeout (default ~9s/call). Integer-coerced so a fractional value can't silently stop pagination early. - Rewrite the guard test to key on `page` (asserts pagina=N + bounded detail calls); it now fails against the pre-fix code instead of passing vacuously. - Throw when dataInicial/dataFinal are missing. Co-Authored-By: Claude Opus 4.8 --- .../erp/bling/src/__tests__/index.test.ts | 12 +++++++---- packages/erp/bling/src/index.ts | 21 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/erp/bling/src/__tests__/index.test.ts b/packages/erp/bling/src/__tests__/index.test.ts index b73e741..6fe1025 100644 --- a/packages/erp/bling/src/__tests__/index.test.ts +++ b/packages/erp/bling/src/__tests__/index.test.ts @@ -47,7 +47,7 @@ describe("mcp-bling", () => { expect(opts.headers.Authorization).toBe("Bearer test-token"); }); - it("list_orders_with_items fetches only one summary page per call (no full-range re-fetch)", async () => { + it("list_orders_with_items fetches only the requested summary page (no full-range re-fetch)", async () => { mockFetch.mockImplementation((url: string) => { const isSummary = String(url).includes("/pedidos/vendas?"); const body = isSummary @@ -62,13 +62,17 @@ describe("mcp-bling", () => { await callToolHandler({ params: { name: "list_orders_with_items", - arguments: { dataInicial: "2026-01-01", dataFinal: "2026-01-31", page: 1 }, + arguments: { dataInicial: "2026-01-01", dataFinal: "2026-01-31", page: 3 }, }, }); - // The fix: exactly one summary request per call, not a re-pull of the whole range. const summaryCalls = mockFetch.mock.calls.filter(([u]: any[]) => String(u).includes("/pedidos/vendas?")); + const detailCalls = mockFetch.mock.calls.filter(([u]: any[]) => /\/pedidos\/vendas\/\d+/.test(String(u))); + // Exactly one summary request, keyed directly by `page` (pagina=3). The old + // code always started its summary walk at pagina=1, so this fails on it. expect(summaryCalls).toHaveLength(1); - expect(String(summaryCalls[0][0])).toContain("pagina=1"); + expect(String(summaryCalls[0][0])).toContain("pagina=3"); + // Details fetched only for the two orders on that page — nothing re-pulled. + expect(detailCalls).toHaveLength(2); }); }); diff --git a/packages/erp/bling/src/index.ts b/packages/erp/bling/src/index.ts index 3cc9e0d..0bc3314 100644 --- a/packages/erp/bling/src/index.ts +++ b/packages/erp/bling/src/index.ts @@ -171,7 +171,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ dataInicial: { type: "string", description: "Start date (YYYY-MM-DD)" }, dataFinal: { type: "string", description: "End date (YYYY-MM-DD)" }, excludeCanceled: { type: "boolean", description: "Exclude canceled orders. Default true." }, - page: { type: "number", description: "Which summary page to fetch (default 1, 100 orders/page). Increment until hasMore=false." }, + page: { type: "number", description: "Which summary page to fetch (default 1). Increment until hasMore=false." }, + pageSize: { type: "number", description: "Orders fetched (and detailed) per call (default 25, max 100). Lower it if a client times out on dense pages." }, }, required: ["dataInicial", "dataFinal"], }, @@ -764,21 +765,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // for the orders on it. Call repeatedly incrementing `page` until // hasMore=false — each call costs a single summary request, so paging a // large range is O(pages), not O(pages × batches). + if (!a.dataInicial || !a.dataFinal) { + throw new Error("dataInicial and dataFinal are required (YYYY-MM-DD)."); + } const excludeCanceled = a.excludeCanceled !== false; const page = (a.page as number) || 1; - const PAGE_SIZE = 100; // Bling summary page size + // Orders fetched (and detailed) per call. Bounds per-call latency so a + // dense page can't blow the MCP client timeout: the detail phase runs + // 2-at-a-time with a 700ms gap, so ~25 orders ≈ 9s, 100 ≈ 35s. + const pageSize = Math.min(Math.max(Math.trunc(Number(a.pageSize)) || 25, 1), 100); const DELAY = 700; // ms between detail pairs — stays under 3 req/sec // 1. Fetch just this page of order summaries (not the whole range). const p = new URLSearchParams(); p.set("pagina", String(page)); - p.set("limite", String(PAGE_SIZE)); - if (a.dataInicial) p.set("dataInicial", String(a.dataInicial)); - if (a.dataFinal) p.set("dataFinal", String(a.dataFinal)); + p.set("limite", String(pageSize)); + p.set("dataInicial", String(a.dataInicial)); + p.set("dataFinal", String(a.dataFinal)); const res: any = await blingRequest("GET", `/pedidos/vendas?${p}`); const summaries: any[] = res?.data ?? []; // A full page implies there may be another; a short page is the last one. - const hasMore = summaries.length === PAGE_SIZE; + const hasMore = summaries.length === pageSize; const chunk = excludeCanceled ? summaries.filter((o: any) => o?.situacao?.valor !== CANCELED_STATUS) @@ -815,7 +822,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return text({ page, - pageSize: PAGE_SIZE, + pageSize, fetchedOnPage: chunk.length, hasMore, nextPage: hasMore ? page + 1 : null,