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
170 changes: 170 additions & 0 deletions templates/content/actions/_builder-cms-read-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { resolveBuilderCredential } from "@agent-native/core/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

import {
builderCmsListEntryFields,
listBuilderCmsModels,
readBuilderCmsContentEntry,
readBuilderCmsContentEntries,
Expand All @@ -24,6 +25,46 @@ describe("Builder CMS read client", () => {
delete process.env.BUILDER_CMS_READ_LIMIT;
});

it("builds additive list projections without reintroducing heavy body fields", () => {
const fields = builderCmsListEntryFields([
"topics",
"data.tags",
"data.customModelField",
"data.published",
"data.Status",
"data.status",
"data.tags",
"data.blocks",
"DATA.BLOCKS",
"data.blocks.children",
"data.blocksString",
"data.BlocksString",
"sys.sync_state",
"bad,field",
]).split(",");

expect(fields).toEqual(
expect.arrayContaining([
"data.title",
"data.topics",
"data.tags",
"data.customModelField",
"data.published",
"data.Status",
"data.status",
]),
);
expect(fields).not.toContain("data.blocks");
expect(fields).not.toContain("data.blocks.children");
expect(fields).not.toContain("data.blocksString");
expect(fields).not.toContain("DATA.BLOCKS");
expect(fields).not.toContain("data.BlocksString");
expect(fields).not.toContain("sys.sync_state");
expect(fields.filter((field) => field === "data.tags")).toHaveLength(1);
expect(fields).toContain("published");
expect(fields).toContain("data.published");
});

it("does not call Builder when the public key is not configured", async () => {
resolveBuilderCredentialMock.mockResolvedValue(null);
const fetchImpl = vi.fn();
Expand Down Expand Up @@ -55,6 +96,38 @@ describe("Builder CMS read client", () => {
expect(fetchImpl).not.toHaveBeenCalled();
});

it("keeps unconfigured model-field discovery as an empty-field fallback", async () => {
resolveBuilderCredentialMock.mockResolvedValue(null);
const fetchImpl = vi.fn();

await expect(
readBuilderCmsModelFields({
model: "blog-article",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).resolves.toEqual([]);
expect(fetchImpl).not.toHaveBeenCalled();
});

it("throws when production model discovery returns an error state", async () => {
resolveBuilderCredentialMock.mockImplementation(async (key) =>
key === "BUILDER_PRIVATE_KEY" ? "private-key" : null,
);
const fetchImpl = vi.fn().mockResolvedValue(
new Response("Builder unavailable", {
status: 503,
}),
);

await expect(
readBuilderCmsModelFields({
model: "blog-article",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toThrow("Builder MCP request failed with HTTP 503.");
expect(fetchImpl).toHaveBeenCalledTimes(1);
});

it("lists Builder models through the MCP read endpoint", async () => {
resolveBuilderCredentialMock.mockImplementation(async (key) =>
key === "BUILDER_PRIVATE_KEY" ? "private-key" : null,
Expand Down Expand Up @@ -236,6 +309,11 @@ describe("Builder CMS read client", () => {
expect(input.searchParams.get("limit")).toBe("100");
expect(input.searchParams.get("offset")).toBe("0");
expect(input.searchParams.get("fields")).toContain("data.title");
expect(input.searchParams.get("fields")).toContain("data.topics");
expect(input.searchParams.get("fields")).toContain("data.tags");
expect(input.searchParams.get("fields")).toContain(
"data.customModelField",
);
expect(input.searchParams.get("fields")).not.toContain("data.blocks");
expect(init?.headers).toMatchObject({
accept: "application/json",
Expand All @@ -250,6 +328,11 @@ describe("Builder CMS read client", () => {
data: {
title: "Builder title",
url: "/blog/builder-title",
topics: ["AI", "CMS"],
tags: ["Agents"],
customModelField: "Preserved",
Status: "Editorial",
status: "published",
},
},
],
Expand All @@ -261,6 +344,14 @@ describe("Builder CMS read client", () => {
await expect(
readBuilderCmsContentEntries({
model: "blog_article",
fieldPaths: [
"data.topics",
"data.tags",
"data.customModelField",
"data.Status",
"data.status",
"data.blocks",
],
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).resolves.toMatchObject({
Expand All @@ -272,11 +363,90 @@ describe("Builder CMS read client", () => {
title: "Builder title",
urlPath: "/blog/builder-title",
updatedAt: "2026-06-08T12:00:00.000Z",
sourceValues: {
"data.topics": ["AI", "CMS"],
"data.tags": ["Agents"],
"data.customModelField": "Preserved",
"data.Status": "Editorial",
"data.status": "published",
},
},
],
});
});

it("requests mapped model fields through Builder MCP list reads", async () => {
resolveBuilderCredentialMock.mockImplementation(async (key) =>
key === "BUILDER_PRIVATE_KEY" ? "private-key" : null,
);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ jsonrpc: "2.0", result: {} }), {
status: 200,
headers: { "mcp-session-id": "session-1" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ jsonrpc: "2.0", result: {} }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
jsonrpc: "2.0",
result: {
content: [
{
type: "text",
text: JSON.stringify({
content: [
{
id: "builder-entry-mcp",
lastUpdated: "2026-06-08T12:00:00.000Z",
data: {
title: "MCP title",
topics: ["AI"],
tags: ["CMS"],
customModelField: "MCP preserved",
},
},
],
}),
},
],
},
}),
{ status: 200 },
),
);

const result = await readBuilderCmsContentEntries({
model: "blog_article",
fieldPaths: [
"topics",
"data.tags",
"data.customModelField",
"data.blocksString",
],
fetchImpl: fetchImpl as unknown as typeof fetch,
});

expect(result.entries[0]?.sourceValues).toMatchObject({
"data.topics": ["AI"],
"data.tags": ["CMS"],
"data.customModelField": "MCP preserved",
});
const [, request] = fetchImpl.mock.calls[2] as [string, RequestInit];
const fields = JSON.parse(String(request.body)).params.arguments.fields;
expect(fields).toContain("data.topics");
expect(fields).toContain("data.tags");
expect(fields).toContain("data.customModelField");
expect(fields).not.toContain("data.blocks");
expect(fields).not.toContain("data.blocksString");
});

it("paginates Builder content through the Content API up to the read limit", async () => {
process.env.BUILDER_CONTENT_API_HOST = "https://cdn.test.builder.io";
resolveBuilderCredentialMock.mockImplementation(async (key) =>
Expand Down
89 changes: 82 additions & 7 deletions templates/content/actions/_builder-cms-read-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,75 @@ const BUILDER_CMS_DEFAULT_READ_LIMIT = 500;
const BUILDER_CMS_MAX_READ_LIMIT = 1000;
const BUILDER_CMS_PAGE_SIZE = 100;
const BUILDER_CMS_READ_RETRIES = 2;
const BUILDER_CMS_METADATA_ENTRY_FIELDS =
"id,name,published,lastUpdated,createdDate,data.title,data.handle,data.url,data.slug,data.date,data.description,data.status,data.author,data.image";
const BUILDER_CMS_BODY_ENTRY_FIELDS = `${BUILDER_CMS_METADATA_ENTRY_FIELDS},data.blocks,data.blocksString`;
const BUILDER_CMS_METADATA_ENTRY_FIELD_PATHS = [
"id",
"name",
"published",
"lastUpdated",
"createdDate",
"data.title",
"data.handle",
"data.url",
"data.slug",
"data.date",
"data.description",
"data.status",
"data.author",
"data.image",
] as const;
const BUILDER_CMS_TOP_LEVEL_METADATA_FIELDS = new Set(
BUILDER_CMS_METADATA_ENTRY_FIELD_PATHS.filter(
(fieldPath) => !fieldPath.startsWith("data."),
).map((fieldPath) => fieldPath.toLowerCase()),
);
const BUILDER_CMS_HEAVY_BODY_FIELD_PATHS = [
"data.blocks",
"data.blocksString",
] as const;
const BUILDER_CMS_FIELD_PATH_PATTERN =
/^[A-Za-z0-9_$-]+(?:\.[A-Za-z0-9_$-]+)*$/;

function normalizeBuilderCmsListFieldPath(fieldPath: string) {
const trimmed = fieldPath.trim();
if (!trimmed || !BUILDER_CMS_FIELD_PATH_PATTERN.test(trimmed)) return null;
const normalized = trimmed.includes(".")
? trimmed
: BUILDER_CMS_TOP_LEVEL_METADATA_FIELDS.has(trimmed.toLowerCase())
? trimmed
: `data.${trimmed}`;
const lower = normalized.toLowerCase();
if (
BUILDER_CMS_HEAVY_BODY_FIELD_PATHS.some((heavyFieldPath) => {
const heavyLower = heavyFieldPath.toLowerCase();
return lower === heavyLower || lower.startsWith(`${heavyLower}.`);
})
) {
return null;
}
if (
normalized.includes(".") &&
!normalized.toLowerCase().startsWith("data.")
) {
return null;
}
return normalized;
}

export function builderCmsListEntryFields(fieldPaths: readonly string[] = []) {
const fields = new Map<string, string>();
for (const fieldPath of [
...BUILDER_CMS_METADATA_ENTRY_FIELD_PATHS,
...fieldPaths,
]) {
const normalized = normalizeBuilderCmsListFieldPath(fieldPath);
if (!normalized) continue;
if (!fields.has(normalized)) fields.set(normalized, normalized);
}
return Array.from(fields.values()).join(",");
}

const BUILDER_CMS_METADATA_ENTRY_FIELDS = builderCmsListEntryFields();
const BUILDER_CMS_BODY_ENTRY_FIELDS = `${BUILDER_CMS_METADATA_ENTRY_FIELDS},${BUILDER_CMS_HEAVY_BODY_FIELD_PATHS.join(",")}`;

function builderContentApiHost() {
return (
Expand Down Expand Up @@ -444,6 +510,7 @@ async function initializeBuilderMcp(args: {

async function readBuilderCmsContentEntriesViaMcp(args: {
model: string;
fieldPaths?: readonly string[];
limit?: number;
maxPages?: number;
offset?: number;
Expand All @@ -464,6 +531,7 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
? Math.max(0, Math.floor(args.offset))
: 0;
const contentEntries: BuilderCmsSourceEntry[] = [];
const fields = builderCmsListEntryFields(args.fieldPaths);
const seenContentIds = new Set<string>();
let pagesRead = 0;
let hasMore = false;
Expand All @@ -490,7 +558,7 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
modelName: args.model,
limit: pageLimit,
offset,
fields: BUILDER_CMS_METADATA_ENTRY_FIELDS,
fields,
enrich: true,
},
},
Expand Down Expand Up @@ -600,7 +668,7 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
modelName: args.model,
limit: 1,
query: { id: entry.id },
fields: BUILDER_CMS_METADATA_ENTRY_FIELDS,
fields,
enrich: true,
},
},
Expand Down Expand Up @@ -633,6 +701,7 @@ async function readBuilderCmsContentEntriesViaMcp(args: {

async function readBuilderCmsContentEntriesViaContentApi(args: {
model: string;
fieldPaths?: readonly string[];
limit?: number;
maxPages?: number;
offset?: number;
Expand All @@ -650,7 +719,7 @@ async function readBuilderCmsContentEntriesViaContentApi(args: {
// bare reference id.
url.searchParams.set("enrich", "true");
url.searchParams.set("noCache", "true");
url.searchParams.set("fields", BUILDER_CMS_METADATA_ENTRY_FIELDS);
url.searchParams.set("fields", builderCmsListEntryFields(args.fieldPaths));

const limit = readLimit(args.limit);
const startOffset =
Expand Down Expand Up @@ -902,7 +971,10 @@ export async function readBuilderCmsModelFields(args: {
fetchImpl?: FetchLike;
}): Promise<BuilderCmsModelFieldSummary[]> {
const models = await listBuilderCmsModels({ fetchImpl: args.fetchImpl });
if (models.state !== "live") return [];
if (models.state === "unconfigured") return [];
if (models.state === "error") {
throw new Error(models.message ?? "Builder CMS model discovery failed.");
}
const modelName = args.model.trim().toLowerCase();
return (
models.models.find((model) => {
Expand All @@ -917,6 +989,7 @@ export async function readBuilderCmsModelFields(args: {

export async function readBuilderCmsContentEntries(args: {
model: string;
fieldPaths?: readonly string[];
limit?: number;
maxPages?: number;
offset?: number;
Expand All @@ -929,6 +1002,7 @@ export async function readBuilderCmsContentEntries(args: {
if (publicKey) {
const contentApiRead = await readBuilderCmsContentEntriesViaContentApi({
model: args.model,
fieldPaths: args.fieldPaths,
limit: args.limit,
maxPages: args.maxPages,
offset: args.offset,
Expand All @@ -945,6 +1019,7 @@ export async function readBuilderCmsContentEntries(args: {
try {
return await readBuilderCmsContentEntriesViaMcp({
model: args.model,
fieldPaths: args.fieldPaths,
limit: args.limit,
maxPages: args.maxPages,
offset: args.offset,
Expand Down
Loading
Loading