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
12 changes: 11 additions & 1 deletion vtex/server/tools/custom/home-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
{
Expand All @@ -167,6 +175,8 @@ export const getHomeTopViewedProducts = (_env: Env) =>
size: params.size,
},
);

return { items: Array.isArray(data) ? data : [] };
},
});

Expand Down
64 changes: 64 additions & 0 deletions vtex/server/tools/custom/list-collections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import {
type CollectionSearchPage,
collectAllCollections,
} from "./list-collections.ts";

function pagedFetcher(all: Record<string, unknown>[]) {
const calls: number[] = [];
const fetchPage = (
page: number,
pageSize: number,
): Promise<CollectionSearchPage> => {
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<CollectionSearchPage> =>
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 });
});
});
98 changes: 98 additions & 0 deletions vtex/server/tools/custom/list-collections.ts
Original file line number Diff line number Diff line change
@@ -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<CollectionSearchPage>,
pageSize: number,
): Promise<{ items: Record<string, unknown>[]; total: number }> {
const items: Record<string, unknown>[] = [];
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<string, unknown>[])
: [];
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);
},
});
2 changes: 1 addition & 1 deletion vtex/server/tools/custom/orders-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
3 changes: 3 additions & 0 deletions vtex/server/tools/custom/orders-trend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions vtex/server/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 5 additions & 8 deletions vtex/server/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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,
Expand Down
Loading