diff --git a/auth-exemptions.json b/auth-exemptions.json index d1244ec7..e7cb31ec 100644 --- a/auth-exemptions.json +++ b/auth-exemptions.json @@ -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." }, diff --git a/bun.lock b/bun.lock index 5cce6719..ded1251e 100644 --- a/bun.lock +++ b/bun.lock @@ -254,6 +254,21 @@ "wrangler": "^4.28.0", }, }, + "dynamic-yield": { + "name": "dynamic-yield", + "version": "1.0.0", + "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", + }, + }, "farmrio-reorder-collection-db": { "name": "farmrio-reorder-collection-db", "version": "1.0.0", @@ -1175,7 +1190,7 @@ }, "wake": { "name": "wake", - "version": "1.0.1", + "version": "1.0.2", "dependencies": { "@decocms/runtime": "^1.6.2", "zod": "^4.0.0", @@ -2844,6 +2859,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "dynamic-yield": ["dynamic-yield@workspace:dynamic-yield"], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -4288,6 +4305,12 @@ "dropbox/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "dynamic-yield/@decocms/runtime": ["@decocms/runtime@1.6.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.10", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.29.0", "jose": "^6.0.11", "zod": "^4.0.0" }, "peerDependencies": { "ai": ">=6.0.0" } }, "sha512-r6O4z+ISJpBnhDw4x1K8IYNqfQ+xdwSjNcg6vDqU42I6x82H8snfAg/E6u9WfcMClap7sXxMAdKuDcEMxPq55A=="], + + "dynamic-yield/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "dynamic-yield/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "external-editor/chardet": ["chardet@0.4.2", "", {}, "sha512-j/Toj7f1z98Hh2cYo2BVr85EpIRWqUi7rtRSGxh/cqUjqrnJe9l9UE7IUGd2vQ2p+kSHLkSzObQPZPLUC6TQwg=="], @@ -4908,6 +4931,10 @@ "dropbox/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "dynamic-yield/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.4.9", "", { "dependencies": { "@decocms/mcp-utils": "^1.0.5", "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-router": "1.169.2", "react": "^19.2.6", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-NvhuHsKL0YpSUaAZqEe0PAzF41/JJSi+H0X7HpMBScOVpALcGqAC+DaJvcKeo3aRXXW7z6du0oCdkhLf2A6Vjw=="], + + "dynamic-yield/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "farmrio-reorder-collection-db/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.4.9", "", { "dependencies": { "@decocms/mcp-utils": "^1.0.5", "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-router": "1.169.2", "react": "^19.2.6", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-NvhuHsKL0YpSUaAZqEe0PAzF41/JJSi+H0X7HpMBScOVpALcGqAC+DaJvcKeo3aRXXW7z6du0oCdkhLf2A6Vjw=="], "farmrio-reorder-collection-db/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], @@ -5538,6 +5565,8 @@ "dropbox/@decocms/bindings/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], + "dynamic-yield/@decocms/runtime/@decocms/bindings/@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], + "farmrio-reorder-collection-db/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "farmrio-reorder-collection-db/@decocms/runtime/@decocms/bindings/@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], @@ -6288,6 +6317,10 @@ "deco-news-weekly-digest/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], + "dynamic-yield/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + + "dynamic-yield/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], + "farmrio-reorder-collection-db/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], "farmrio-reorder-collection-db/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], diff --git a/deploy.json b/deploy.json index 0eb69bd5..cfc2bf59 100644 --- a/deploy.json +++ b/deploy.json @@ -530,5 +530,14 @@ "wake/**", "shared/**" ] + }, + "dynamic-yield": { + "site": "dynamic-yield", + "entrypoint": "./dist/server/main.js", + "platformName": "kubernetes-bun", + "watch": [ + "dynamic-yield/**", + "shared/**" + ] } } diff --git a/dynamic-yield/.gitignore b/dynamic-yield/.gitignore new file mode 100644 index 00000000..3e950378 --- /dev/null +++ b/dynamic-yield/.gitignore @@ -0,0 +1,4 @@ +.dev.vars +.env +dist/ +node_modules/ diff --git a/dynamic-yield/README.md b/dynamic-yield/README.md new file mode 100644 index 00000000..d0e70656 --- /dev/null +++ b/dynamic-yield/README.md @@ -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 +``` diff --git a/dynamic-yield/app.json b/dynamic-yield/app.json new file mode 100644 index 00000000..74a37fd6 --- /dev/null +++ b/dynamic-yield/app.json @@ -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." + } +} diff --git a/dynamic-yield/package.json b/dynamic-yield/package.json new file mode 100644 index 00000000..e706021a --- /dev/null +++ b/dynamic-yield/package.json @@ -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" + } +} diff --git a/dynamic-yield/server/lib/client.test.ts b/dynamic-yield/server/lib/client.test.ts new file mode 100644 index 00000000..4f372ce4 --- /dev/null +++ b/dynamic-yield/server/lib/client.test.ts @@ -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); + }); +}); diff --git a/dynamic-yield/server/lib/client.ts b/dynamic-yield/server/lib/client.ts new file mode 100644 index 00000000..4d90672c --- /dev/null +++ b/dynamic-yield/server/lib/client.ts @@ -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> { + 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), traceId }; +} diff --git a/dynamic-yield/server/lib/tool.ts b/dynamic-yield/server/lib/tool.ts new file mode 100644 index 00000000..625ab32a --- /dev/null +++ b/dynamic-yield/server/lib/tool.ts @@ -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, +>(config: { + id: string; + description: string; + inputSchema: TSchema; + annotations: ToolAnnotations; + handler: ( + input: z.infer, + env: Env, + ) => Promise>; +}) { + 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, runtimeContext.env as Env), + }); +} diff --git a/dynamic-yield/server/main.ts b/dynamic-yield/server/main.ts new file mode 100644 index 00000000..2d3b3a02 --- /dev/null +++ b/dynamic-yield/server/main.ts @@ -0,0 +1,17 @@ +import { withRuntime } from "@decocms/runtime"; +import { serve } from "@decocms/mcps-shared/serve"; +import { tools } from "./tools/index.ts"; +import { type Env, StateSchema } from "./types/env.ts"; + +export type { Env }; +export { StateSchema }; + +const runtime = withRuntime({ + configuration: { state: StateSchema }, + tools, +}); + +/** Served without `withAuth`: the Authorization header carries the user's DY API key. Tracked in `auth-exemptions.json`. */ +if (runtime.fetch) { + serve(runtime.fetch); +} diff --git a/dynamic-yield/server/tools/collect.ts b/dynamic-yield/server/tools/collect.ts new file mode 100644 index 00000000..e20f0bb8 --- /dev/null +++ b/dynamic-yield/server/tools/collect.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { dyFetch } from "../lib/client.ts"; +import { createDyTool } from "../lib/tool.ts"; +import { + buildContext, + buildIdentity, + identitySchema, + pageSchema, +} from "./serve.ts"; + +const WRITES_REAL_DATA = + "This writes real data to the Dynamic Yield site and affects its analytics; use a test user (a dedicated dyid)."; + +export const trackPageviewTool = createDyTool({ + id: "DY_TRACK_PAGEVIEW", + description: `Report a pageview to Dynamic Yield for a page context. ${WRITES_REAL_DATA}`, + inputSchema: z.object({ ...identitySchema, ...pageSchema }), + annotations: { readOnlyHint: false, openWorldHint: true }, + handler: (input, env) => + dyFetch(env, "/collect/user/pageview", { + body: { ...buildIdentity(input), context: buildContext(input) }, + }), +}); + +export const trackEventsTool = createDyTool({ + id: "DY_TRACK_EVENTS", + description: `Report events to Dynamic Yield (add-to-cart, purchase, remove-from-cart, sync-cart, identify, login, signup, newsletter or custom events). ${WRITES_REAL_DATA}`, + inputSchema: z.object({ + ...identitySchema, + events: z + .array( + z.object({ + name: z.string().describe('Event name, e.g. "Add to Cart".'), + properties: z + .record(z.string(), z.unknown()) + .default({}) + .describe( + 'Event properties. Predefined events need dyType, e.g. {"dyType":"add-to-cart-v1","value":10,"currency":"BRL","productId":"sku-1","quantity":1,"cart":[...]}. Purchases should set uniqueTransactionId.', + ), + }), + ) + .min(1), + }), + annotations: { readOnlyHint: false, openWorldHint: true }, + handler: (input, env) => + dyFetch(env, "/collect/user/event", { + body: { ...buildIdentity(input), events: input.events }, + }), +}); + +const variations = z + .array(z.number().int()) + .optional() + .describe("Variation ids from the choose response."); + +export const trackEngagementTool = createDyTool({ + id: "DY_TRACK_ENGAGEMENT", + description: `Report engagement with a choose result: CLICK or IMP on a decisionId, or SLOT_CLICK on a recommendation slotId. ${WRITES_REAL_DATA}`, + inputSchema: z.object({ + ...identitySchema, + engagements: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.enum(["CLICK", "IMP"]), + decisionId: z.string(), + variations, + }), + z.object({ + type: z.literal("SLOT_CLICK"), + slotId: z.string(), + variations, + }), + ]), + ) + .min(1), + }), + annotations: { readOnlyHint: false, openWorldHint: true }, + handler: (input, env) => + dyFetch(env, "/collect/user/engagement", { + body: { ...buildIdentity(input), engagements: input.engagements }, + }), +}); + +export const collectTools = [ + trackPageviewTool, + trackEventsTool, + trackEngagementTool, +]; diff --git a/dynamic-yield/server/tools/feed.ts b/dynamic-yield/server/tools/feed.ts new file mode 100644 index 00000000..a916262b --- /dev/null +++ b/dynamic-yield/server/tools/feed.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { dyFetch } from "../lib/client.ts"; +import { createDyTool } from "../lib/tool.ts"; + +const feedId = z + .string() + .regex(/^\d+$/) + .describe( + "Numeric product feed id (Assets › Data Feeds, source Sync via API).", + ); + +export const FeedBulkInputSchema = z.object({ + feedId, + requests: z + .array( + z + .object({ + id: z.string().min(1).describe("Product SKU."), + action: z + .enum(["update", "partial", "delete"]) + .describe( + "update: full upsert. partial: change only the sent fields. delete: remove the SKU.", + ), + data: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Feed row: sku, group_id, name, url, price, in_stock, image_url, categories (pipe-delimited), plus custom columns. Omit for delete.", + ), + }) + .refine((r) => r.action === "delete" || r.data !== undefined, { + message: "data is required for update and partial", + }), + ) + .min(1) + .max(100) + .describe("Up to 100 actions per call (DY limit)."), +}); + +export const feedBulkTool = createDyTool({ + id: "DY_FEED_BULK", + description: + "Upsert, partially update or delete products in a Dynamic Yield API-synced product feed. Returns a transaction_id; check it with DY_FEED_TRANSACTION_STATUS. Changes apply to the live catalog used by recommendations. The API key needs the Feed ACL.", + inputSchema: FeedBulkInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }, + handler: ({ feedId, requests }, env) => + dyFetch(env, `/feeds/${feedId}/bulk`, { + body: { + requests: requests.map(({ id, action, data }) => ({ + id, + action, + ...(data && { body: { data } }), + })), + }, + }), +}); + +export const feedTransactionStatusTool = createDyTool({ + id: "DY_FEED_TRANSACTION_STATUS", + description: + "Get the per-item status (success or failed) of a DY_FEED_BULK transaction, optionally for a single SKU. Items should settle within about 90 seconds.", + inputSchema: z.object({ + feedId, + transactionId: z.string().min(1), + itemId: z.string().optional().describe("Only this SKU."), + }), + annotations: { readOnlyHint: true, openWorldHint: true }, + handler: ({ feedId, transactionId, itemId }, env) => + dyFetch( + env, + `/feeds/${feedId}/transaction/${encodeURIComponent(transactionId)}${ + itemId ? `/item/${encodeURIComponent(itemId)}` : "" + }`, + { method: "GET" }, + ), +}); + +export const feedTools = [feedBulkTool, feedTransactionStatusTool]; diff --git a/dynamic-yield/server/tools/index.ts b/dynamic-yield/server/tools/index.ts new file mode 100644 index 00000000..dc3d5d87 --- /dev/null +++ b/dynamic-yield/server/tools/index.ts @@ -0,0 +1,11 @@ +import { collectTools } from "./collect.ts"; +import { feedTools } from "./feed.ts"; +import { profileTools } from "./profile.ts"; +import { serveTools } from "./serve.ts"; + +export const tools = [ + ...serveTools, + ...collectTools, + ...feedTools, + ...profileTools, +]; diff --git a/dynamic-yield/server/tools/profile.ts b/dynamic-yield/server/tools/profile.ts new file mode 100644 index 00000000..d563ed81 --- /dev/null +++ b/dynamic-yield/server/tools/profile.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { dyFetch } from "../lib/client.ts"; +import { createDyTool } from "../lib/tool.ts"; + +export const userProfileTool = createDyTool({ + id: "DY_USER_PROFILE", + description: + "Read a user's Dynamic Yield profile through Profile Anywhere, optionally with affinity scores. Requires profileAnywhereKey in the connection configuration.", + inputSchema: z.object({ + cuid: z.string().min(1).describe("Customer id value."), + cuidType: z + .string() + .default("external") + .describe( + 'Customer id type, e.g. "external", "he" (hashed email) or "dyid".', + ), + affinity: z.boolean().default(true).describe("Include affinity scores."), + }), + annotations: { readOnlyHint: true, openWorldHint: true }, + handler: ({ cuid, cuidType, affinity }, env) => { + const apiKey = env.MESH_REQUEST_CONTEXT?.state?.profileAnywhereKey; + if (!apiKey) { + throw new Error( + "profileAnywhereKey is not configured. Add the Profile Anywhere API key to the connection configuration.", + ); + } + const query = new URLSearchParams({ + cuid, + cuidType, + affinity: String(affinity), + }); + return dyFetch(env, `/userprofile?${query}`, { method: "GET", apiKey }); + }, +}); + +export const profileTools = [userProfileTool]; diff --git a/dynamic-yield/server/tools/serve.ts b/dynamic-yield/server/tools/serve.ts new file mode 100644 index 00000000..3656826b --- /dev/null +++ b/dynamic-yield/server/tools/serve.ts @@ -0,0 +1,180 @@ +import { z } from "zod"; +import { dyFetch } from "../lib/client.ts"; +import { createDyTool } from "../lib/tool.ts"; + +export const identitySchema = { + dyid: z + .string() + .optional() + .describe( + "Value of the _dyid / _dyid_server cookie. Omit to act as a brand-new user.", + ), + session: z + .string() + .optional() + .describe("Value of the _dyjsession cookie. Omit to start a new session."), + activeConsentAccepted: z + .boolean() + .optional() + .describe("Only needed when the site uses active consent."), +}; + +export const pageSchema = { + pageType: z + .enum(["HOMEPAGE", "CATEGORY", "PRODUCT", "CART", "OTHER"]) + .describe("DY page context type."), + pageData: z + .array(z.string()) + .default([]) + .describe( + "PRODUCT: [sku]. CATEGORY: category path, one entry per level. CART: cart SKUs. OTHER: [page id]. HOMEPAGE: [].", + ), + location: z.string().url().describe("Full page URL the request simulates."), + locale: z + .string() + .optional() + .describe('Page locale, e.g. "pt_BR" or "en_US".'), + referrer: z.string().optional(), + userAgent: z.string().optional(), + ip: z.string().optional(), + channel: z.enum(["WEB", "APP"]).optional(), +}; + +type Identity = { + dyid?: string; + session?: string; + activeConsentAccepted?: boolean; +}; +type Page = { + pageType: string; + pageData: string[]; + location: string; + locale?: string; + referrer?: string; + userAgent?: string; + ip?: string; + channel?: string; +}; + +export function buildIdentity(input: Identity) { + return { + user: { + ...(input.dyid && { dyid: input.dyid, dyid_server: input.dyid }), + ...(input.activeConsentAccepted !== undefined && { + active_consent_accepted: input.activeConsentAccepted, + }), + }, + session: input.session ? { dy: input.session } : {}, + }; +} + +export function buildContext( + input: Page & { pageAttributes?: Record }, +) { + return { + page: { + type: input.pageType, + data: input.pageData, + location: input.location, + ...(input.locale && { locale: input.locale }), + ...(input.referrer && { referrer: input.referrer }), + }, + ...((input.userAgent || input.ip) && { + device: { + ...(input.userAgent && { userAgent: input.userAgent }), + ...(input.ip && { ip: input.ip }), + }, + }), + ...(input.channel && { channel: input.channel }), + ...(input.pageAttributes && { pageAttributes: input.pageAttributes }), + }; +} + +export const chooseTool = createDyTool({ + id: "DY_CHOOSE", + description: + "Run Dynamic Yield's Experience API choose call for one page context and return the chosen variations: campaign payloads, recommendation slots (SKUs and product data), decision ids, analytics metadata and cookies. Use it to QA campaigns and recommendation selectors, including unpublished ones via a preview token. By default it does not count as a pageview.", + inputSchema: z + .object({ + selectorNames: z + .array(z.string()) + .default([]) + .describe("Campaign API selector names to evaluate."), + selectorGroups: z + .array(z.string()) + .default([]) + .describe("Selector groups to evaluate."), + ...identitySchema, + ...pageSchema, + pageAttributes: z + .record(z.string(), z.string()) + .optional() + .describe( + "Real-time targeting/filter attributes (case-sensitive, not stored).", + ), + previewToken: z + .string() + .optional() + .describe( + "Value of the dyApiPreview URL parameter from a DY preview link, to see unpublished variations.", + ), + recommendationArgs: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "selector.args passed as-is, e.g. real-time recommendation filters.", + ), + skusOnly: z + .boolean() + .default(false) + .describe("Return only SKUs in recommendation slots."), + productFields: z + .array(z.string()) + .optional() + .describe( + "Restrict recommendation productData to these feed fields. Ignored if skusOnly.", + ), + returnAnalyticsMetadata: z.boolean().default(true), + isImplicitPageview: z + .boolean() + .default(false) + .describe( + "Also report a pageview. Leave false for QA to avoid polluting analytics.", + ), + deduplicateRecommendations: z.boolean().optional(), + }) + .refine( + (input) => input.selectorNames.length + input.selectorGroups.length > 0, + { message: "Provide at least one selector name or group" }, + ), + annotations: { readOnlyHint: true, openWorldHint: true }, + handler: (input, env) => + dyFetch(env, "/serve/user/choose", { + body: { + ...buildIdentity(input), + context: buildContext(input), + selector: { + names: input.selectorNames, + ...(input.selectorGroups.length && { groups: input.selectorGroups }), + ...(input.previewToken && { + preview: { ids: [input.previewToken] }, + }), + ...(input.recommendationArgs && { args: input.recommendationArgs }), + }, + options: { + isImplicitPageview: input.isImplicitPageview, + returnAnalyticsMetadata: input.returnAnalyticsMetadata, + ...(input.deduplicateRecommendations !== undefined && { + deduplicateRecommendations: input.deduplicateRecommendations, + }), + ...(input.skusOnly + ? { recsProductData: { skusOnly: true } } + : input.productFields && { + recsProductData: { fieldFilter: input.productFields }, + }), + }, + }, + }), +}); + +export const serveTools = [chooseTool]; diff --git a/dynamic-yield/server/types/env.ts b/dynamic-yield/server/types/env.ts new file mode 100644 index 00000000..2b9c0f15 --- /dev/null +++ b/dynamic-yield/server/types/env.ts @@ -0,0 +1,19 @@ +import type { DefaultEnv } from "@decocms/runtime"; +import { z } from "zod"; + +export const StateSchema = z.object({ + region: z + .enum(["us", "eu"]) + .default("us") + .describe( + "Dynamic Yield data center of the site. US uses dy-api.com, EU uses dy-api.eu.", + ), + profileAnywhereKey: z + .string() + .optional() + .describe( + "Optional Profile Anywhere API key (created in the Profile Anywhere app, not in Settings › API Keys). Required only by DY_USER_PROFILE.", + ), +}); + +export type Env = DefaultEnv; diff --git a/dynamic-yield/tsconfig.json b/dynamic-yield/tsconfig.json new file mode 100644 index 00000000..f3687373 --- /dev/null +++ b/dynamic-yield/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "ES2024"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "types": ["bun-types"], + "allowJs": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "server/*": ["./server/*"] + } + }, + "include": ["server"] +} diff --git a/package.json b/package.json index a217f4db..e86da9b2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "discord", "discord-read", "dropbox", + "dynamic-yield", "farmrio-reorder-collection-db", "flux", "gemini-pro-vision", diff --git a/registry.json b/registry.json index 62197fef..7f136966 100644 --- a/registry.json +++ b/registry.json @@ -1499,6 +1499,54 @@ ] } }, + { + "id": "deco/dynamic-yield", + "title": "Dynamic Yield", + "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.", + "is_public": true, + "_meta": { + "mcp.mesh": { + "verified": false, + "friendly_name": "Dynamic Yield", + "short_description": "QA Dynamic Yield campaigns and recommendations, report events and sync the product feed.", + "owner": "deco", + "has_remote": true, + "has_oauth": false, + "tags": [ + "dynamic-yield", + "personalization", + "recommendations", + "experience-api", + "product-feed", + "ab-testing", + "ecommerce" + ], + "categories": [ + "E-commerce" + ], + "readme": "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." + } + }, + "server": { + "name": "dynamic-yield", + "title": "Dynamic Yield", + "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.", + "icons": [ + { + "src": "https://avatars.githubusercontent.com/u/12912945?s=256&v=4" + } + ], + "remotes": [ + { + "type": "HTTP", + "url": "https://sites-dynamic-yield.deco.site/mcp", + "name": "dynamic-yield", + "title": "Dynamic Yield", + "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." + } + ] + } + }, { "id": "deco/flux", "title": "FLUX Image Generation",