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
3 changes: 3 additions & 0 deletions auth-exemptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dropbox": {
"reason": "Predates the withAuth requirement — 19 findings pending remediation."
},
"dynamic-yield": {
"reason": "The Authorization header carries the user's Dynamic Yield API key (app.json auth token), so there is no shared secret to check. Re-add withAuth once Mesh forwards the connection secret on a separate header."
},
"farmrio-reorder-collection-db": {
"reason": "Predates the withAuth requirement — 1 finding pending remediation."
},
Expand Down
35 changes: 34 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions deploy.json
Original file line number Diff line number Diff line change
Expand Up @@ -530,5 +530,14 @@
"wake/**",
"shared/**"
]
},
"dynamic-yield": {
"site": "dynamic-yield",
"entrypoint": "./dist/server/main.js",
"platformName": "kubernetes-bun",
"watch": [
"dynamic-yield/**",
"shared/**"
]
}
}
4 changes: 4 additions & 0 deletions dynamic-yield/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.dev.vars
.env
dist/
node_modules/
31 changes: 31 additions & 0 deletions dynamic-yield/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Dynamic Yield MCP

Tools for Dynamic Yield's public server-side APIs. Campaigns, audiences, strategies and reports have no public API and stay in the DY console.

| Tool | API |
| --- | --- |
| `DY_CHOOSE` | `POST /v2/serve/user/choose` — campaign and recommendation QA, preview tokens |
| `DY_TRACK_PAGEVIEW` | `POST /v2/collect/user/pageview` |
| `DY_TRACK_EVENTS` | `POST /v2/collect/user/event` |
| `DY_TRACK_ENGAGEMENT` | `POST /v2/collect/user/engagement` |
| `DY_FEED_BULK` | `POST /v2/feeds/{feedId}/bulk` |
| `DY_FEED_TRANSACTION_STATUS` | `GET /v2/feeds/{feedId}/transaction/{id}[/item/{sku}]` |
| `DY_USER_PROFILE` | `GET /v2/userprofile` (Profile Anywhere) |

The collect tools write real data: use a dedicated test `dyid`.

## Connecting

1. In DY, open **Settings › API Keys › New Key**, choose **Server-side**, and grant the Experience API permissions plus **Feed** if you will use the feed tools.
2. In Studio, add the Dynamic Yield connection and paste the key as the token.
3. In the configuration, pick the site's data center (`us` or `eu`). Add a Profile Anywhere key only if you need `DY_USER_PROFILE`.

The product feed must be set up in DY as **Sync via API** (Assets › Data Feeds); its numeric id is the `feedId`.

## Development

```sh
bun run dev # serves http://localhost:8001/mcp
bun test
bun run check
```
35 changes: 35 additions & 0 deletions dynamic-yield/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"scopeName": "deco",
"name": "dynamic-yield",
"friendlyName": "Dynamic Yield",
"connection": {
"type": "HTTP",
"url": "https://sites-dynamic-yield.deco.site/mcp"
},
"description": "Operate Dynamic Yield through its public APIs: QA campaigns and recommendations with the Experience API, report events, sync the product feed and read user profiles.",
"icon": "https://avatars.githubusercontent.com/u/12912945?s=256&v=4",
"unlisted": false,
"auth": {
"type": "token",
"header": "Authorization",
"prefix": "Bearer"
},
"metadata": {
"categories": [
"E-commerce",
"Marketing"
],
"official": false,
"tags": [
"dynamic-yield",
"personalization",
"recommendations",
"experience-api",
"product-feed",
"ab-testing",
"ecommerce"
],
"short_description": "QA Dynamic Yield campaigns and recommendations, report events and sync the product feed.",
"mesh_description": "The **Dynamic Yield** MCP wraps Dynamic Yield's public server-side APIs. **Serve**: DY_CHOOSE runs the Experience API choose call for a page context (HOMEPAGE, CATEGORY, PRODUCT, CART, OTHER) and returns campaign payloads, recommendation slots, decision ids and analytics metadata; it accepts dyApiPreview tokens to QA unpublished variations and does not count as a pageview by default. **Collect**: DY_TRACK_PAGEVIEW, DY_TRACK_EVENTS and DY_TRACK_ENGAGEMENT report real data, so use them with test users. **Product feed**: DY_FEED_BULK upserts, partially updates or deletes up to 100 products per call in an API-synced feed, and DY_FEED_TRANSACTION_STATUS checks the result. **Profiles**: DY_USER_PROFILE reads Profile Anywhere affinity data. **Authentication**: a server-side DY API key (Settings › API Keys) sent as the connection token, with the Experience API and Feed ACLs as needed; set the data center (US or EU) in the configuration. Dynamic Yield has no public API for managing campaigns or reports, so those remain in the DY console."
}
}
27 changes: 27 additions & 0 deletions dynamic-yield/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "dynamic-yield",
"version": "1.0.0",
"description": "MCP for the Dynamic Yield Experience, Product Feed and Profile Anywhere APIs.",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --hot server/main.ts",
"check": "tsc --noEmit",
"build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js",
"build": "bun run build:server"
},
"dependencies": {
"@decocms/runtime": "^1.6.2",
"zod": "^4.0.0"
},
"devDependencies": {
"@decocms/mcps-shared": "1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"bun-types": "^1.3.7",
"deco-cli": "^0.28.0",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=22.0.0"
}
}
91 changes: 91 additions & 0 deletions dynamic-yield/server/lib/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "bun:test";
import { FeedBulkInputSchema } from "../tools/feed.ts";
import type { Env } from "../types/env.ts";
import { baseUrl, dyFetch, getApiKey } from "./client.ts";

