diff --git a/vtex/server/tools/custom/home-analytics.ts b/vtex/server/tools/custom/home-analytics.ts index 9913552f..416a1dca 100644 --- a/vtex/server/tools/custom/home-analytics.ts +++ b/vtex/server/tools/custom/home-analytics.ts @@ -80,6 +80,10 @@ export function buildHomeTopProductsUrl( }); } +const topViewedProductsOutputSchema = z.object({ + items: z.array(z.record(z.string(), z.unknown())), +}); + export function resolveTopViewedProductsParams(input: { startDate?: string; endDate?: string; @@ -152,12 +156,16 @@ export const getHomeTopViewedProducts = (_env: Env) => .describe("Maximum number of products to return"), timezone: analyticsTimezoneSchema, }), + outputSchema: topViewedProductsOutputSchema, execute: async ({ context, runtimeContext }) => { const env = runtimeContext.env as Env; const { accountName, appKey, appToken } = env.MESH_REQUEST_CONTEXT.state; const params = resolveTopViewedProductsParams(context); - return fetchAnalyticsConsumption( + // This endpoint returns a bare JSON array, but MCP structuredContent must + // be a record — wrap it in `{ items }` (same shape the tool adapter uses + // for array-returning operations). + const data = await fetchAnalyticsConsumption( { accountName, appKey, appToken }, "home-top-viewed-products", { @@ -167,6 +175,8 @@ export const getHomeTopViewedProducts = (_env: Env) => size: params.size, }, ); + + return { items: Array.isArray(data) ? data : [] }; }, }); diff --git a/vtex/server/tools/custom/list-collections.test.ts b/vtex/server/tools/custom/list-collections.test.ts new file mode 100644 index 00000000..44c4be64 --- /dev/null +++ b/vtex/server/tools/custom/list-collections.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { + type CollectionSearchPage, + collectAllCollections, +} from "./list-collections.ts"; + +function pagedFetcher(all: Record[]) { + const calls: number[] = []; + const fetchPage = ( + page: number, + pageSize: number, + ): Promise => { + calls.push(page); + const start = (page - 1) * pageSize; + return Promise.resolve({ + items: all.slice(start, start + pageSize), + paging: { total: all.length }, + }); + }; + return { fetchPage, calls }; +} + +describe("collectAllCollections", () => { + test("accumulates every collection across pages", async () => { + const all = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 })); + const { fetchPage, calls } = pagedFetcher(all); + + const result = await collectAllCollections(fetchPage, 2); + + expect(result.total).toBe(5); + expect(result.items).toEqual(all); + // pages 1,2,3 — third page is short, so it stops there. + expect(calls).toEqual([1, 2, 3]); + }); + + test("stops after a single full page when total is reached", async () => { + const all = Array.from({ length: 4 }, (_, i) => ({ id: i + 1 })); + const { fetchPage, calls } = pagedFetcher(all); + + const result = await collectAllCollections(fetchPage, 4); + + expect(result.items).toHaveLength(4); + expect(calls).toEqual([1]); + }); + + test("returns empty result when there are no collections", async () => { + const { fetchPage, calls } = pagedFetcher([]); + + const result = await collectAllCollections(fetchPage, 50); + + expect(result).toEqual({ items: [], total: 0 }); + expect(calls).toEqual([1]); + }); + + test("falls back to collected count when paging.total is absent", async () => { + const fetchPage = (): Promise => + Promise.resolve({ items: [{ id: 1 }] }); + + const result = await collectAllCollections(fetchPage, 50); + + // Short page (1 < 50) stops pagination; total defaults to items collected. + expect(result).toEqual({ items: [{ id: 1 }], total: 1 }); + }); +}); diff --git a/vtex/server/tools/custom/list-collections.ts b/vtex/server/tools/custom/list-collections.ts new file mode 100644 index 00000000..0577644b --- /dev/null +++ b/vtex/server/tools/custom/list-collections.ts @@ -0,0 +1,98 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../../types/env.ts"; + +const DEFAULT_PAGE_SIZE = 50; +// Safety cap so a misbehaving/huge catalog can't spin forever. +const MAX_PAGES = 100; + +const outputSchema = z.object({ + items: z.array(z.record(z.string(), z.unknown())), + total: z.number(), +}); + +export interface CollectionSearchPage { + items?: unknown; + paging?: { total?: number }; +} + +/** + * Page through the catalog collection search endpoint, accumulating every + * collection. The fetcher is injected so the pagination logic can be tested + * without a live VTEX account. Stops when a page comes back short (last page), + * once the reported `paging.total` is reached, or at the MAX_PAGES safety cap. + */ +export async function collectAllCollections( + fetchPage: (page: number, pageSize: number) => Promise, + pageSize: number, +): Promise<{ items: Record[]; total: number }> { + const items: Record[] = []; + let total = 0; + + for (let page = 1; page <= MAX_PAGES; page++) { + const data = await fetchPage(page, pageSize); + const pageItems = Array.isArray(data.items) + ? (data.items as Record[]) + : []; + items.push(...pageItems); + if (typeof data.paging?.total === "number") { + total = data.paging.total; + } + + if (pageItems.length < pageSize || items.length >= total) { + break; + } + } + + return { items, total: total || items.length }; +} + +// Read per-request env from `runtimeContext` — see comment in +// lib/tool-adapter.ts for why the factory's captured env is unsafe to read +// inside execute (cached registrations + fresh per-request bindings). +export const listCollections = (_env: Env) => + createTool({ + id: "VTEX_LIST_COLLECTIONS", + description: + "List all collections in the catalog (active and inactive), paginating through VTEX's collection search endpoint. Use VTEX_SEARCH_COLLECTIONS to filter by name, or VTEX_GET_COLLECTION to read one by ID.", + annotations: { readOnlyHint: true }, + inputSchema: z.object({ + pageSize: z + .number() + .int() + .min(1) + .max(50) + .default(DEFAULT_PAGE_SIZE) + .describe("Collections fetched per request while paginating (max 50)"), + }), + outputSchema, + execute: async ({ context, runtimeContext }) => { + const env = runtimeContext.env as Env; + const { accountName, appKey, appToken } = env.MESH_REQUEST_CONTEXT.state; + const pageSize = context.pageSize ?? DEFAULT_PAGE_SIZE; + + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + ...(appKey && { "X-VTEX-API-AppKey": appKey }), + ...(appToken && { "X-VTEX-API-AppToken": appToken }), + }; + + // The search endpoint returns every collection when the search term is + // blank; page through it until we've collected them all. + return collectAllCollections(async (page, size) => { + const url = `https://${accountName}.vtexcommercestable.com.br/api/catalog_system/pvt/collection/search/?page=${page}&pageSize=${size}`; + console.log("[VTEX] GET", url); + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error( + `VTEX API Error: ${response.status} - ${await response.text()}`, + ); + } + + return (await response.json()) as CollectionSearchPage; + }, pageSize); + }, + }); diff --git a/vtex/server/tools/custom/orders-timeline.ts b/vtex/server/tools/custom/orders-timeline.ts index 5ee77425..ff74c213 100644 --- a/vtex/server/tools/custom/orders-timeline.ts +++ b/vtex/server/tools/custom/orders-timeline.ts @@ -33,7 +33,7 @@ export const ordersTimeline = (_env: Env) => createTool({ id: "VTEX_ORDERS_TIMELINE", description: - "Fetch today's orders aggregated by hour for a bar chart. Uses the admin home orders trend analytics endpoint (single request), falling back to per-hour OMS order-list aggregation when analytics is unavailable.", + "Fetch today's orders aggregated by hour for a bar chart. Uses the admin home orders trend analytics endpoint (single request), falling back to per-hour OMS order-list aggregation when analytics is unavailable. Note: the analytics path only provides per-hour order counts, so `totalValue` is 0 unless the OMS fallback is used — for hourly revenue use VTEX_ORDERS_SALES_CARD.", inputSchema: z.object({}), outputSchema, _meta: { ui: { resourceUri: VTEX_ORDERS_TIMELINE_RESOURCE_URI } }, diff --git a/vtex/server/tools/custom/orders-trend.ts b/vtex/server/tools/custom/orders-trend.ts index a4169ffe..8e679c85 100644 --- a/vtex/server/tools/custom/orders-trend.ts +++ b/vtex/server/tools/custom/orders-trend.ts @@ -175,6 +175,9 @@ export function parseAnalyticsHourlyBuckets( } const hour = hourLabelInTimezone(point.date, timezone); + // The home-orders-trend endpoint only reports order counts per bucket, not + // revenue, so totalValue stays 0 on this path. The OMS fallback in + // orders-timeline is what populates per-hour revenue. byHour.set(hour, { hour, count, diff --git a/vtex/server/tools/index.ts b/vtex/server/tools/index.ts index 750ae4d1..cf77a99d 100644 --- a/vtex/server/tools/index.ts +++ b/vtex/server/tools/index.ts @@ -84,6 +84,7 @@ import { import { ordersTimeline } from "./custom/orders-timeline.ts"; import { ordersSalesCard } from "./custom/orders-sales-card.ts"; import { searchCollections } from "./custom/search-collections.ts"; +import { listCollections } from "./custom/list-collections.ts"; import { reorderCollection } from "./custom/reorder-collection.ts"; import { updateProductSpecifications } from "./custom/update-product-specifications.ts"; import { getOrdersTrend } from "./custom/orders-trend.ts"; @@ -181,6 +182,9 @@ const customFactories = [ ordersSalesCard, // Collection search (endpoint absent from generated SDKs) searchCollections, + // List all collections via the search endpoint (generated SDK only exposes + // the inactive-only listing) + listCollections, // Collection overwrite/reorder via XML import flow reorderCollection, // Bulk replace product specifications (PUT v2, missing from generated SDK) diff --git a/vtex/server/tools/registry.ts b/vtex/server/tools/registry.ts index 4abdc865..e607250d 100644 --- a/vtex/server/tools/registry.ts +++ b/vtex/server/tools/registry.ts @@ -313,13 +313,9 @@ export const collectionTools = [ requestSchema: catalogZod.zGetApiCatalogPvtCollectionByCollectionIdData, sdkFn: catalogSdk.getApiCatalogPvtCollectionByCollectionId as any, }), - createToolFromOperation({ - id: "VTEX_LIST_COLLECTIONS", - description: "List all collections in the catalog.", - annotations: { readOnlyHint: true }, - requestSchema: catalogZod.zGetAllInactiveCollectionsData, - sdkFn: catalogSdk.getAllInactiveCollections as any, - }), + // VTEX_LIST_COLLECTIONS lives in tools/custom/list-collections.ts — the + // generated `/collection/inactive` operation only returns inactive + // collections, so we page the catalog search endpoint instead. createToolFromOperation({ id: "VTEX_CREATE_COLLECTION", description: "Create a new product collection.", @@ -343,7 +339,8 @@ export const collectionTools = [ }), createToolFromOperation({ id: "VTEX_GET_COLLECTION_PRODUCTS", - description: "Get all products in a collection.", + description: + "Get the products in a collection. Only returns products for manual collections; automatic (rule-based) collections resolve their products at query time and return an empty list (TotalRows: 0) from this endpoint.", annotations: { readOnlyHint: true }, requestSchema: catalogZod.zGetProductsfromacollectionData, sdkFn: catalogSdk.getProductsfromacollection as any,