const env = (authorization?: string, region?: "us" | "eu") =>
({
MESH_REQUEST_CONTEXT: { authorization, state: { region } },
}) as unknown as Env;

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

describe("client", () => {
it("maps region to base URL", () => {
expect(baseUrl(undefined)).toBe("https://dy-api.com/v2");
expect(baseUrl("eu")).toBe("https://dy-api.eu/v2");
});

it("strips Bearer and requires a key", () => {
expect(getApiKey(env("Bearer test-key"))).toBe("test-key");
expect(getApiKey(env("test-key"))).toBe("test-key");
expect(() => getApiKey(env())).toThrow("missing Dynamic Yield API key");
});

it("sends the key and surfaces status, body and trace id on errors", async () => {
let seen: Request | undefined;
globalThis.fetch = (async (url: string, init: RequestInit) => {
seen = new Request(url, init);
return new Response('{"error":"bad key"}', {
status: 401,
headers: { "DY-Trace-ID": "trace-1" },
});
}) as typeof fetch;
await expect(
dyFetch(env("test-key", "eu"), "/serve/user/choose", { body: {} }),
).rejects.toThrow('401 (DY-Trace-ID trace-1): {"error":"bad key"}');
expect(seen?.url).toBe("https://dy-api.eu/v2/serve/user/choose");
expect(seen?.headers.get("DY-API-Key")).toBe("test-key");
});

it("wraps array responses", async () => {
globalThis.fetch = (async () =>
new Response(
'[{"item":"sku-1","status":"success"}]',
)) as unknown as typeof fetch;
const result = await dyFetch(
env("test-key"),
"/feeds/000000/transaction/tx",
{
method: "GET",
},
);
expect(result.items).toEqual([{ item: "sku-1", status: "success" }]);
});
});

describe("DY_FEED_BULK input", () => {
const row = {
id: "sku-1",
action: "update" as const,
data: { sku: "sku-1" },
};

it("accepts up to 100 actions and delete without data", () => {
expect(
FeedBulkInputSchema.safeParse({
feedId: "000000",
requests: [...Array(99).fill(row), { id: "sku-2", action: "delete" }],
}).success,
).toBe(true);
});

it("rejects more than 100 actions, update without data and a bad feed id", () => {
const parse = (input: unknown) =>
FeedBulkInputSchema.safeParse(input).success;
expect(parse({ feedId: "000000", requests: Array(101).fill(row) })).toBe(
false,
);
expect(
parse({
feedId: "000000",
requests: [{ id: "sku-1", action: "update" }],
}),
).toBe(false);
expect(parse({ feedId: "../x", requests: [row] })).toBe(false);
expect(parse({ feedId: "000000", requests: [] })).toBe(false);
});
});
55 changes: 55 additions & 0 deletions dynamic-yield/server/lib/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Env } from "../types/env.ts";

const BASE_URLS = {
us: "https://dy-api.com/v2",
eu: "https://dy-api.eu/v2",
} as const;

const TIMEOUT_MS = 30_000;

export function baseUrl(region: keyof typeof BASE_URLS | undefined): string {
return BASE_URLS[region ?? "us"];
}

export function getApiKey(env: Env): string {
const auth = env.MESH_REQUEST_CONTEXT?.authorization ?? "";
const key = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
if (!key) {
throw new Error(
"Unauthorized: missing Dynamic Yield API key. Configure the connection with a server-side DY API key.",
);
}
return key;
}

export async function dyFetch(
env: Env,
path: string,
init: { method?: "GET" | "POST"; body?: unknown; apiKey?: string } = {},
): Promise<Record<string, unknown>> {
const response = await fetch(
`${baseUrl(env.MESH_REQUEST_CONTEXT?.state?.region)}${path}`,
{
method: init.method ?? "POST",
headers: {
"DY-API-Key": init.apiKey ?? getApiKey(env),
"Content-Type": "application/json",
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
signal: AbortSignal.timeout(TIMEOUT_MS),
},
);
const traceId = response.headers.get("DY-Trace-ID") ?? undefined;
const text = await response.text();
if (!response.ok) {
throw new Error(
`Dynamic Yield API error ${response.status}${traceId ? ` (DY-Trace-ID ${traceId})` : ""}: ${text.slice(0, 2000)}`,
);
}
if (!text) return { status: response.status, traceId };
const data: unknown = JSON.parse(text);
// Some endpoints (transaction status) return a bare array.
return Array.isArray(data)
? { items: data, traceId }
: { ...(data as Record<string, unknown>), traceId };
}
33 changes: 33 additions & 0 deletions dynamic-yield/server/lib/tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createPrivateTool } from "@decocms/runtime/tools";
import type { z } from "zod";
import type { Env } from "../types/env.ts";

interface ToolAnnotations {
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
}

export function createDyTool<
TSchema extends z.ZodObject<z.ZodRawShape>,
>(config: {
id: string;
description: string;
inputSchema: TSchema;
annotations: ToolAnnotations;
handler: (
input: z.infer<TSchema>,
env: Env,
) => Promise<Record<string, unknown>>;
}) {
return (_env: Env) =>
createPrivateTool({
id: config.id,
description: config.description,
inputSchema: config.inputSchema,
annotations: config.annotations,
execute: async ({ context, runtimeContext }) =>
config.handler(context as z.infer<TSchema>, runtimeContext.env as Env),
});
}
Loading
Loading