x
This commit is contained in:
1 parent
9237c0eded
commit
f5893c3fad
16 files changed
+1230
-114
No files matched your search
@@ -1,2 +1,13 @@
|
||||
# Backend target used by the MCP server. Override per environment as needed.
|
||||
MCP_BACKEND_URL=https://api.ddc.kittisak.com/api
|
||||
MCP_BACKEND_URL=http://localhost:3051/api
|
||||
|
||||
# Optional CMS image-upload staging directory. Must be an absolute local path.
|
||||
# Only images inside this directory can be uploaded by upload_cms_asset.
|
||||
# MCP_ASSET_ROOT=C:\private\dbk-mcp-assets
|
||||
|
||||
# Optional multi-customer profile mode. Use an absolute path to a private JSON
|
||||
# registry outside Git. Each profile references distinct credential env names.
|
||||
# MCP_TARGET_PROFILES_FILE=C:\private\dbk-mcp-target-profiles.json
|
||||
# MCP_DEFAULT_TARGET_PROFILE=customer-a-local
|
||||
# MCP_CUSTOMER_A_LOCAL_USERNAME=provided-by-secret-store
|
||||
# MCP_CUSTOMER_A_LOCAL_PASSWORD=provided-by-secret-store
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> HomAroma is the reference tenant. This server exposes shared MCP capabilities; a tool call must use the active tenant's data and configuration.
|
||||
|
||||
MCP server สำหรับเชื่อม AI tools เข้ากับ backend ของ HomAroma ผ่าน stdio transport
|
||||
MCP server สำหรับเชื่อม AI tools เข้ากับ backend ของ DBK Platform ผ่าน stdio transport โดยใช้ข้อมูลและการตั้งค่าของ tenant ที่กำลังเชื่อมต่อ
|
||||
|
||||
## Run
|
||||
|
||||
@@ -20,7 +20,27 @@ bun run dev
|
||||
|
||||
## Environment
|
||||
|
||||
รองรับตัวแปรต่อไปนี้:
|
||||
### Multi-customer target profiles (recommended)
|
||||
|
||||
MCP process เดียวสลับ backend ลูกค้าหลายรายได้ด้วย allowlist ใน private JSON registry ตัวอย่างโครงสร้างอยู่ที่ [target-profiles.example.json](./target-profiles.example.json) ให้คัดลอกไปยัง absolute path **นอก Git** แล้วกำหนด:
|
||||
|
||||
- `MCP_TARGET_PROFILES_FILE` = absolute path ของ registry
|
||||
- `MCP_DEFAULT_TARGET_PROFILE` = profile ID ที่เริ่มใช้งานทุกครั้งหลัง restart
|
||||
- ตัวแปร username/password ที่แต่ละ profile ระบุใน `usernameEnv` และ `passwordEnv` ผ่าน secret store หรือ environment ของ MCP client
|
||||
|
||||
Registry เก็บเพียง `id`, `displayName`, `kind` (`local`/`staging`/`production`), `baseUrl`, และ **ชื่อตัวแปร** credential; ห้ามใส่รหัสผ่านจริงใน JSON หรือ Git แต่ละ profile ต้องใช้ชื่อตัวแปร credential แยกกัน ไม่รับ URL ซ้ำกัน และตรวจว่าทุก secret มีค่าก่อนเริ่ม server URL ระยะไกลต้องเป็น HTTPS; HTTP อนุญาตเฉพาะ loopback หาก config ผิด server จะไม่เริ่ม
|
||||
|
||||
ใน profile mode ใช้ `list_target_profiles` → `select_target_profile` ด้วย ID → `get_target_environment` เพื่อยืนยัน URL ก่อนเขียน ทุก backend-facing tool ต้องส่ง `expectedProfileId` ให้ตรงกับ active profile; หากแชตอื่นสลับเป้าหมายระหว่างนั้น คำขอจะถูกปฏิเสธก่อนแตะ backend คำขอ MCP ถูกประมวลผลแบบเรียงลำดับเพื่อไม่ให้การสลับ target ตัดกลางคำขอ; auth token เก็บแยกตาม profile การเรียก `set_target_environment` รวมทั้ง `customUrl` จะถูกปฏิเสธ และไม่ fallback ไป URL หรือ admin credential ชุด legacy การเปลี่ยน active profile มีผลเฉพาะ process ปัจจุบัน; restart กลับไป `MCP_DEFAULT_TARGET_PROFILE`
|
||||
|
||||
Registry เป็น allowlist ของปลายทาง **ไม่ใช่ระบบ authorization แยกตามแชต/ผู้ใช้** ทุก client ที่เข้าถึง MCP process เดียวกันสามารถเลือกทุก profile ใน registry ได้ และ `expectedProfileId` ป้องกันการส่งคำขอไปผิดเป้าหมายโดยไม่ตั้งใจเท่านั้น หากลูกค้าอยู่คนละ trust group ให้ใช้ MCP process และ registry แยกกัน
|
||||
|
||||
`upload_product_image_from_url` ถูกซ่อนและปฏิเสธใน profile mode เพราะตัวดาวน์โหลดเดิมรับ URL ภายนอกโดยไม่มี outbound allowlist หรือ resource limits; legacy mode ยังคง behavior เดิม ระหว่างนี้ให้ใช้ workflow อัปโหลดที่ผ่านการตรวจความปลอดภัยจากระบบอื่น
|
||||
|
||||
**Migration:** ตั้ง registry และ secret environment ทุก profile ให้ครบก่อนเปิด `MCP_TARGET_PROFILES_FILE` แล้ว restart MCP client ตรวจ `get_target_environment` ว่า mode เป็น `profiles` และ ID/URL ถูกต้องก่อนใช้ tool เขียนข้อมูล อย่าคัดลอก URL หรือ credential ของลูกค้าจริงลงตัวอย่างใน repository
|
||||
|
||||
### Legacy mode (no registry)
|
||||
|
||||
รองรับตัวแปรต่อไปนี้เพื่อความเข้ากันได้:
|
||||
|
||||
- `MCP_BACKEND_URL` สำหรับกำหนด backend target ตรง ๆ
|
||||
- `MCP_BACKEND_URL_LOCAL` ค่าเริ่มต้นสำหรับ local
|
||||
@@ -30,12 +50,35 @@ bun run dev
|
||||
|
||||
ถ้าไม่ตั้งค่า `MCP_BACKEND_URL` server จะเริ่มที่ local backend และสลับเป็น production ได้ด้วย tool `set_target_environment`
|
||||
|
||||
ชื่อ `homaroma` ที่ยังอยู่ใน package, server identity, config key หรือ URL เป็น legacy runtime identifier เพื่อความเข้ากันได้ ไม่ใช่ชื่อแพลตฟอร์มหรือค่าแบรนด์สำหรับทุก tenant การเปลี่ยน identifier เหล่านี้ต้องทำเป็น migration แยกต่างหาก
|
||||
|
||||
ข้อควรระวังใน legacy mode: `set_target_environment` ยังใช้ admin credential ชุดเดียวกับทุก backend; `production` ยังมี HomAroma URL เป็น fallback หากไม่ได้กำหนด `MCP_BACKEND_URL_PRODUCTION` และ `customUrl` รับ URL อิสระ จึงไม่ควรใช้ legacy mode เพื่อสลับหลายลูกค้า
|
||||
|
||||
legacy mode ยังมีค่า admin สำรองในโค้ดตามการตัดสินใจชั่วคราวของเจ้าของโปรเจกต์ ควรกำหนด `MCP_ADMIN_USERNAME` และ `MCP_ADMIN_PASSWORD` ในสภาพแวดล้อมของ MCP client และย้ายออกจากค่าสำรองในรอบ migration ถัดไป อย่าใส่รหัสผ่านในแชตหรือ commit ลง repository ค่า fallback นี้ **ไม่ถูกใช้ใน profile mode**
|
||||
|
||||
## Available tools
|
||||
|
||||
- `upload_cms_asset` อัปโหลด JPEG/PNG/WebP/GIF จากไฟล์ในเครื่องเข้า Media Library ของ backend ปัจจุบัน แล้วคืน `url` และ `path` สำหรับ `ARTICLE_MEDIA` / `ARTICLE_GALLERY` โดยไม่ผูกกับสินค้า ต้องตั้ง `MCP_ASSET_ROOT` เป็น absolute directory ของไฟล์ที่อนุญาตก่อน restart; ส่ง `filePath` แบบ absolute ภายใน directory นั้น ขนาดไม่เกิน 10 MiB และใน profile mode ต้องส่ง `expectedProfileId` ด้วย Tool ตรวจ real path, ชนิดไฟล์จากนามสกุลและ magic bytes; ไม่รับ URL ภายนอก, SVG หรือ base64 การอัปโหลดสร้าง Media record แม้ยังไม่เพิ่มภาพลงหน้า CMS ให้ตรวจ target ด้วย `get_target_environment` ก่อนเสมอ
|
||||
- `update_brand_name` เปลี่ยนชื่อแบรนด์กลางที่ใช้ใน header/footer ของ `apps/spa` และ `apps/website`
|
||||
- `update_store_settings_translation` อัปเดตข้อความแปลของ store settings
|
||||
- `update_footer_content` อัปเดตคำอธิบายแบรนด์และ social links เท่านั้น
|
||||
- `get_footer_brand` / `update_footer_brand` อ่านและแก้ Footer Brand block โดยตรง (brand name, logo, คำอธิบายแยกภาษา, social links) พร้อม `dryRun` และ `expectedVersion`
|
||||
- `get_footer_layout` / `patch_footer_layout` อ่านและแก้โครง Footer พร้อม preview และการตรวจ version ก่อนเขียน
|
||||
- `get_category_sections` / `seed_category_sections` / `add_category_section` / `update_category_section` / `reorder_category_sections` / `delete_category_section` จัดการ section ของหน้า Category
|
||||
|
||||
Tool definitions มี annotation บอกการอ่านอย่างเดียวหรือการลบ และผลลัพธ์ JSON มี `structuredContent` เพิ่มจากข้อความเดิมเพื่อให้ LLM ใช้ง่ายขึ้น โดยยังเข้ากันได้กับ client ที่อ่าน `content` อยู่
|
||||
|
||||
ตัวอย่างจาก `get_page_block_schema` เป็นโครง payload สำหรับเริ่มต้น ไม่ใช่เนื้อหาที่ควรเผยแพร่ทันที ให้แทน URL ตัวอย่าง ข้อความ ผู้เขียน และช่องทางติดต่อด้วยข้อมูลที่ตรวจสอบแล้วของ tenant ปัจจุบันก่อนเขียนจริง โดยเฉพาะ `CONTACT_INFO` ที่มี email และ LINE ID เป็น placeholder
|
||||
|
||||
## Safe editing workflow
|
||||
|
||||
1. เรียก `get_target_environment` ก่อนแก้ข้อมูล โดยเฉพาะหลัง restart; ใน profile mode ยืนยันทั้ง profile ID และ URL อย่าเขียน production ถ้าผู้ใช้ไม่ได้ขอชัดเจน
|
||||
2. อ่านข้อมูลเป้าหมายก่อน แล้วเลือก tool ที่เฉพาะงาน
|
||||
3. ใช้ `dryRun: true` เมื่อมี, ตรวจผล preview แล้วค่อยเขียนจริง
|
||||
4. ส่ง `expectedVersion` สำหรับ Footer layout และงานอื่นที่รองรับ version
|
||||
5. อ่านข้อมูลซ้ำหลังเขียนเพื่อตรวจผล
|
||||
|
||||
Category section tools ทำ preview ฝั่ง MCP เท่านั้น เพราะ backend ปัจจุบันยังไม่มี `dryRun` หรือ version check แบบ atomic สำหรับ endpoint กลุ่มนี้ ข้อมูลอาจเปลี่ยนระหว่าง preview กับการเขียนจริง; ให้ตรวจรายการล่าสุดก่อนเขียนเสมอ
|
||||
|
||||
## Footer Menu LLM Workflow
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import axios from "axios";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { handleAssetTool } from "./asset-toolkit";
|
||||
|
||||
const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]);
|
||||
const parsed = (value: Awaited<ReturnType<typeof handleAssetTool>>) => JSON.parse(value!.content[0].text);
|
||||
|
||||
test("upload_cms_asset validates local image scope before backend access", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "dbk-assets-"));
|
||||
const root = join(dir, "allowed");
|
||||
mkdirSync(root);
|
||||
const valid = join(root, "photo.png");
|
||||
const outside = join(dir, "outside.png");
|
||||
const invalid = join(root, "wrong.png");
|
||||
const big = join(root, "big.png");
|
||||
writeFileSync(valid, png);
|
||||
writeFileSync(outside, png);
|
||||
writeFileSync(invalid, Buffer.from("not an image"));
|
||||
writeFileSync(big, Buffer.alloc(10 * 1024 * 1024 + 1));
|
||||
const seen: Array<{ url: string; body: any }> = [];
|
||||
const api = axios.create();
|
||||
api.post = async (url: string, body: any) => {
|
||||
seen.push({ url, body });
|
||||
return { data: { success: true, url: "http://localhost:3051/uploads/new.png", path: "/uploads/new.png" } } as any;
|
||||
};
|
||||
try {
|
||||
const noRoot = await handleAssetTool(api, "upload_cms_asset", { filePath: valid }, "");
|
||||
expect(noRoot?.isError).toBe(true);
|
||||
const outsideResult = await handleAssetTool(api, "upload_cms_asset", { filePath: outside }, root);
|
||||
expect(parsed(outsideResult).code).toBe("FILE_OUTSIDE_ASSET_ROOT");
|
||||
expect(parsed(await handleAssetTool(api, "upload_cms_asset", { filePath: invalid }, root)).code).toBe("INVALID_IMAGE_TYPE");
|
||||
expect(parsed(await handleAssetTool(api, "upload_cms_asset", { filePath: big }, root)).code).toBe("INVALID_IMAGE_SIZE");
|
||||
const link = join(root, "escape.png");
|
||||
try {
|
||||
symlinkSync(outside, link, "file");
|
||||
expect(parsed(await handleAssetTool(api, "upload_cms_asset", { filePath: link }, root)).code).toBe("FILE_OUTSIDE_ASSET_ROOT");
|
||||
} catch (error: any) {
|
||||
// Windows without Developer Mode may disallow creating test symlinks.
|
||||
if (error?.code !== "EPERM") throw error;
|
||||
}
|
||||
expect(seen).toHaveLength(0);
|
||||
const success = await handleAssetTool(api, "upload_cms_asset", { filePath: valid }, root);
|
||||
expect(parsed(success)).toEqual({ success: true, url: "http://localhost:3051/uploads/new.png", path: "/uploads/new.png", mimeType: "image/png", sizeBytes: png.length });
|
||||
expect(JSON.stringify(success)).not.toContain(valid);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.url).toBe("/upload");
|
||||
expect(seen[0]?.body.getBuffer().toString("latin1")).toContain('name="file"; filename="photo.png"');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("upload_cms_asset obeys profile binding and sends authenticated multipart", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "dbk-assets-profile-"));
|
||||
const root = join(dir, "allowed");
|
||||
mkdirSync(root);
|
||||
const image = join(root, "article.png");
|
||||
const registry = join(dir, "profiles.json");
|
||||
writeFileSync(image, png);
|
||||
const uploads: Array<{ contentType: string; authorization: string; body: string }> = [];
|
||||
const backend = Bun.serve({ port: 0, fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname;
|
||||
if (path === "/api/auth/login") return Response.json({ token: "test-token" });
|
||||
if (path === "/api/upload") {
|
||||
uploads.push({ contentType: request.headers.get("content-type") ?? "", authorization: request.headers.get("authorization") ?? "", body: Buffer.from(await request.arrayBuffer()).toString("latin1") });
|
||||
return Response.json({ success: true, url: `${new URL(request.url).origin}/uploads/asset.png`, path: "/uploads/asset.png", filename: "asset.png" });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
} });
|
||||
writeFileSync(registry, JSON.stringify({ profiles: [{ id: "tenant-a", displayName: "Tenant A", kind: "local", baseUrl: `http://localhost:${backend.port}/api`, usernameEnv: "A_USER", passwordEnv: "A_PASS" }] }));
|
||||
const env = { ...process.env, MCP_TARGET_PROFILES_FILE: registry, MCP_DEFAULT_TARGET_PROFILE: "tenant-a", MCP_ASSET_ROOT: root, A_USER: "alice", A_PASS: "secret" } as Record<string, string>;
|
||||
const client = new Client({ name: "asset-upload-test", version: "1.0.0" });
|
||||
try {
|
||||
await client.connect(new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, "index.ts")], cwd: import.meta.dir, env, stderr: "pipe" }));
|
||||
const tool = (await client.listTools()).tools.find((item) => item.name === "upload_cms_asset");
|
||||
expect(tool?.inputSchema.required).toEqual(["filePath", "expectedProfileId"]);
|
||||
const stale = await client.callTool({ name: "upload_cms_asset", arguments: { filePath: image, expectedProfileId: "wrong" } });
|
||||
expect(stale.isError).toBe(true);
|
||||
expect(uploads).toHaveLength(0);
|
||||
const good = await client.callTool({ name: "upload_cms_asset", arguments: { filePath: image, expectedProfileId: "tenant-a" } });
|
||||
expect(good.isError).toBeFalsy();
|
||||
expect(good.structuredContent).toMatchObject({ success: true, path: "/uploads/asset.png" });
|
||||
expect(JSON.stringify(good)).not.toContain(image);
|
||||
expect(uploads).toHaveLength(1);
|
||||
expect(uploads[0]?.contentType).toContain("multipart/form-data; boundary=");
|
||||
expect(uploads[0]?.authorization).toBe("Bearer test-token");
|
||||
expect(uploads[0]?.body).toContain('name="file"; filename="article.png"');
|
||||
} finally {
|
||||
await client.close();
|
||||
backend.stop(true);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import FormData from "form-data";
|
||||
import { open, realpath, stat } from "node:fs/promises";
|
||||
import { isAbsolute, relative, basename, sep } from "node:path";
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const IMAGE_TYPES: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
};
|
||||
|
||||
type ToolResult = { content: [{ type: "text"; text: string }]; isError?: true };
|
||||
const result = (value: unknown, isError = false): ToolResult => ({
|
||||
content: [{ type: "text", text: JSON.stringify(value) }],
|
||||
...(isError ? { isError: true as const } : {}),
|
||||
});
|
||||
const failure = (code: string, message: string): ToolResult => result({ success: false, code, message }, true);
|
||||
|
||||
export const ASSET_TOOL_DEFINITIONS = [{
|
||||
name: "upload_cms_asset",
|
||||
description: "Upload one local JPEG, PNG, WebP or GIF image to the active backend Media Library, without attaching it to a product. Requires MCP_ASSET_ROOT (absolute directory). filePath must be an absolute path inside that directory; max 10 MiB. Returns url and website-relative path for ARTICLE_MEDIA or ARTICLE_GALLERY. Confirm the active target first; this creates a media record even before a page uses the image.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filePath: { type: "string", description: "Absolute path to an existing image inside MCP_ASSET_ROOT. No remote URLs, SVG, or base64." },
|
||||
},
|
||||
required: ["filePath"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}] as const;
|
||||
|
||||
function detectedMime(buffer: Buffer): string | null {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
|
||||
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
|
||||
if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") return "image/webp";
|
||||
if (buffer.length >= 6 && ["GIF87a", "GIF89a"].includes(buffer.toString("ascii", 0, 6))) return "image/gif";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleAssetTool(api: AxiosInstance, name: string, rawParams: unknown, assetRoot = process.env.MCP_ASSET_ROOT): Promise<ToolResult | null> {
|
||||
if (name !== "upload_cms_asset") return null;
|
||||
const params = rawParams && typeof rawParams === "object" && !Array.isArray(rawParams) ? rawParams as Record<string, unknown> : {};
|
||||
if (typeof assetRoot !== "string" || !isAbsolute(assetRoot)) return failure("ASSET_ROOT_REQUIRED", "Set MCP_ASSET_ROOT to an absolute image staging directory and restart MCP.");
|
||||
if (typeof params.filePath !== "string" || !isAbsolute(params.filePath)) return failure("INVALID_FILE_PATH", "filePath must be an absolute local path inside MCP_ASSET_ROOT.");
|
||||
|
||||
let buffer: Buffer;
|
||||
let fileName: string;
|
||||
let mime: string;
|
||||
try {
|
||||
const canonicalRoot = await realpath(assetRoot);
|
||||
if (!(await stat(canonicalRoot)).isDirectory()) return failure("INVALID_ASSET_ROOT", "MCP_ASSET_ROOT must be a directory.");
|
||||
const canonicalFile = await realpath(params.filePath);
|
||||
const subpath = relative(canonicalRoot, canonicalFile);
|
||||
if (!subpath || subpath === ".." || subpath.startsWith(`..${sep}`) || isAbsolute(subpath)) {
|
||||
return failure("FILE_OUTSIDE_ASSET_ROOT", "Image is outside MCP_ASSET_ROOT.");
|
||||
}
|
||||
const fileInfo = await stat(canonicalFile);
|
||||
if (!fileInfo.isFile()) return failure("INVALID_IMAGE_FILE", "filePath must refer to a regular file.");
|
||||
if (fileInfo.size === 0 || fileInfo.size > MAX_IMAGE_BYTES) return failure("INVALID_IMAGE_SIZE", "Image must be nonempty and at most 10 MiB.");
|
||||
fileName = basename(canonicalFile);
|
||||
const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||
const expectedMime = IMAGE_TYPES[extension];
|
||||
if (!expectedMime) return failure("INVALID_IMAGE_TYPE", "Only JPEG, PNG, WebP and GIF files are supported.");
|
||||
const handle = await open(canonicalFile, "r");
|
||||
try { buffer = await handle.readFile(); } finally { await handle.close(); }
|
||||
if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES) return failure("INVALID_IMAGE_SIZE", "Image must be nonempty and at most 10 MiB.");
|
||||
mime = detectedMime(buffer) ?? "";
|
||||
if (mime !== expectedMime) return failure("INVALID_IMAGE_TYPE", "Image content does not match its supported file extension.");
|
||||
} catch {
|
||||
return failure("IMAGE_NOT_READABLE", "Image or MCP_ASSET_ROOT is missing or cannot be read.");
|
||||
}
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", buffer, { filename: fileName, contentType: mime, knownLength: buffer.length });
|
||||
const response = await api.post("/upload", form, {
|
||||
headers: form.getHeaders(),
|
||||
maxBodyLength: MAX_IMAGE_BYTES + 1024 * 1024,
|
||||
maxContentLength: 1024 * 1024,
|
||||
});
|
||||
const data = response.data;
|
||||
if (data?.success !== true || typeof data.url !== "string" || typeof data.path !== "string" || !data.path.startsWith("/uploads/")) {
|
||||
return failure("INVALID_UPLOAD_RESPONSE", "Backend upload response did not include a usable media URL and path.");
|
||||
}
|
||||
return result({ success: true, url: data.url, path: data.path, mimeType: mime, sizeBytes: buffer.length });
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
return failure("UPLOAD_FAILED", `Backend upload failed${typeof status === "number" ? ` (HTTP ${status})` : ""}.`);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
|
||||
async function connectMcp(baseUrl: string) {
|
||||
const env = { ...process.env, MCP_BACKEND_URL: baseUrl } as Record<string, string>;
|
||||
delete env.ADMIN_USERNAME;
|
||||
delete env.ADMIN_PASSWORD;
|
||||
delete env.MCP_ADMIN_USERNAME;
|
||||
delete env.MCP_ADMIN_PASSWORD;
|
||||
env.MCP_ADMIN_USERNAME = "contract-test";
|
||||
env.MCP_ADMIN_PASSWORD = "contract-test";
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(import.meta.dir, "index.ts")],
|
||||
cwd: import.meta.dir,
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "mcp-contract-test", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
}
|
||||
|
||||
test("settings writes send only supported fields and simple product fields are advertised", async () => {
|
||||
const writes: unknown[] = [];
|
||||
let getSettingsCount = 0;
|
||||
const backend = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname;
|
||||
if (path === "/api/auth/login") return Response.json({ token: "contract-token" });
|
||||
if (path === "/api/settings/footer-layout" && request.method === "GET") {
|
||||
return Response.json({ version: "2026-10-11T00:00:00.000Z", layout: { columns: ["brand", "explore"] } });
|
||||
}
|
||||
if (path === "/api/settings" && request.method === "GET") {
|
||||
getSettingsCount++;
|
||||
return Response.json({ brandName: "Old", footerLayout: { columns: [] }, themeTokens: { brandAccent: "#123" } });
|
||||
}
|
||||
if (path === "/api/settings" && request.method === "PUT") {
|
||||
const body = await request.json();
|
||||
writes.push(body);
|
||||
return Response.json({ ...(body as object), themeTokens: { brandAccent: "#123", ...((body as any).themeTokens ?? {}) } });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
client = await connectMcp(`http://localhost:${backend.port}/api`);
|
||||
const tools = (await client.listTools()).tools;
|
||||
expect(tools).toHaveLength(113);
|
||||
expect(tools.find((item) => item.name === "get_footer_layout")?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(tools.find((item) => item.name === "delete_category_section")?.annotations?.destructiveHint).toBe(true);
|
||||
const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} });
|
||||
expect(footerLayout.isError).toBeFalsy();
|
||||
expect(footerLayout.structuredContent).toEqual({ version: "2026-10-11T00:00:00.000Z", layout: { columns: ["brand", "explore"] } });
|
||||
for (const name of ["create_product", "bulk_create_products"]) {
|
||||
const tool = tools.find((item) => item.name === name);
|
||||
expect(tool).toBeDefined();
|
||||
const properties = name === "create_product"
|
||||
? tool!.inputSchema.properties
|
||||
: (tool!.inputSchema.properties?.products as any).items.properties;
|
||||
for (const field of ["price", "stock", "sku", "barcode"]) {
|
||||
expect(properties?.[field]).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
expect((await client.callTool({ name: "update_theme_tokens", arguments: { brandPrimary: "#456" } })).isError).toBeFalsy();
|
||||
expect((await client.callTool({ name: "update_brand_name", arguments: { brandName: "New" } })).isError).toBeFalsy();
|
||||
expect((await client.callTool({ name: "update_footer_content", arguments: { brandDescription: "New footer", socialLinks: { facebookUrl: "https://example.com" } } })).isError).toBeFalsy();
|
||||
|
||||
expect(getSettingsCount).toBe(0);
|
||||
expect(writes).toEqual([
|
||||
{ themeTokens: { brandPrimary: "#456" } },
|
||||
{ brandName: "New" },
|
||||
{ footerDescription: "New footer", facebookUrl: "https://example.com" },
|
||||
]);
|
||||
} finally {
|
||||
await client?.close();
|
||||
backend.stop();
|
||||
}
|
||||
});
|
||||
|
||||
+141
-57
@@ -4,14 +4,41 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { CallToolRequest, CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import axios from "axios";
|
||||
import FormData from "form-data";
|
||||
import { MENU_TOOL_DEFINITIONS, getLegacyFooterMenuDeprecation, handleMenuTool } from "./menu-toolkit";
|
||||
import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit";
|
||||
import { STOREFRONT_TOOL_DEFINITIONS, handleStorefrontTool } from "./storefront-toolkit";
|
||||
import { ASSET_TOOL_DEFINITIONS, handleAssetTool } from "./asset-toolkit";
|
||||
import { presentToolDefinitions, withStructuredContent } from "./tool-presentation";
|
||||
import { loadTargetRegistry } from "./target-profiles";
|
||||
const targetRegistry = loadTargetRegistry();
|
||||
let activeProfileId = targetRegistry?.defaultProfileId;
|
||||
const profileTokens = new Map<string, string>();
|
||||
const TARGET_MANAGEMENT_TOOLS = new Set(["get_target_environment", "list_target_profiles", "select_target_profile", "set_target_environment"]);
|
||||
|
||||
function bindToolsToProfile(tools: Tool[]): Tool[] {
|
||||
if (!targetRegistry) return tools;
|
||||
return tools.filter((tool) => tool.name !== "upload_product_image_from_url").map((tool) => TARGET_MANAGEMENT_TOOLS.has(tool.name) ? tool : {
|
||||
...tool,
|
||||
description: `${tool.description ?? ""} In profile mode, pass expectedProfileId from get_target_environment; the call fails if another chat switched targets.`,
|
||||
inputSchema: {
|
||||
...tool.inputSchema,
|
||||
properties: {
|
||||
...tool.inputSchema.properties,
|
||||
expectedProfileId: { type: "string", description: "Active target profile ID to guard against cross-customer calls." },
|
||||
},
|
||||
required: [...new Set([...(tool.inputSchema.required ?? []), "expectedProfileId"])],
|
||||
},
|
||||
});
|
||||
}
|
||||
const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api";
|
||||
const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api";
|
||||
|
||||
let BACKEND_URL = process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
|
||||
let BACKEND_URL = targetRegistry
|
||||
? targetRegistry.profiles.find((profile) => profile.id === activeProfileId)!.baseUrl
|
||||
: process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
|
||||
const ADMIN_USERNAME = process.env.MCP_ADMIN_USERNAME || process.env.ADMIN_USERNAME || "admin";
|
||||
const ADMIN_PASSWORD = process.env.MCP_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || "homaroma123";
|
||||
const CATEGORY_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
|
||||
@@ -23,6 +50,7 @@ export const api = axios.create({
|
||||
});
|
||||
|
||||
function currentTargetEnvironment() {
|
||||
if (targetRegistry) return "profile";
|
||||
if (BACKEND_URL === LOCAL_BACKEND_URL) return "local";
|
||||
if (BACKEND_URL === PRODUCTION_BACKEND_URL) return "production";
|
||||
return "custom";
|
||||
@@ -53,28 +81,37 @@ function normalizeToolError(err: any) {
|
||||
// MCP server state is process-local because this server runs over stdio.
|
||||
|
||||
api.interceptors.request.use(async (config) => {
|
||||
if (!authToken && config.url !== "/auth/login") {
|
||||
if (targetRegistry && (!config.url?.startsWith("/") || config.url.startsWith("//") || config.baseURL !== BACKEND_URL)) {
|
||||
throw new Error("Profile requests must remain on the selected backend.");
|
||||
}
|
||||
const token = targetRegistry ? profileTokens.get(activeProfileId!) : authToken;
|
||||
if (!token && config.url !== "/auth/login") {
|
||||
console.error("[MCP] No token found. Attempting auto-login...");
|
||||
await performLogin();
|
||||
}
|
||||
if (authToken) {
|
||||
config.headers.Authorization = `Bearer ${authToken}`;
|
||||
const currentToken = targetRegistry ? profileTokens.get(activeProfileId!) : authToken;
|
||||
if (targetRegistry && !currentToken) throw new Error("Target profile authentication failed.");
|
||||
if (currentToken) {
|
||||
config.headers.Authorization = `Bearer ${currentToken}`;
|
||||
}
|
||||
if (targetRegistry) config.maxRedirects = 0;
|
||||
return config;
|
||||
});
|
||||
|
||||
async function performLogin() {
|
||||
try {
|
||||
const profile = targetRegistry?.profiles.find((item) => item.id === activeProfileId);
|
||||
const res = await axios.post(`${BACKEND_URL}/auth/login`, {
|
||||
username: ADMIN_USERNAME,
|
||||
password: ADMIN_PASSWORD,
|
||||
});
|
||||
username: profile ? process.env[profile.usernameEnv] : ADMIN_USERNAME,
|
||||
password: profile ? process.env[profile.passwordEnv] : ADMIN_PASSWORD,
|
||||
}, profile ? { maxRedirects: 0 } : undefined);
|
||||
if (res.data && res.data.token) {
|
||||
authToken = res.data.token;
|
||||
if (profile) profileTokens.set(profile.id, res.data.token);
|
||||
else authToken = res.data.token;
|
||||
console.error("[MCP] Successfully logged in to Backend API.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[MCP] Auto-login failed:", err.message);
|
||||
console.error("[MCP] Auto-login failed:", targetRegistry ? "profile authentication failed" : err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,12 +191,13 @@ const server = new Server(
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
instructions: "Check get_target_environment and confirm the exact customer backend URL before writes. In profile mode, use list_target_profiles and select_target_profile to switch approved customer backends; legacy arbitrary URL switching is disabled. Prefer compact discovery tools, then read the target record before editing. Use dryRun and expectedVersion where offered; verify the result by reading it back. Production writes require an explicit user request.",
|
||||
}
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
tools: presentToolDefinitions(bindToolsToProfile([
|
||||
{
|
||||
name: "get_target_environment",
|
||||
description: "Return the MCP server's current backend target environment and base URL. Use this before write operations when you need to confirm local vs production.",
|
||||
@@ -167,7 +205,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
name: "set_target_environment",
|
||||
description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.",
|
||||
description: "Legacy mode only: switch the backend target (local, production, or a trusted customUrl). Disabled when target profiles are configured; use select_target_profile instead.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -177,17 +215,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
customUrl: {
|
||||
type: "string",
|
||||
description: "Optional custom URL to use for the API (e.g., https://api.pkh.kitizone.com/api)"
|
||||
description: "Optional custom URL to use for the API (e.g., https://api.example.com/api)"
|
||||
}
|
||||
},
|
||||
required: ["env"]
|
||||
}
|
||||
},
|
||||
...(targetRegistry ? [
|
||||
{
|
||||
name: "list_target_profiles",
|
||||
description: "List approved customer backend profiles (ID, display name, kind, URL; no credentials). Read before selecting a customer.",
|
||||
inputSchema: { type: "object" as const, properties: {} },
|
||||
},
|
||||
{
|
||||
name: "select_target_profile",
|
||||
description: "Select an approved customer backend profile by ID. All later calls use only that profile's URL and credentials. Confirm the exact URL with get_target_environment before writes.",
|
||||
inputSchema: { type: "object" as const, properties: { profileId: { type: "string" } }, required: ["profileId"] },
|
||||
},
|
||||
] : []),
|
||||
...MENU_TOOL_DEFINITIONS,
|
||||
...SECTION_TOOL_DEFINITIONS,
|
||||
...STOREFRONT_TOOL_DEFINITIONS,
|
||||
...ASSET_TOOL_DEFINITIONS,
|
||||
{
|
||||
name: "ping_backend",
|
||||
description: "Ping the backend to verify connection and authentication",
|
||||
description: "Check backend connectivity through the public products endpoint. This does not verify admin authentication.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
@@ -525,7 +577,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
name: "create_product",
|
||||
description: "Create a new product, optionally including its options and variant matrix. Provide categoryId/brandId OR categoryName/brandName.",
|
||||
description: "Create a product. For a simple product without variants, pass root price and stock (and optional sku/barcode); omitted values default to 0. For products with variants, put price and stock on each variant. Provide categoryId/brandId OR categoryName/brandName.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -536,6 +588,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
details: { type: "string" },
|
||||
price: { type: "number", minimum: 0, description: "Simple product price; used only when variants are omitted." },
|
||||
stock: { type: "number", minimum: 0, description: "Simple product stock; used only when variants are omitted." },
|
||||
sku: { type: "string", description: "Simple product SKU; used only when variants are omitted." },
|
||||
barcode: { type: "string", description: "Simple product barcode; used only when variants are omitted." },
|
||||
translations: {
|
||||
type: "object",
|
||||
description: "Object with lang codes as keys (e.g. 'th', 'en') and {name, description, details} as values",
|
||||
@@ -593,7 +649,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
name: "bulk_create_products",
|
||||
description: "Create multiple products at once. Each object in the array has the same schema as create_product.",
|
||||
description: "Create multiple products. For each simple product without variants, pass root price and stock (and optional sku/barcode); omitted values default to 0. For products with variants, set price and stock on each variant.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -609,6 +665,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
details: { type: "string" },
|
||||
price: { type: "number", minimum: 0, description: "Simple product price; used only when variants are omitted." },
|
||||
stock: { type: "number", minimum: 0, description: "Simple product stock; used only when variants are omitted." },
|
||||
sku: { type: "string", description: "Simple product SKU; used only when variants are omitted." },
|
||||
barcode: { type: "string", description: "Simple product barcode; used only when variants are omitted." },
|
||||
translations: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
@@ -1081,11 +1141,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
] as Tool[])),
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
async function handleToolRequest(request: CallToolRequest) {
|
||||
const params = request.params.arguments as any;
|
||||
|
||||
try {
|
||||
@@ -1098,25 +1158,55 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (sectionToolResponse) {
|
||||
return sectionToolResponse;
|
||||
}
|
||||
const storefrontToolResponse = await handleStorefrontTool(api, request.params.name, params);
|
||||
if (storefrontToolResponse) {
|
||||
return storefrontToolResponse;
|
||||
}
|
||||
const assetToolResponse = await handleAssetTool(api, request.params.name, params);
|
||||
if (assetToolResponse) {
|
||||
return assetToolResponse;
|
||||
}
|
||||
|
||||
switch (request.params.name) {
|
||||
case "get_target_environment": {
|
||||
const activeProfile = targetRegistry?.profiles.find((profile) => profile.id === activeProfileId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
mode: targetRegistry ? "profiles" : "legacy",
|
||||
...(activeProfile ? { activeProfile: { id: activeProfile.id, displayName: activeProfile.displayName, kind: activeProfile.kind } } : {}),
|
||||
env: currentTargetEnvironment(),
|
||||
baseUrl: BACKEND_URL,
|
||||
localBaseUrl: LOCAL_BACKEND_URL,
|
||||
productionBaseUrl: PRODUCTION_BACKEND_URL,
|
||||
authenticated: Boolean(authToken),
|
||||
...(!targetRegistry ? { localBaseUrl: LOCAL_BACKEND_URL, productionBaseUrl: PRODUCTION_BACKEND_URL } : {}),
|
||||
authenticated: targetRegistry ? Boolean(profileTokens.get(activeProfileId!)) : Boolean(authToken),
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
case "list_target_profiles": {
|
||||
if (!targetRegistry) throw new Error("Target profiles are not configured.");
|
||||
return { content: [{ type: "text", text: JSON.stringify({
|
||||
activeProfileId,
|
||||
profiles: targetRegistry.profiles.map(({ id, displayName, kind, baseUrl }) => ({ id, displayName, kind, baseUrl })),
|
||||
}, null, 2) }] };
|
||||
}
|
||||
case "select_target_profile": {
|
||||
if (!targetRegistry) throw new Error("Target profiles are not configured.");
|
||||
const profile = targetRegistry.profiles.find((item) => item.id === params?.profileId);
|
||||
if (!profile) throw new Error("Unknown target profile ID.");
|
||||
activeProfileId = profile.id;
|
||||
BACKEND_URL = profile.baseUrl;
|
||||
api.defaults.baseURL = profile.baseUrl;
|
||||
return { content: [{ type: "text", text: JSON.stringify({
|
||||
mode: "profiles", activeProfile: { id: profile.id, displayName: profile.displayName, kind: profile.kind }, baseUrl: profile.baseUrl,
|
||||
authenticated: Boolean(profileTokens.get(profile.id)),
|
||||
}, null, 2) }] };
|
||||
}
|
||||
case "set_target_environment": {
|
||||
if (targetRegistry) throw new Error("Legacy target switching is disabled in profile mode. Use select_target_profile.");
|
||||
const env = params.env;
|
||||
const customUrl = params.customUrl;
|
||||
|
||||
@@ -1498,11 +1588,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
case "update_theme_tokens": {
|
||||
const tokenParams = params as Record<string, string>;
|
||||
|
||||
const currentSettingsRes = await api.get("/settings");
|
||||
const currentSettings = currentSettingsRes.data || {};
|
||||
const currentTokens = currentSettings.themeTokens || {};
|
||||
const newTokens = { ...currentTokens };
|
||||
const newTokens: Record<string, string> = {};
|
||||
|
||||
const validKeys = [
|
||||
"brandPrimary", "brandPrimaryHover", "brandAccent", "brandWarm",
|
||||
@@ -1520,15 +1606,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
const mergedSettings = { ...currentSettings, themeTokens: newTokens };
|
||||
|
||||
for (const key in mergedSettings) {
|
||||
if (mergedSettings[key] === null) {
|
||||
delete mergedSettings[key];
|
||||
}
|
||||
if (Object.keys(newTokens).length === 0) {
|
||||
throw new Error("At least one theme token must be supplied.");
|
||||
}
|
||||
|
||||
const res = await api.put("/settings", mergedSettings);
|
||||
const res = await api.put("/settings", { themeTokens: newTokens });
|
||||
return { content: [{ type: "text", text: "Theme tokens updated successfully:\\n" + JSON.stringify(res.data.themeTokens, null, 2) }] };
|
||||
}
|
||||
case "get_footer_brand": {
|
||||
@@ -1545,17 +1626,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
case "update_brand_name": {
|
||||
const { brandName } = params as any;
|
||||
const currentSettingsRes = await api.get("/settings");
|
||||
const currentSettings = currentSettingsRes.data || {};
|
||||
const mergedSettings = { ...currentSettings, brandName };
|
||||
|
||||
for (const key in mergedSettings) {
|
||||
if (mergedSettings[key] === null) {
|
||||
delete mergedSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
const res = await api.put("/settings", mergedSettings);
|
||||
const res = await api.put("/settings", { brandName });
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -1812,18 +1883,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (params.socialLinks?.tiktokUrl !== undefined) settingsPayload.tiktokUrl = params.socialLinks.tiktokUrl;
|
||||
|
||||
if (Object.keys(settingsPayload).length > 0) {
|
||||
// First fetch existing settings because the backend requires full payload validation
|
||||
const currentSettingsRes = await api.get("/settings");
|
||||
const currentSettings = currentSettingsRes.data || {};
|
||||
|
||||
const mergedSettings = { ...currentSettings, ...settingsPayload };
|
||||
// Remove null values so Elysia validation doesn't fail on t.Optional(t.String())
|
||||
for (const key in mergedSettings) {
|
||||
if (mergedSettings[key] === null) {
|
||||
delete mergedSettings[key];
|
||||
}
|
||||
}
|
||||
await api.put("/settings", mergedSettings);
|
||||
await api.put("/settings", settingsPayload);
|
||||
results.push({ type: "settings", status: "success" });
|
||||
}
|
||||
}
|
||||
@@ -1850,12 +1910,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = normalizeToolError(err);
|
||||
const errorMsg = targetRegistry && axios.isAxiosError(err)
|
||||
? `Backend request failed${err.response?.status ? ` (HTTP ${err.response.status})` : ""}.`
|
||||
: normalizeToolError(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// A profile switch cannot interleave with requests that still use the previous target.
|
||||
let toolQueue: Promise<void> = Promise.resolve();
|
||||
server.setRequestHandler(CallToolRequestSchema, (request) => {
|
||||
const run = toolQueue.then(async () => {
|
||||
if (targetRegistry && request.params.name === "upload_product_image_from_url") {
|
||||
return { content: [{ type: "text" as const, text: "PROFILE_TOOL_UNAVAILABLE: upload_product_image_from_url is disabled in profile mode until the downloader has an outbound URL allowlist and resource limits." }], isError: true };
|
||||
}
|
||||
if (targetRegistry && !TARGET_MANAGEMENT_TOOLS.has(request.params.name)) {
|
||||
const args = request.params.arguments;
|
||||
if (!args || args.expectedProfileId !== activeProfileId) {
|
||||
return { content: [{ type: "text" as const, text: "Target profile mismatch or missing expectedProfileId. Call get_target_environment and retry only after confirming the intended customer." }], isError: true };
|
||||
}
|
||||
const { expectedProfileId: _guard, ...backendArgs } = args;
|
||||
const boundRequest = { ...request, params: { ...request.params, arguments: backendArgs } } as CallToolRequest;
|
||||
return withStructuredContent(await handleToolRequest(boundRequest) as CallToolResult);
|
||||
}
|
||||
return withStructuredContent(await handleToolRequest(request) as CallToolResult);
|
||||
});
|
||||
toolQueue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
});
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -34,10 +34,42 @@ const websiteBlockTypes = [
|
||||
"CONTACT_FORM",
|
||||
"HERO_V2",
|
||||
"USP_BAR",
|
||||
"HERO_MINIMAL",
|
||||
"HERO_SPLIT_MINIMAL",
|
||||
"HERO_VIDEO_MINIMAL",
|
||||
"BANNER_MINIMAL",
|
||||
"ANNOUNCEMENT_BAR_MINIMAL",
|
||||
"USP_MINIMAL",
|
||||
"CUSTOM",
|
||||
] as const;
|
||||
|
||||
const minimalBlockTypes = [
|
||||
"HERO_MINIMAL",
|
||||
"HERO_SPLIT_MINIMAL",
|
||||
"HERO_VIDEO_MINIMAL",
|
||||
"BANNER_MINIMAL",
|
||||
"ANNOUNCEMENT_BAR_MINIMAL",
|
||||
"USP_MINIMAL",
|
||||
] as const;
|
||||
|
||||
describe("Page Builder block contracts", () => {
|
||||
test("reusable payload examples do not carry another tenant's identity", () => {
|
||||
for (const type of websiteBlockTypes) {
|
||||
const schema = getPageBlockSchema(type);
|
||||
const examples = JSON.stringify({
|
||||
minimalPayload: schema.minimalPayload,
|
||||
recommendedPayload: schema.recommendedPayload,
|
||||
legacyExample: schema.legacyExample,
|
||||
});
|
||||
|
||||
expect(examples).not.toMatch(/homaroma|ddc|kitizone|pkh|@homaroma|hello@homaroma\.shop/i);
|
||||
}
|
||||
|
||||
const contact = getPageBlockSchema("CONTACT_INFO");
|
||||
expect(contact.warnings).toContain("Replace sample contact details with the active tenant's verified address, email, LINE ID, and business hours before writing.");
|
||||
expect(contact.recommendedPayload).toMatchObject({ email: "hello@example.com", line: "@your-line-id" });
|
||||
});
|
||||
|
||||
for (const type of websiteBlockTypes) {
|
||||
test(`${type} exposes a valid recommended payload`, () => {
|
||||
const schema = getPageBlockSchema(type);
|
||||
@@ -48,6 +80,48 @@ describe("Page Builder block contracts", () => {
|
||||
});
|
||||
}
|
||||
|
||||
test("minimal blocks accept backend-valid payloads with optional copy omitted", () => {
|
||||
expect(validatePayloadAgainstMetadata("HERO_MINIMAL", { imageUrl: "/hero.jpg", title: { th: "Hero" } }).valid).toBe(true);
|
||||
expect(validatePayloadAgainstMetadata("HERO_SPLIT_MINIMAL", { imageUrl: "/hero.jpg", title: { th: "Hero" } }).valid).toBe(true);
|
||||
expect(validatePayloadAgainstMetadata("HERO_VIDEO_MINIMAL", { videoUrl: "/hero.mp4" }).valid).toBe(true);
|
||||
expect(validatePayloadAgainstMetadata("BANNER_MINIMAL", { title: { th: "Banner" } }).valid).toBe(true);
|
||||
expect(validatePayloadAgainstMetadata("ANNOUNCEMENT_BAR_MINIMAL", { slides: [{ th: "Announcement" }] }).valid).toBe(true);
|
||||
expect(validatePayloadAgainstMetadata("USP_MINIMAL", { items: [{ th: "Benefit" }] }).valid).toBe(true);
|
||||
});
|
||||
|
||||
test("minimal blocks reject missing required fields and malformed items", () => {
|
||||
for (const type of minimalBlockTypes) {
|
||||
expect(validatePayloadAgainstMetadata(type, {}).valid).toBe(false);
|
||||
}
|
||||
expect(validatePayloadAgainstMetadata("USP_MINIMAL", { items: [{ icon: "leaf", title: { th: "Benefit" } }] }).valid).toBe(false);
|
||||
expect(validatePayloadAgainstMetadata("ANNOUNCEMENT_BAR_MINIMAL", { slides: [] }).valid).toBe(false);
|
||||
});
|
||||
|
||||
test("minimal blocks pass page creation and section addition preflight", async () => {
|
||||
const calls: Array<{ url: string; payload: any }> = [];
|
||||
const api = {
|
||||
post: async (url: string, payload: any) => {
|
||||
calls.push({ url, payload });
|
||||
return { data: { pageId: 50, sectionId: 120, section: { id: 120, type: payload.section?.type ?? payload.type } } };
|
||||
},
|
||||
} as any;
|
||||
|
||||
for (const type of minimalBlockTypes) {
|
||||
const payload = getPageBlockSchema(type).recommendedPayload;
|
||||
const create = await handleSectionTool(api, "create_page_with_section", {
|
||||
title: "Minimal page", slug: `minimal-${type.toLowerCase()}`, type, payload,
|
||||
});
|
||||
const add = await handleSectionTool(api, "add_page_section", { pageId: 50, type, payload });
|
||||
|
||||
expect(create && "isError" in create ? create.isError : false).toBe(false);
|
||||
expect(add && "isError" in add ? add.isError : false).toBe(false);
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(minimalBlockTypes.length * 2);
|
||||
expect(calls.filter((call) => call.url === "/pages/with-section")).toHaveLength(minimalBlockTypes.length);
|
||||
expect(calls.filter((call) => call.url === "/pages/50/sections")).toHaveLength(minimalBlockTypes.length);
|
||||
});
|
||||
|
||||
test("HERO_V2 rejects the legacy empty payload before backend writes", () => {
|
||||
const result = validatePayloadAgainstMetadata("HERO_V2", {});
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ const WEBSITE_PAGE_SECTION_TYPE_LIST = [
|
||||
"CONTACT_FORM",
|
||||
"HERO_V2",
|
||||
"USP_BAR",
|
||||
"HERO_MINIMAL",
|
||||
"HERO_SPLIT_MINIMAL",
|
||||
"HERO_VIDEO_MINIMAL",
|
||||
"BANNER_MINIMAL",
|
||||
"ANNOUNCEMENT_BAR_MINIMAL",
|
||||
"USP_MINIMAL",
|
||||
"CUSTOM",
|
||||
] as const;
|
||||
|
||||
@@ -82,7 +88,7 @@ type BlockMetadata = {
|
||||
recommendedPayload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const imageUrlExample = "https://elysia3051.kitizone.com/uploads/2b3cca4c-9f68-43c0-982b-097475f9e55f.jpeg";
|
||||
const imageUrlExample = "https://example.com/tenant-image.jpg";
|
||||
const localizedObjectExample = { th: "ข้อความภาษาไทย", en: "English text" };
|
||||
|
||||
const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
@@ -93,7 +99,7 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
renderedFields: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink", "focalPoint", "contentWidth", "textColorScheme", "buttonPosition", "mobileButtonPosition", "overlayStyle", "overlayOpacity", "titleSize"],
|
||||
storedOnlyFields: [],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: ["Use an image URL from a host allowed by apps/website next.config.ts."],
|
||||
warnings: ["Replace sample image URLs with this tenant's approved media URLs before writing. The website must allow the media host."],
|
||||
minimalPayload: {
|
||||
tagline: localizedObjectExample,
|
||||
title: localizedObjectExample,
|
||||
@@ -122,12 +128,12 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
warnings: ["videoUrl must be a direct MP4 or WebM video link."],
|
||||
minimalPayload: {
|
||||
title: localizedObjectExample,
|
||||
videoUrl: "https://cdn.coverr.co/videos/coverr-essential-oils-and-diffuser-5374/1080p.mp4"
|
||||
videoUrl: "https://example.com/tenant-video.mp4"
|
||||
},
|
||||
recommendedPayload: {
|
||||
tagline: { th: "ภาพยนตร์แห่งกลิ่นหอม", en: "Cinema of Scents" },
|
||||
title: { th: "วิหารแห่งกลิ่นหอม", en: "Pure Botanical Sanctuary" },
|
||||
videoUrl: "https://cdn.coverr.co/videos/coverr-essential-oils-and-diffuser-5374/1080p.mp4",
|
||||
tagline: { th: "เรื่องราวของเรา", en: "Our Story" },
|
||||
title: { th: "สำรวจคอลเลกชัน", en: "Explore the Collection" },
|
||||
videoUrl: "https://example.com/tenant-video.mp4",
|
||||
buttonText: { th: "สำรวจคอลเลกชัน", en: "Discover Collection" },
|
||||
buttonLink: "/shop"
|
||||
}
|
||||
@@ -188,7 +194,7 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
imagePosition: "left",
|
||||
},
|
||||
recommendedPayload: {
|
||||
title: { th: "เรื่องราวของกลิ่นหอม", en: "A Scented Story" },
|
||||
title: { th: "เรื่องราวของเรา", en: "Our Story" },
|
||||
description: { th: "ทดสอบ editorial tile พร้อมรูปและปุ่ม", en: "Testing an editorial tile with image and action." },
|
||||
imageUrl: imageUrlExample,
|
||||
imagePosition: "left",
|
||||
@@ -214,13 +220,13 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
columns: 2,
|
||||
tiles: [
|
||||
{
|
||||
title: { th: "Here Comes The Sun", en: "Here Comes The Sun" },
|
||||
title: { th: "คอลเลกชันฤดูร้อน", en: "Summer Collection" },
|
||||
imageUrl: imageUrlExample,
|
||||
buttonText: { th: "ช้อปเลย", en: "Shop Beach Blossom" },
|
||||
buttonText: { th: "ช้อปเลย", en: "Shop now" },
|
||||
buttonLink: "/shop"
|
||||
},
|
||||
{
|
||||
title: { th: "The Scent Of A New Adventure", en: "The Scent Of A New Adventure" },
|
||||
title: { th: "สิ่งใหม่กำลังมา", en: "Something New Is Coming" },
|
||||
imageUrl: imageUrlExample,
|
||||
buttonText: { th: "ติดตามข่าวสาร", en: "Be The First To Know" },
|
||||
buttonLink: "/shop"
|
||||
@@ -235,7 +241,7 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
renderedFields: ["autoplay", "interval", "aspectRatio", "mobileAspectRatio", "showArrows", "showDots", "backgroundColor", "slides"],
|
||||
storedOnlyFields: [],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: ["Full-wide edge-to-edge promotional carousel. Default aspect ratio is '21/7' for a sleek Beautrium-style wide ribbon."],
|
||||
warnings: ["Full-wide edge-to-edge promotional carousel. Default aspect ratio is '21/7'."],
|
||||
minimalPayload: {
|
||||
slides: [
|
||||
{ imageUrl: imageUrlExample, linkUrl: "/shop", alt: localizedObjectExample }
|
||||
@@ -253,8 +259,8 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
imageUrl: imageUrlExample,
|
||||
linkUrl: "/shop",
|
||||
alt: { th: "โปรโมชั่นพิเศษ 1", en: "Special Deal 1" },
|
||||
title: { th: "โปรแรงลดสูงสุด [70%]", en: "Mega Sale Up To [70% OFF]" },
|
||||
subtitle: { th: "BEAUTY DEALS", en: "BEAUTY DEALS" },
|
||||
title: { th: "สินค้าแนะนำ", en: "Featured Products" },
|
||||
subtitle: { th: "พบรายการที่น่าสนใจ", en: "Explore the collection" },
|
||||
buttonText: { th: "ช้อปเลย", en: "Shop Now" }
|
||||
}
|
||||
]
|
||||
@@ -370,8 +376,8 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
recommendedPayload: {
|
||||
title: { th: "บทความทดสอบ", en: "Hero article test" },
|
||||
subtitle: { th: "ทดสอบ HERO_ARTICLE block", en: "Testing HERO_ARTICLE block." },
|
||||
author: { th: "ทีม HomAroma", en: "HomAroma Team" },
|
||||
category: { th: "คู่มือกลิ่น", en: "Scent Guide" },
|
||||
author: { th: "ทีมบรรณาธิการ", en: "Editorial Team" },
|
||||
category: { th: "คู่มือ", en: "Guide" },
|
||||
publishDate: "2026-07-24",
|
||||
imageUrl: imageUrlExample,
|
||||
},
|
||||
@@ -386,10 +392,10 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
warnings: ["content may contain HTML and is rendered as CMS-authored rich text."],
|
||||
minimalPayload: { content: localizedObjectExample },
|
||||
recommendedPayload: {
|
||||
title: { th: "เรื่องราวของกลิ่นหอม", en: "The Story of Fragrance" },
|
||||
title: { th: "เรื่องราวของเรา", en: "Our Story" },
|
||||
content: {
|
||||
th: "<p>สำรวจศิลปะแห่งกลิ่นหอมผ่านวัตถุดิบที่คัดสรร</p>",
|
||||
en: "<p>Explore the art of fragrance through carefully selected ingredients.</p>",
|
||||
th: "<p>เรียนรู้เรื่องราวและแนวคิดเบื้องหลังคอลเลกชันนี้</p>",
|
||||
en: "<p>Explore the story and ideas behind this collection.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -406,14 +412,14 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
],
|
||||
minimalPayload: { body: localizedObjectExample },
|
||||
recommendedPayload: {
|
||||
heading: { th: "ความแม่นยำในทุกฟันสเตอร์", en: "Precision in every sprocket tooth" },
|
||||
heading: { th: "รายละเอียดที่ใส่ใจ", en: "Details that matter" },
|
||||
body: {
|
||||
th: "Fine Blanking ช่วยควบคุมผิวตัดและรูปทรงของฟันสเตอร์ให้สม่ำเสมอ เพื่อการส่งกำลังที่ราบรื่นและแม่นยำ",
|
||||
en: "Fine blanking controls the cut surface and tooth profile for smooth, precise power transmission.",
|
||||
th: "บอกเล่ารายละเอียดสำคัญของสินค้าและสิ่งที่ลูกค้าควรรู้",
|
||||
en: "Describe the product details and what customers should know.",
|
||||
},
|
||||
imageUrl: imageUrlExample,
|
||||
imageAlt: { th: "สเตอร์ที่ผลิตด้วยเทคโนโลยี Fine Blanking", en: "A sprocket made with fine blanking technology" },
|
||||
imageCaption: { th: "ผิวตัดเรียบและรูปทรงสม่ำเสมอ", en: "A smooth cut surface and consistent profile" },
|
||||
imageAlt: { th: "ภาพประกอบรายละเอียดสินค้า", en: "Product detail image" },
|
||||
imageCaption: { th: "รายละเอียดเพิ่มเติม", en: "More details" },
|
||||
imagePosition: "right",
|
||||
width: "standard",
|
||||
background: "default",
|
||||
@@ -429,10 +435,10 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
warnings: ["imageAlt is required whenever imageUrl is provided."],
|
||||
minimalPayload: { title: localizedObjectExample },
|
||||
recommendedPayload: {
|
||||
title: { th: "เทคโนโลยีที่อยู่เบื้องหลังทุกการขับเคลื่อน", en: "Technology behind every ride" },
|
||||
subtitle: { th: "ทำความรู้จักกระบวนการผลิตที่ให้ความสำคัญกับความแม่นยำ", en: "A closer look at precision manufacturing." },
|
||||
category: { th: "เทคโนโลยี", en: "Technology" },
|
||||
author: { th: "ทีมงาน DDC", en: "DDC Team" },
|
||||
title: { th: "เรื่องราวเบื้องหลังสินค้า", en: "The Story Behind the Product" },
|
||||
subtitle: { th: "ทำความรู้จักแนวคิดและรายละเอียดสำคัญ", en: "A closer look at the ideas and details." },
|
||||
category: { th: "เรื่องราว", en: "Stories" },
|
||||
author: { th: "ทีมบรรณาธิการ", en: "Editorial Team" },
|
||||
publishDate: "2026-07-24",
|
||||
},
|
||||
},
|
||||
@@ -451,11 +457,11 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
],
|
||||
},
|
||||
recommendedPayload: {
|
||||
title: { th: "ภาพจากกระบวนการผลิต", en: "Inside the production process" },
|
||||
title: { th: "ภาพประกอบเรื่องราว", en: "Story Gallery" },
|
||||
layout: "grid",
|
||||
images: [
|
||||
{ imageUrl: imageUrlExample, alt: { th: "ขั้นตอนการผลิตชิ้นงาน", en: "Component production" } },
|
||||
{ imageUrl: imageUrlExample, alt: { th: "การตรวจสอบคุณภาพ", en: "Quality inspection" } },
|
||||
{ imageUrl: imageUrlExample, alt: { th: "ภาพประกอบรายการแรก", en: "First story image" } },
|
||||
{ imageUrl: imageUrlExample, alt: { th: "ภาพประกอบรายการที่สอง", en: "Second story image" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -471,8 +477,8 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
recommendedPayload: {
|
||||
title: { th: "ข้อมูลสำคัญ", en: "Key facts" },
|
||||
items: [
|
||||
{ title: { th: "ผิวตัดสม่ำเสมอ", en: "Consistent cut surface" }, description: { th: "ช่วยลดขั้นตอนการแต่งผิว", en: "Reduces secondary finishing." } },
|
||||
{ title: { th: "รูปทรงแม่นยำ", en: "Precise geometry" }, description: { th: "ช่วยให้การขบกับโซ่ต่อเนื่อง", en: "Supports consistent chain engagement." } },
|
||||
{ title: { th: "คุณสมบัติแรก", en: "First feature" }, description: { th: "อธิบายประโยชน์ให้ชัดเจน", en: "Explain the benefit clearly." } },
|
||||
{ title: { th: "คุณสมบัติที่สอง", en: "Second feature" }, description: { th: "เพิ่มรายละเอียดที่เกี่ยวข้อง", en: "Add relevant details." } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -486,9 +492,9 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
warnings: ["Use a full HTTPS YouTube, Vimeo, or direct video URL. Do not send embed HTML."],
|
||||
minimalPayload: { videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID" },
|
||||
recommendedPayload: {
|
||||
title: { th: "ชมกระบวนการผลิต", en: "Watch the production process" },
|
||||
title: { th: "ชมเรื่องราว", en: "Watch the Story" },
|
||||
videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID",
|
||||
caption: { th: "เบื้องหลังการผลิตที่แม่นยำ", en: "Behind precision manufacturing" },
|
||||
caption: { th: "รายละเอียดเพิ่มเติมในวิดีโอ", en: "More details in the video" },
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
},
|
||||
@@ -503,7 +509,7 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
minimalPayload: { items: [{ title: localizedObjectExample }] },
|
||||
recommendedPayload: {
|
||||
title: { th: "แหล่งข้อมูล", en: "References" },
|
||||
items: [{ title: { th: "เอกสารข้อมูลทางเทคนิค", en: "Technical documentation" }, note: { th: "ข้อมูลจากฝ่ายวิศวกรรม", en: "Engineering source material" } }],
|
||||
items: [{ title: { th: "เอกสารอ้างอิง", en: "Reference document" }, note: { th: "ตรวจสอบแหล่งข้อมูลก่อนเผยแพร่", en: "Verify the source before publishing" } }],
|
||||
},
|
||||
},
|
||||
ARTICLE_TABLE: {
|
||||
@@ -520,8 +526,8 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
},
|
||||
recommendedPayload: {
|
||||
title: { th: "ข้อมูลเปรียบเทียบ", en: "Comparison" },
|
||||
columns: [{ th: "คุณสมบัติ", en: "Property" }, { th: "Fine Blanking", en: "Fine blanking" }],
|
||||
rows: [{ label: { th: "ผิวตัด", en: "Cut surface" }, cells: [{ th: "ผิวตัด", en: "Cut surface" }, { th: "เรียบและสม่ำเสมอ", en: "Smooth and consistent" }] }],
|
||||
columns: [{ th: "หัวข้อ", en: "Item" }, { th: "รายละเอียด", en: "Details" }],
|
||||
rows: [{ label: { th: "ตัวอย่าง", en: "Example" }, cells: [{ th: "คุณสมบัติ", en: "Feature" }, { th: "คำอธิบาย", en: "Description" }] }],
|
||||
},
|
||||
},
|
||||
ARTICLE_DOWNLOAD: {
|
||||
@@ -582,9 +588,9 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
warnings: [],
|
||||
minimalPayload: { quote: localizedObjectExample },
|
||||
recommendedPayload: {
|
||||
quote: { th: "กลิ่นหอมคือความทรงจำที่มองไม่เห็น", en: "Fragrance is an invisible memory." },
|
||||
author: { th: "ทีม HomAroma", en: "HomAroma Team" },
|
||||
role: { th: "นักสร้างสรรค์กลิ่น", en: "Scent Creators" },
|
||||
quote: { th: "รายละเอียดเล็ก ๆ สร้างความแตกต่าง", en: "Small details make a difference." },
|
||||
author: { th: "ทีมบรรณาธิการ", en: "Editorial Team" },
|
||||
role: { th: "ผู้เขียน", en: "Author" },
|
||||
},
|
||||
},
|
||||
PRODUCT_CAROUSEL: {
|
||||
@@ -599,7 +605,7 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
recommendedPayload: {
|
||||
type: "featured",
|
||||
title: { th: "สินค้าที่คัดสรร", en: "Featured Products" },
|
||||
description: { th: "กลิ่นหอมยอดนิยมที่เราเลือกให้คุณ", en: "Popular fragrances selected for you." },
|
||||
description: { th: "รายการที่คัดสรรสำหรับคุณ", en: "Selected items for you." },
|
||||
limit: 4,
|
||||
showPrice: true,
|
||||
showAddToCart: true,
|
||||
@@ -622,10 +628,10 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
recommendedPayload: {
|
||||
title: { th: "คำถามที่พบบ่อย", en: "Frequently Asked Questions" },
|
||||
categories: [{
|
||||
name: { th: "การเลือกกลิ่น", en: "Choosing a Scent" },
|
||||
name: { th: "การเลือกสินค้า", en: "Choosing a Product" },
|
||||
questions: [{
|
||||
q: { th: "ควรเริ่มอย่างไร", en: "How should I start?" },
|
||||
a: { th: "เริ่มจากโน้ตกลิ่นที่คุณชอบ", en: "Start with notes you already enjoy." },
|
||||
a: { th: "เริ่มจากคุณสมบัติที่ต้องการ", en: "Start with the features you need." },
|
||||
}],
|
||||
}],
|
||||
},
|
||||
@@ -637,18 +643,18 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
renderedFields: ["title", "address", "email", "line", "hours"],
|
||||
storedOnlyFields: [],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: [],
|
||||
warnings: ["Replace sample contact details with the active tenant's verified address, email, LINE ID, and business hours before writing."],
|
||||
minimalPayload: {
|
||||
address: localizedObjectExample,
|
||||
email: "hello@homaroma.shop",
|
||||
line: "@homaroma",
|
||||
email: "hello@example.com",
|
||||
line: "@your-line-id",
|
||||
hours: localizedObjectExample,
|
||||
},
|
||||
recommendedPayload: {
|
||||
title: { th: "ติดต่อเรา", en: "Contact Us" },
|
||||
address: { th: "กรุงเทพมหานคร ประเทศไทย", en: "Bangkok, Thailand" },
|
||||
email: "hello@homaroma.shop",
|
||||
line: "@homaroma",
|
||||
email: "hello@example.com",
|
||||
line: "@your-line-id",
|
||||
hours: { th: "ทุกวัน 10:00-18:00 น.", en: "Daily, 10:00 AM-6:00 PM" },
|
||||
},
|
||||
},
|
||||
@@ -677,8 +683,8 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
minimalPayload: { title: localizedObjectExample, imageUrl: imageUrlExample },
|
||||
recommendedPayload: {
|
||||
tagline: { th: "คอลเลกชันใหม่", en: "New Collection" },
|
||||
title: { th: "กลิ่นหอมที่บอกเล่าตัวตน", en: "A Fragrance That Tells Your Story" },
|
||||
description: { th: "ค้นพบกลิ่นที่เข้ากับช่วงเวลาของคุณ", en: "Discover a scent for your moment." },
|
||||
title: { th: "เรื่องราวในแบบของคุณ", en: "Your Story, Your Way" },
|
||||
description: { th: "ค้นพบสิ่งที่เหมาะกับคุณ", en: "Discover what works for you." },
|
||||
imageUrl: imageUrlExample,
|
||||
contentAlignment: "left",
|
||||
showOrnament: true,
|
||||
@@ -706,6 +712,101 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
],
|
||||
},
|
||||
},
|
||||
HERO_MINIMAL: {
|
||||
type: "HERO_MINIMAL",
|
||||
requiredFields: ["imageUrl", "title"],
|
||||
optionalFields: ["tagline", "focalPoint", "contentAlignment", "overlayOpacity", "titleSize"],
|
||||
renderedFields: ["imageUrl", "title", "tagline", "focalPoint", "contentAlignment", "overlayOpacity"],
|
||||
storedOnlyFields: ["titleSize"],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: ["titleSize is accepted by the backend but is not used by apps/website HeroMinimalSection."],
|
||||
minimalPayload: { imageUrl: imageUrlExample, title: localizedObjectExample },
|
||||
recommendedPayload: {
|
||||
imageUrl: imageUrlExample,
|
||||
title: { th: "เรื่องราวของเรา", en: "Our Story" },
|
||||
tagline: { th: "สำรวจเพิ่มเติม", en: "Explore More" },
|
||||
contentAlignment: "center",
|
||||
overlayOpacity: 20,
|
||||
},
|
||||
},
|
||||
HERO_SPLIT_MINIMAL: {
|
||||
type: "HERO_SPLIT_MINIMAL",
|
||||
requiredFields: ["imageUrl", "title"],
|
||||
optionalFields: ["subtitle", "imagePosition", "focalPoint", "backgroundColor", "titleSize"],
|
||||
renderedFields: ["imageUrl", "title", "subtitle", "imagePosition", "focalPoint", "backgroundColor"],
|
||||
storedOnlyFields: ["titleSize"],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: ["titleSize is accepted by the backend but is not used by apps/website HeroSplitMinimalSection."],
|
||||
minimalPayload: { imageUrl: imageUrlExample, title: localizedObjectExample },
|
||||
recommendedPayload: {
|
||||
imageUrl: imageUrlExample,
|
||||
title: { th: "แรงบันดาลใจจากธรรมชาติ", en: "Inspired by Nature" },
|
||||
subtitle: { th: "สิ่งที่เลือกสรรสำหรับคุณ", en: "Selected for you" },
|
||||
imagePosition: "right",
|
||||
},
|
||||
},
|
||||
HERO_VIDEO_MINIMAL: {
|
||||
type: "HERO_VIDEO_MINIMAL",
|
||||
requiredFields: ["videoUrl"],
|
||||
optionalFields: ["posterImageUrl", "tagline", "title", "overlayOpacity", "height", "focalPoint", "titleSize"],
|
||||
renderedFields: ["videoUrl", "posterImageUrl", "tagline", "title", "overlayOpacity", "height", "focalPoint"],
|
||||
storedOnlyFields: ["titleSize"],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: ["videoUrl should point to an MP4 or WebM file. titleSize is accepted by the backend but is not used by apps/website HeroVideoMinimalSection."],
|
||||
minimalPayload: { videoUrl: "https://example.com/hero.mp4" },
|
||||
recommendedPayload: {
|
||||
videoUrl: "https://example.com/hero.mp4",
|
||||
title: { th: "บรรยากาศแห่งความสงบ", en: "A Moment of Calm" },
|
||||
posterImageUrl: imageUrlExample,
|
||||
height: "large",
|
||||
},
|
||||
},
|
||||
BANNER_MINIMAL: {
|
||||
type: "BANNER_MINIMAL",
|
||||
requiredFields: ["title"],
|
||||
optionalFields: ["subtitle", "imageUrl", "linkUrl", "backgroundColor", "textColor", "focalPoint"],
|
||||
renderedFields: ["title", "subtitle", "imageUrl", "linkUrl", "backgroundColor", "textColor", "focalPoint"],
|
||||
storedOnlyFields: [],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: [],
|
||||
minimalPayload: { title: localizedObjectExample },
|
||||
recommendedPayload: {
|
||||
title: { th: "สำรวจคอลเลกชัน", en: "Explore the Collection" },
|
||||
subtitle: { th: "ค้นหาสิ่งที่ใช่สำหรับคุณ", en: "Find what fits you" },
|
||||
linkUrl: "/shop",
|
||||
},
|
||||
},
|
||||
ANNOUNCEMENT_BAR_MINIMAL: {
|
||||
type: "ANNOUNCEMENT_BAR_MINIMAL",
|
||||
requiredFields: ["slides"],
|
||||
optionalFields: ["backgroundColor", "textColor", "speed"],
|
||||
renderedFields: ["slides", "backgroundColor", "textColor", "speed"],
|
||||
storedOnlyFields: [],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: [],
|
||||
minimalPayload: { slides: [localizedObjectExample] },
|
||||
recommendedPayload: {
|
||||
slides: [{ th: "ค้นพบรายการใหม่", en: "Discover Something New" }],
|
||||
speed: 4000,
|
||||
},
|
||||
},
|
||||
USP_MINIMAL: {
|
||||
type: "USP_MINIMAL",
|
||||
requiredFields: ["items"],
|
||||
optionalFields: ["separator", "backgroundColor", "textColor"],
|
||||
renderedFields: ["items", "separator", "backgroundColor", "textColor"],
|
||||
storedOnlyFields: [],
|
||||
discoveryToolsNeeded: [],
|
||||
warnings: ["items is an array of localized strings, not USP_BAR objects with icons."],
|
||||
minimalPayload: { items: [localizedObjectExample] },
|
||||
recommendedPayload: {
|
||||
items: [
|
||||
{ th: "วัตถุดิบคัดสรร", en: "Selected Ingredients" },
|
||||
{ th: "ใส่ใจทุกรายละเอียด", en: "Made with Care" },
|
||||
],
|
||||
separator: "dot",
|
||||
},
|
||||
},
|
||||
CUSTOM: {
|
||||
type: "CUSTOM",
|
||||
requiredFields: ["componentId"],
|
||||
@@ -722,11 +823,11 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
|
||||
componentId: "BakeryCategories",
|
||||
props: {
|
||||
title: { th: "เลือกตามบรรยากาศ", en: "Shop by Mood" },
|
||||
subtitle: { th: "ค้นหากลิ่นที่เหมาะกับคุณ", en: "Find a scent that suits you." },
|
||||
subtitle: { th: "ค้นหาสินค้าที่เหมาะกับคุณ", en: "Find what suits you." },
|
||||
layout: "horizontal",
|
||||
items: [{
|
||||
title: { th: "ผ่อนคลาย", en: "Relax" },
|
||||
description: { th: "กลิ่นนุ่มนวลสำหรับวันสบาย", en: "Soft scents for quiet days." },
|
||||
description: { th: "ตัวเลือกสำหรับทุกวัน", en: "An option for every day." },
|
||||
href: "/shop",
|
||||
icon: "leaf",
|
||||
}],
|
||||
@@ -850,7 +951,7 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
|
||||
},
|
||||
CONTACT_INFO: {
|
||||
required: ["address", "email", "line", "hours"],
|
||||
example: `{ "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`
|
||||
example: `{ "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@your-line-id", "hours": ${localizedStringExample} }`
|
||||
},
|
||||
CONTACT_FORM: {
|
||||
required: [],
|
||||
@@ -1180,7 +1281,7 @@ const SECTION_TOOL_CONFIGS = [
|
||||
{
|
||||
name: "update_contact_info",
|
||||
sectionType: "CONTACT_INFO",
|
||||
description: `Update a CONTACT_INFO section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`,
|
||||
description: `Update a CONTACT_INFO section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title is optional and localized. address and hours are localized text. email and line are plain strings. Replace sample contact values with the active tenant's verified details. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@your-line-id", "hours": ${localizedStringExample} }`,
|
||||
properties: {
|
||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { handleStorefrontTool, STOREFRONT_TOOL_DEFINITIONS } from "./storefront-toolkit";
|
||||
|
||||
function fakeApi(sections: any[] = []) {
|
||||
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
||||
const api = {
|
||||
get: async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
return { data: path === "/settings/footer-layout" ? { version: "v1", layout: {} } : sections };
|
||||
},
|
||||
patch: async (path: string, body: unknown) => { calls.push({ method: "PATCH", path, body }); return { data: { success: true } }; },
|
||||
post: async (path: string, body?: unknown) => { calls.push({ method: "POST", path, body }); return { data: { success: true } }; },
|
||||
put: async (path: string, body: unknown) => { calls.push({ method: "PUT", path, body }); return { data: { success: true } }; },
|
||||
delete: async (path: string) => { calls.push({ method: "DELETE", path }); return { data: { success: true } }; },
|
||||
} as unknown as AxiosInstance;
|
||||
return { api, calls };
|
||||
}
|
||||
|
||||
const value = (response: any) => JSON.parse(response.content[0].text);
|
||||
|
||||
describe("storefront MCP tools", () => {
|
||||
test("definitions expose the complete new workflow", () => {
|
||||
expect(STOREFRONT_TOOL_DEFINITIONS.map((tool) => tool.name)).toEqual([
|
||||
"get_footer_layout", "patch_footer_layout", "get_category_sections", "seed_category_sections",
|
||||
"add_category_section", "update_category_section", "reorder_category_sections", "delete_category_section",
|
||||
]);
|
||||
});
|
||||
|
||||
test("footer write requires version and forwards backend dry run", async () => {
|
||||
const { api, calls } = fakeApi();
|
||||
const missing = await handleStorefrontTool(api, "patch_footer_layout", { columns: ["brand"] });
|
||||
expect(value(missing).code).toBe("VERSION_REQUIRED");
|
||||
expect(calls).toHaveLength(0);
|
||||
const preview = await handleStorefrontTool(api, "patch_footer_layout", { columns: ["brand"], dryRun: true });
|
||||
expect(preview?.isError).toBeUndefined();
|
||||
expect(calls[0]).toEqual({ method: "PATCH", path: "/settings/footer-layout", body: { columns: ["brand"], dryRun: true } });
|
||||
await handleStorefrontTool(api, "patch_footer_layout", { finale: { enabled: true }, expectedVersion: "v1" });
|
||||
expect(calls[1]?.body).toEqual({ finale: { enabled: true }, expectedVersion: "v1" });
|
||||
});
|
||||
|
||||
test("footer validation returns field paths without a request", async () => {
|
||||
const { api, calls } = fakeApi();
|
||||
const response = await handleStorefrontTool(api, "patch_footer_layout", { columns: ["brand", "brand"], dryRun: true });
|
||||
expect(value(response).code).toBe("INVALID_INPUT");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("category dry run reads but never writes", async () => {
|
||||
const { api, calls } = fakeApi();
|
||||
const response = await handleStorefrontTool(api, "seed_category_sections", { categoryId: 9, dryRun: true });
|
||||
expect(value(response).dryRun).toBe(true);
|
||||
expect(calls).toEqual([{ method: "GET", path: "/categories/9/sections" }]);
|
||||
});
|
||||
|
||||
test("category update rejects wrong ownership and locked changes", async () => {
|
||||
const { api, calls } = fakeApi([{ id: 7, type: "CATEGORY_HEADER", isLocked: true }]);
|
||||
expect(value(await handleStorefrontTool(api, "update_category_section", { categoryId: 9, sectionId: 8, isActive: false })).code).toBe("NOT_FOUND");
|
||||
expect(value(await handleStorefrontTool(api, "update_category_section", { categoryId: 9, sectionId: 7, isActive: false })).code).toBe("CONFLICT");
|
||||
expect(calls.every((call) => call.method === "GET")).toBe(true);
|
||||
});
|
||||
|
||||
test("reorder rejects missing IDs before backend write", async () => {
|
||||
const { api, calls } = fakeApi([{ id: 1 }, { id: 2 }]);
|
||||
const response = await handleStorefrontTool(api, "reorder_category_sections", { categoryId: 9, sections: [{ id: 1, order: 1 }] });
|
||||
expect(value(response).code).toBe("INVALID_INPUT");
|
||||
expect(calls.every((call) => call.method === "GET")).toBe(true);
|
||||
});
|
||||
|
||||
test("backend conflict includes current version", async () => {
|
||||
const { api } = fakeApi();
|
||||
api.patch = async () => { throw { response: { status: 409, data: { code: "VERSION_CONFLICT", error: "Changed", currentVersion: "v2" } } }; };
|
||||
const response = await handleStorefrontTool(api, "patch_footer_layout", { columns: ["brand"], expectedVersion: "v1" });
|
||||
expect(value(response)).toMatchObject({ code: "VERSION_CONFLICT", message: "Changed", details: { status: 409, currentVersion: "v2" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { CATEGORY_SECTION_TYPES, FooterLayoutSettingsPatchSchema } from "@homaroma/types";
|
||||
|
||||
type ToolResult = { content: [{ type: "text"; text: string }]; isError?: true };
|
||||
const result = (value: unknown, isError = false): ToolResult => ({
|
||||
content: [{ type: "text", text: JSON.stringify(value) }],
|
||||
...(isError ? { isError: true as const } : {}),
|
||||
});
|
||||
const failure = (code: string, message: string, details?: unknown) => result({ success: false, code, message, ...(details === undefined ? {} : { details }) }, true);
|
||||
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
||||
const positiveId = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
||||
|
||||
function apiError(error: unknown): ToolResult {
|
||||
const response = (error as any)?.response;
|
||||
const data = response?.data;
|
||||
return failure(
|
||||
data?.code || (response?.status === 409 ? "CONFLICT" : response?.status ? "BACKEND_ERROR" : "NETWORK_ERROR"),
|
||||
data?.error || data?.message || (error as Error)?.message || "Request failed",
|
||||
{ status: response?.status, issues: data?.issues, currentVersion: data?.currentVersion },
|
||||
);
|
||||
}
|
||||
|
||||
const footerPatchProperties = {
|
||||
columns: { type: "array", items: { type: "string", enum: ["brand", "explore", "collections", "support"] }, minItems: 1, maxItems: 4, uniqueItems: true },
|
||||
mobileColumns: { type: "integer", enum: [1, 2] },
|
||||
desktopColumns: { type: "integer", enum: [2, 3, 4] },
|
||||
containerWidth: { type: "string", enum: ["narrow", "standard", "wide", "full"] },
|
||||
verticalPadding: { type: "string", enum: ["sm", "md", "lg", "xl"] },
|
||||
columnGap: { type: "string", enum: ["sm", "md", "lg", "xl"] },
|
||||
headingSize: { type: "string", enum: ["sm", "md", "lg"] },
|
||||
linkSize: { type: "string", enum: ["sm", "md", "lg"] },
|
||||
brandLogoSize: { type: "string", enum: ["sm", "md", "lg", "xl"] },
|
||||
socialSize: { type: "string", enum: ["sm", "md", "lg"] },
|
||||
finale: { type: "object", additionalProperties: false, properties: {
|
||||
enabled: { type: "boolean" }, mode: { type: "string", enum: ["static", "curtain"] },
|
||||
mobileHeight: { type: "string", enum: ["compact", "medium", "large"] },
|
||||
desktopHeight: { type: "string", enum: ["compact", "half", "screen"] },
|
||||
logoSize: { type: "string", enum: ["sm", "md", "lg", "xl"] },
|
||||
} },
|
||||
} as const;
|
||||
|
||||
const idSchema = { type: "integer", minimum: 1 } as const;
|
||||
const sectionFields = {
|
||||
type: { type: "string", enum: [...CATEGORY_SECTION_TYPES] },
|
||||
payload: { type: "object", description: "Complete payload for its type; backend validates it. For update, omitted payload preserves the current value." },
|
||||
order: { type: "number" }, isActive: { type: "boolean" }, isLocked: { type: "boolean" },
|
||||
dryRun: { type: "boolean", description: "Preview only. Category backend has no atomic version check; preview may become stale." },
|
||||
} as const;
|
||||
|
||||
export const STOREFRONT_TOOL_DEFINITIONS = [
|
||||
{ name: "get_footer_layout", description: "Read footer layout and version. Pass that version to patch_footer_layout before writing.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
||||
{ name: "patch_footer_layout", description: "Patch footer layout. Use dryRun=true to preview. An actual write requires expectedVersion from get_footer_layout; backend returns VERSION_CONFLICT on concurrent changes.", inputSchema: { type: "object", properties: { ...footerPatchProperties, expectedVersion: { type: "string" }, dryRun: { type: "boolean" } }, additionalProperties: false } },
|
||||
{ name: "get_category_sections", description: "Read category sections, including inactive sections by default for editing.", inputSchema: { type: "object", properties: { categoryId: idSchema, includeInactive: { type: "boolean" }, lang: { type: "string", description: "Locale; defaults to th." } }, required: ["categoryId"], additionalProperties: false } },
|
||||
{ name: "seed_category_sections", description: "Seed three default sections only when category has none. Returns 409 if sections already exist. dryRun previews without writing.", inputSchema: { type: "object", properties: { categoryId: idSchema, dryRun: { type: "boolean" } }, required: ["categoryId"], additionalProperties: false } },
|
||||
{ name: "add_category_section", description: "Create section. On an empty category, backend also seeds defaults; same-type default may be updated instead of creating a fourth section. Preview first to inspect this behavior.", inputSchema: { type: "object", properties: { categoryId: idSchema, ...sectionFields }, required: ["categoryId", "type", "payload"], additionalProperties: false } },
|
||||
{ name: "update_category_section", description: "Update one section. Supply categoryId to verify ownership; locked sections cannot change type, be hidden or unlocked. Payload, when supplied, replaces stored payload.", inputSchema: { type: "object", properties: { categoryId: idSchema, sectionId: idSchema, ...sectionFields }, required: ["categoryId", "sectionId"], additionalProperties: false } },
|
||||
{ name: "reorder_category_sections", description: "Reorder all category sections. Supply every current section ID exactly once; preview before write. Backend has no atomic version check.", inputSchema: { type: "object", properties: { categoryId: idSchema, sections: { type: "array", items: { type: "object", properties: { id: idSchema, order: { type: "number" } }, required: ["id", "order"], additionalProperties: false } }, dryRun: { type: "boolean" } }, required: ["categoryId", "sections"], additionalProperties: false } },
|
||||
{ name: "delete_category_section", description: "Delete an unlocked section after verifying category ownership. dryRun previews without writing.", inputSchema: { type: "object", properties: { categoryId: idSchema, sectionId: idSchema, dryRun: { type: "boolean" } }, required: ["categoryId", "sectionId"], additionalProperties: false } },
|
||||
] as const;
|
||||
|
||||
const toolNames = new Set<string>(STOREFRONT_TOOL_DEFINITIONS.map((tool) => tool.name));
|
||||
const categoryPath = (id: number) => `/categories/${id}/sections`;
|
||||
async function sectionsForEdit(api: AxiosInstance, categoryId: number): Promise<any[]> {
|
||||
const response = await api.get(categoryPath(categoryId), { params: { includeInactive: true, lang: "th" } });
|
||||
if (!Array.isArray(response.data)) throw new Error("Backend returned an invalid category sections list");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function handleStorefrontTool(api: AxiosInstance, name: string, rawParams: unknown): Promise<ToolResult | null> {
|
||||
if (!toolNames.has(name)) return null;
|
||||
const params = object(rawParams) ? rawParams : {};
|
||||
try {
|
||||
if (name === "get_footer_layout") return result((await api.get("/settings/footer-layout")).data);
|
||||
if (name === "patch_footer_layout") {
|
||||
const { expectedVersion, dryRun, ...patch } = params;
|
||||
const parsed = FooterLayoutSettingsPatchSchema.safeParse(patch);
|
||||
if (!parsed.success) return failure("INVALID_INPUT", "Invalid footer layout patch", parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })));
|
||||
if (!Object.keys(parsed.data).length) return failure("INVALID_INPUT", "Provide at least one footer layout field");
|
||||
if (dryRun !== undefined && typeof dryRun !== "boolean") return failure("INVALID_INPUT", "dryRun must be boolean");
|
||||
if (!dryRun && (typeof expectedVersion !== "string" || !expectedVersion)) return failure("VERSION_REQUIRED", "Read get_footer_layout and pass its version as expectedVersion");
|
||||
if (expectedVersion !== undefined && (typeof expectedVersion !== "string" || !expectedVersion)) return failure("INVALID_INPUT", "expectedVersion must be a non-empty string");
|
||||
return result((await api.patch("/settings/footer-layout", { ...parsed.data, ...(expectedVersion ? { expectedVersion } : {}), ...(dryRun ? { dryRun: true } : {}) })).data);
|
||||
}
|
||||
|
||||
if (!positiveId(params.categoryId)) return failure("INVALID_INPUT", "categoryId must be a positive integer");
|
||||
const categoryId = params.categoryId;
|
||||
if (params.dryRun !== undefined && typeof params.dryRun !== "boolean") return failure("INVALID_INPUT", "dryRun must be boolean");
|
||||
if (name === "get_category_sections") {
|
||||
if (params.lang !== undefined && (typeof params.lang !== "string" || !params.lang)) return failure("INVALID_INPUT", "lang must be a non-empty string");
|
||||
return result((await api.get(categoryPath(categoryId), { params: { includeInactive: params.includeInactive === false ? false : true, lang: params.lang || "th" } })).data);
|
||||
}
|
||||
|
||||
const current = await sectionsForEdit(api, categoryId);
|
||||
const preview = (operation: string, wouldSend: unknown, note?: string) => result({ dryRun: true, operation, categoryId, current, wouldSend, ...(note ? { note } : {}) });
|
||||
if (name === "seed_category_sections") {
|
||||
if (current.length) return failure("CONFLICT", "Category sections already exist", { sectionIds: current.map((section) => section.id) });
|
||||
if (params.dryRun === true) return preview(name, { method: "POST", path: `${categoryPath(categoryId)}/seed-default` }, "Backend will create CATEGORY_HEADER, CATALOG_GRID and USP_BAR. No atomic version check.");
|
||||
return result((await api.post(`${categoryPath(categoryId)}/seed-default`)).data);
|
||||
}
|
||||
if (name === "add_category_section" || name === "update_category_section") {
|
||||
if (name === "add_category_section" && (!object(params.payload) || !Object.keys(params.payload).length)) return failure("INVALID_INPUT", "payload must be a non-empty object");
|
||||
if (params.type !== undefined && (typeof params.type !== "string" || !CATEGORY_SECTION_TYPES.includes(params.type.toUpperCase() as any))) return failure("INVALID_INPUT", `type must be one of ${CATEGORY_SECTION_TYPES.join(", ")}`);
|
||||
if (params.payload !== undefined && !object(params.payload)) return failure("INVALID_INPUT", "payload must be an object");
|
||||
if (params.order !== undefined && (typeof params.order !== "number" || !Number.isFinite(params.order))) return failure("INVALID_INPUT", "order must be a finite number");
|
||||
for (const key of ["isActive", "isLocked"] as const) if (params[key] !== undefined && typeof params[key] !== "boolean") return failure("INVALID_INPUT", `${key} must be boolean`);
|
||||
const body = Object.fromEntries(["type", "payload", "order", "isActive", "isLocked"].filter((key) => params[key] !== undefined).map((key) => [key, key === "type" ? (params[key] as string).toUpperCase() : params[key]]));
|
||||
if (name === "add_category_section") {
|
||||
if (!body.type) return failure("INVALID_INPUT", "type is required");
|
||||
if (params.dryRun === true) return preview(name, body, current.length ? "Backend validates payload on write. No atomic version check." : "Empty category: backend seeds defaults first, and may reuse a matching default type. Backend validates payload on write.");
|
||||
return result((await api.post(categoryPath(categoryId), body)).data);
|
||||
}
|
||||
if (!positiveId(params.sectionId)) return failure("INVALID_INPUT", "sectionId must be a positive integer");
|
||||
const existing = current.find((section) => section.id === params.sectionId);
|
||||
if (!existing) return failure("NOT_FOUND", "Section does not belong to this category", { sectionId: params.sectionId });
|
||||
if (existing.isLocked && ((body.type && body.type !== existing.type) || body.isActive === false || body.isLocked === false)) return failure("CONFLICT", "Locked section cannot change type, be hidden or unlocked");
|
||||
if (!Object.keys(body).length) return failure("INVALID_INPUT", "Provide at least one section field");
|
||||
if (params.dryRun === true) return preview(name, { sectionId: params.sectionId, ...body }, "Backend validates complete payload on write. No atomic version check.");
|
||||
return result((await api.put(`/categories/sections/${params.sectionId}`, body)).data);
|
||||
}
|
||||
if (name === "reorder_category_sections") {
|
||||
const items = params.sections;
|
||||
if (!Array.isArray(items) || items.length !== current.length || items.some((item) => !object(item) || !positiveId(item.id) || typeof item.order !== "number" || !Number.isFinite(item.order)) || new Set(items.map((item) => item.id)).size !== items.length || items.some((item) => !current.some((section) => section.id === item.id))) return failure("INVALID_INPUT", "sections must contain every current section ID exactly once with a finite numeric order", { currentIds: current.map((section) => section.id) });
|
||||
const body = { sections: items };
|
||||
if (params.dryRun === true) return preview(name, body, "Backend does not enforce an atomic version check.");
|
||||
return result((await api.put(`${categoryPath(categoryId)}/reorder`, body)).data);
|
||||
}
|
||||
if (name === "delete_category_section") {
|
||||
if (!positiveId(params.sectionId)) return failure("INVALID_INPUT", "sectionId must be a positive integer");
|
||||
const existing = current.find((section) => section.id === params.sectionId);
|
||||
if (!existing) return failure("NOT_FOUND", "Section does not belong to this category", { sectionId: params.sectionId });
|
||||
if (existing.isLocked) return failure("CONFLICT", "Locked section cannot be deleted");
|
||||
if (params.dryRun === true) return preview(name, { sectionId: params.sectionId }, "Backend does not enforce an atomic version check.");
|
||||
return result((await api.delete(`/categories/sections/${params.sectionId}`)).data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"profiles": [
|
||||
{
|
||||
"id": "customer-a-local",
|
||||
"displayName": "Customer A local",
|
||||
"kind": "local",
|
||||
"baseUrl": "http://localhost:3051/api",
|
||||
"usernameEnv": "MCP_CUSTOMER_A_LOCAL_USERNAME",
|
||||
"passwordEnv": "MCP_CUSTOMER_A_LOCAL_PASSWORD"
|
||||
},
|
||||
{
|
||||
"id": "customer-b-production",
|
||||
"displayName": "Customer B production",
|
||||
"kind": "production",
|
||||
"baseUrl": "https://api.customer-b.example/api",
|
||||
"usernameEnv": "MCP_CUSTOMER_B_USERNAME",
|
||||
"passwordEnv": "MCP_CUSTOMER_B_PASSWORD"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { loadTargetRegistry } from "./target-profiles";
|
||||
|
||||
test("profile registry rejects unsafe or ambiguous configuration", () => {
|
||||
expect(() => loadTargetRegistry({ MCP_DEFAULT_TARGET_PROFILE: "tenant-a" })).toThrow(/requires MCP_TARGET_PROFILES_FILE/);
|
||||
const dir = mkdtempSync(join(tmpdir(), "dbk-mcp-profiles-"));
|
||||
const file = join(dir, "profiles.json");
|
||||
const base = { id: "tenant-a", displayName: "Tenant A", kind: "production", baseUrl: "https://a.example/api", usernameEnv: "A_USER", passwordEnv: "A_PASS" };
|
||||
const env = { MCP_TARGET_PROFILES_FILE: file, MCP_DEFAULT_TARGET_PROFILE: "tenant-a", A_USER: "alice", A_PASS: "secret", B_USER: "bob", B_PASS: "secret2" };
|
||||
try {
|
||||
writeFileSync(file, JSON.stringify({ profiles: [base] }));
|
||||
expect(loadTargetRegistry(env)?.profiles[0]?.id).toBe("tenant-a");
|
||||
writeFileSync(file, JSON.stringify({ profiles: [base, base] }));
|
||||
expect(() => loadTargetRegistry(env)).toThrow(/duplicated/);
|
||||
writeFileSync(file, JSON.stringify({ profiles: [{ ...base, baseUrl: "http://customer.example/api" }] }));
|
||||
expect(() => loadTargetRegistry(env)).toThrow(/HTTPS/);
|
||||
for (const baseUrl of ["https://a.example/api?", "https://a.example/api#"]) {
|
||||
writeFileSync(file, JSON.stringify({ profiles: [{ ...base, baseUrl }] }));
|
||||
expect(() => loadTargetRegistry(env)).toThrow(/query, or fragment/);
|
||||
}
|
||||
writeFileSync(file, JSON.stringify({ profiles: [base, { ...base, id: "tenant-b", baseUrl: "https://A.EXAMPLE:443/api/", usernameEnv: "B_USER", passwordEnv: "B_PASS" }] }));
|
||||
expect(() => loadTargetRegistry(env)).toThrow(/duplicated/);
|
||||
writeFileSync(file, JSON.stringify({ profiles: [{ ...base, password: "literal" }] }));
|
||||
expect(() => loadTargetRegistry(env)).toThrow(/literal credentials/);
|
||||
writeFileSync(file, JSON.stringify({ profiles: [base] }));
|
||||
expect(() => loadTargetRegistry({ ...env, A_PASS: "", ADMIN_PASSWORD: "legacy-fallback" })).toThrow(/missing credential/);
|
||||
expect(() => loadTargetRegistry({ ...env, MCP_DEFAULT_TARGET_PROFILE: "missing" })).toThrow(/MCP_DEFAULT_TARGET_PROFILE/);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("MCP switches between two backends without sharing credentials or tokens", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "dbk-mcp-protocol-"));
|
||||
const file = join(dir, "profiles.json");
|
||||
const seenA: Array<{ path: string; auth: string; username?: string; password?: string }> = [];
|
||||
const seenB: Array<{ path: string; auth: string; username?: string; password?: string }> = [];
|
||||
const bSettingsBodies: unknown[] = [];
|
||||
let holdA = false;
|
||||
let signalAStarted: (() => void) | undefined;
|
||||
let releaseA: (() => void) | undefined;
|
||||
const aStarted = new Promise<void>((resolve) => { signalAStarted = resolve; });
|
||||
const aReleased = new Promise<void>((resolve) => { releaseA = resolve; });
|
||||
const createBackend = (seen: typeof seenA, token: string) => Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname;
|
||||
if (path === "/api/auth/login") {
|
||||
const body = await request.json() as { username: string; password: string };
|
||||
seen.push({ path, auth: request.headers.get("authorization") ?? "", ...body });
|
||||
return Response.json({ token });
|
||||
}
|
||||
seen.push({ path, auth: request.headers.get("authorization") ?? "" });
|
||||
if (token === "token-b" && path === "/api/settings" && request.method === "PUT") {
|
||||
const body = await request.json();
|
||||
bSettingsBodies.push(body);
|
||||
return Response.json(body);
|
||||
}
|
||||
if (token === "token-a" && path === "/api/products/brands" && holdA) {
|
||||
signalAStarted?.();
|
||||
await aReleased;
|
||||
}
|
||||
if (path === "/api/products/brands") return Response.json([{ id: token }]);
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
const a = createBackend(seenA, "token-a");
|
||||
const b = createBackend(seenB, "token-b");
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
writeFileSync(file, JSON.stringify({ profiles: [
|
||||
{ id: "tenant-a", displayName: "Tenant A", kind: "local", baseUrl: `http://localhost:${a.port}/api`, usernameEnv: "A_USER", passwordEnv: "A_PASS" },
|
||||
{ id: "tenant-b", displayName: "Tenant B", kind: "local", baseUrl: `http://localhost:${b.port}/api`, usernameEnv: "B_USER", passwordEnv: "B_PASS" },
|
||||
] }));
|
||||
const env = { ...process.env, MCP_TARGET_PROFILES_FILE: file, MCP_DEFAULT_TARGET_PROFILE: "tenant-a", A_USER: "alice", A_PASS: "pass-a", B_USER: "bob", B_PASS: "pass-b" } as Record<string, string>;
|
||||
const transport = new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, "index.ts")], cwd: import.meta.dir, env, stderr: "pipe" });
|
||||
client = new Client({ name: "profile-test", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
const tools = (await client.listTools()).tools;
|
||||
expect(tools.some((tool) => tool.name === "list_target_profiles")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "select_target_profile")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "upload_product_image_from_url")).toBe(false);
|
||||
expect(tools.find((tool) => tool.name === "list_product_brands")?.inputSchema.required).toContain("expectedProfileId");
|
||||
expect(tools.find((tool) => tool.name === "update_brand_name")?.inputSchema.required).toContain("expectedProfileId");
|
||||
const listed = await client.callTool({ name: "list_target_profiles", arguments: {} });
|
||||
expect(JSON.stringify(listed)).not.toContain("pass-a");
|
||||
expect(JSON.stringify(listed)).not.toContain("pass-b");
|
||||
const missing = await client.callTool({ name: "list_product_brands", arguments: {} });
|
||||
expect(missing.isError).toBe(true);
|
||||
const blockedUpload = await client.callTool({ name: "upload_product_image_from_url", arguments: { expectedProfileId: "tenant-a", imageUrl: "https://example.com/photo.jpg", productId: "p1" } });
|
||||
expect(blockedUpload.isError).toBe(true);
|
||||
expect(JSON.stringify(blockedUpload)).toContain("PROFILE_TOOL_UNAVAILABLE");
|
||||
expect(seenA.filter((item) => item.path === "/api/products/brands")).toHaveLength(0);
|
||||
const first = await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
||||
expect(first.structuredContent).toEqual({ data: [{ id: "token-a" }] });
|
||||
const legacy = await client.callTool({ name: "set_target_environment", arguments: { env: "production", customUrl: "https://evil.example/api" } });
|
||||
expect(legacy.isError).toBe(true);
|
||||
expect((await client.callTool({ name: "get_target_environment", arguments: {} })).structuredContent).toMatchObject({ activeProfile: { id: "tenant-a" } });
|
||||
const unknown = await client.callTool({ name: "select_target_profile", arguments: { profileId: "unknown" } });
|
||||
expect(unknown.isError).toBe(true);
|
||||
await client.callTool({ name: "select_target_profile", arguments: { profileId: "tenant-b" } });
|
||||
const staleRead = await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
||||
expect(staleRead.isError).toBe(true);
|
||||
const staleWrite = await client.callTool({ name: "update_brand_name", arguments: { expectedProfileId: "tenant-a", brandName: "Wrong tenant" } });
|
||||
expect(staleWrite.isError).toBe(true);
|
||||
expect(seenB).toHaveLength(0); // no login, stale read, or stale write reached backend
|
||||
const second = await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-b" } });
|
||||
expect(second.structuredContent).toEqual({ data: [{ id: "token-b" }] });
|
||||
const validWrite = await client.callTool({ name: "update_brand_name", arguments: { expectedProfileId: "tenant-b", brandName: "Tenant B" } });
|
||||
expect(validWrite.isError).toBeFalsy();
|
||||
expect(bSettingsBodies).toEqual([{ brandName: "Tenant B" }]);
|
||||
await client.callTool({ name: "select_target_profile", arguments: { profileId: "tenant-a" } });
|
||||
await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
||||
holdA = true;
|
||||
const inFlight = client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
||||
await aStarted;
|
||||
const switching = client.callTool({ name: "select_target_profile", arguments: { profileId: "tenant-b" } });
|
||||
releaseA?.();
|
||||
expect((await inFlight).structuredContent).toEqual({ data: [{ id: "token-a" }] });
|
||||
expect((await switching).structuredContent).toMatchObject({ activeProfile: { id: "tenant-b" } });
|
||||
expect((await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-b" } })).structuredContent).toEqual({ data: [{ id: "token-b" }] });
|
||||
expect(seenA.filter((item) => item.path === "/api/auth/login")).toEqual([{ path: "/api/auth/login", auth: "", username: "alice", password: "pass-a" }]);
|
||||
expect(seenB.filter((item) => item.path === "/api/auth/login")).toEqual([{ path: "/api/auth/login", auth: "", username: "bob", password: "pass-b" }]);
|
||||
expect(seenA.filter((item) => item.path === "/api/products/brands").every((item) => item.auth === "Bearer token-a")).toBe(true);
|
||||
expect(seenB.filter((item) => item.path === "/api/products/brands").every((item) => item.auth === "Bearer token-b")).toBe(true);
|
||||
await client.close();
|
||||
client = undefined;
|
||||
const restartTransport = new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, "index.ts")], cwd: import.meta.dir, env, stderr: "pipe" });
|
||||
client = new Client({ name: "profile-restart-test", version: "1.0.0" });
|
||||
await client.connect(restartTransport);
|
||||
expect((await client.callTool({ name: "get_target_environment", arguments: {} })).structuredContent).toMatchObject({ mode: "profiles", activeProfile: { id: "tenant-a" } });
|
||||
} finally {
|
||||
await client?.close();
|
||||
a.stop(); b.stop();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
export type TargetProfile = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
kind: "local" | "staging" | "production";
|
||||
baseUrl: string;
|
||||
usernameEnv: string;
|
||||
passwordEnv: string;
|
||||
};
|
||||
|
||||
export type TargetRegistry = {
|
||||
profiles: TargetProfile[];
|
||||
defaultProfileId: string;
|
||||
};
|
||||
|
||||
const envNamePattern = /^[A-Z_][A-Z0-9_]*$/;
|
||||
const profileIdPattern = /^[a-z][a-z0-9_-]*$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateBaseUrl(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("Target profile baseUrl must be a URL string.");
|
||||
let url: URL;
|
||||
try { url = new URL(value); } catch { throw new Error("Target profile baseUrl is invalid."); }
|
||||
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
||||
throw new Error("Target profile baseUrl must use HTTPS, except HTTP loopback.");
|
||||
}
|
||||
if (url.username || url.password || value.includes("?") || value.includes("#") || !url.hostname) {
|
||||
throw new Error("Target profile baseUrl cannot include credentials, query, or fragment.");
|
||||
}
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Private registry stores references to secret environment variables, never values. */
|
||||
export function loadTargetRegistry(env: NodeJS.ProcessEnv = process.env): TargetRegistry | null {
|
||||
const file = env.MCP_TARGET_PROFILES_FILE;
|
||||
if (!file) {
|
||||
if (env.MCP_DEFAULT_TARGET_PROFILE) throw new Error("MCP_DEFAULT_TARGET_PROFILE requires MCP_TARGET_PROFILES_FILE.");
|
||||
return null;
|
||||
}
|
||||
if (!isAbsolute(file)) throw new Error("MCP_TARGET_PROFILES_FILE must be an absolute path.");
|
||||
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(readFileSync(file, "utf8")); }
|
||||
catch { throw new Error("MCP target profile registry could not be read as JSON."); }
|
||||
if (!isRecord(raw) || Object.keys(raw).some((key) => key !== "profiles") || !Array.isArray(raw.profiles) || raw.profiles.length === 0) {
|
||||
throw new Error("MCP target profile registry must contain a non-empty profiles array only.");
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
const credentialNames = new Set<string>();
|
||||
const urls = new Set<string>();
|
||||
const profiles = raw.profiles.map((entry: unknown): TargetProfile => {
|
||||
if (!isRecord(entry) || Object.keys(entry).some((key) => !["id", "displayName", "kind", "baseUrl", "usernameEnv", "passwordEnv"].includes(key))) {
|
||||
throw new Error("Target profile contains unsupported fields; literal credentials are not allowed.");
|
||||
}
|
||||
const { id, displayName, kind, baseUrl, usernameEnv, passwordEnv } = entry;
|
||||
if (typeof id !== "string" || !profileIdPattern.test(id) || ids.has(id)) throw new Error("Target profile ID is invalid or duplicated.");
|
||||
if (typeof displayName !== "string" || !displayName.trim()) throw new Error("Target profile displayName is required.");
|
||||
if (kind !== "local" && kind !== "staging" && kind !== "production") throw new Error("Target profile kind must be local, staging, or production.");
|
||||
if (typeof usernameEnv !== "string" || !envNamePattern.test(usernameEnv) || typeof passwordEnv !== "string" || !envNamePattern.test(passwordEnv) || usernameEnv === passwordEnv) {
|
||||
throw new Error("Target profile credential environment variable names are invalid.");
|
||||
}
|
||||
if (credentialNames.has(usernameEnv) || credentialNames.has(passwordEnv)) throw new Error("Target profiles must use distinct credential environment variables.");
|
||||
if (!env[usernameEnv] || !env[passwordEnv]) throw new Error(`Target profile ${id} is missing credential environment variables.`);
|
||||
const normalizedUrl = validateBaseUrl(baseUrl);
|
||||
if (urls.has(normalizedUrl)) throw new Error("Target profile baseUrl is duplicated.");
|
||||
ids.add(id);
|
||||
credentialNames.add(usernameEnv);
|
||||
credentialNames.add(passwordEnv);
|
||||
urls.add(normalizedUrl);
|
||||
return { id, displayName, kind, baseUrl: normalizedUrl, usernameEnv, passwordEnv };
|
||||
});
|
||||
|
||||
const defaultProfileId = env.MCP_DEFAULT_TARGET_PROFILE;
|
||||
if (!defaultProfileId || !ids.has(defaultProfileId)) throw new Error("MCP_DEFAULT_TARGET_PROFILE must identify a configured target profile.");
|
||||
return { profiles, defaultProfileId };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { presentToolDefinitions, withStructuredContent } from "./tool-presentation";
|
||||
|
||||
describe("LLM tool presentation", () => {
|
||||
test("marks read-only, destructive and open-world tools accurately", () => {
|
||||
const input = ["get_product_detail", "delete_category", "upload_product_image_from_url"].map((name) => ({
|
||||
name,
|
||||
inputSchema: { type: "object" as const },
|
||||
}));
|
||||
const tools = presentToolDefinitions(input);
|
||||
|
||||
expect(tools[0]?.annotations).toMatchObject({ readOnlyHint: true, idempotentHint: true, destructiveHint: false });
|
||||
expect(tools[1]?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true });
|
||||
expect(tools[2]?.annotations).toMatchObject({ readOnlyHint: false, openWorldHint: true });
|
||||
});
|
||||
|
||||
test("adds structured JSON without removing the existing text content", () => {
|
||||
const result = { content: [{ type: "text" as const, text: '{"success":true,"count":2}' }] };
|
||||
expect(withStructuredContent(result)).toEqual({
|
||||
...result,
|
||||
structuredContent: { success: true, count: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves plain text responses untouched", () => {
|
||||
const result = { content: [{ type: "text" as const, text: "Connection successful!" }] };
|
||||
expect(withStructuredContent(result)).toEqual(result);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
const READ_ONLY_PREFIXES = ["get_", "list_", "search_", "validate_", "audit_", "ping_"];
|
||||
const DESTRUCTIVE_PREFIXES = ["delete_", "remove_", "restore_", "sync_"];
|
||||
|
||||
/** Advertise intent without changing the existing tool names or input contracts. */
|
||||
export function presentToolDefinitions(tools: Tool[]): Tool[] {
|
||||
return tools.map((tool) => {
|
||||
const readOnly = READ_ONLY_PREFIXES.some((prefix) => tool.name.startsWith(prefix));
|
||||
const destructive = DESTRUCTIVE_PREFIXES.some((prefix) => tool.name.startsWith(prefix));
|
||||
|
||||
return {
|
||||
...tool,
|
||||
annotations: {
|
||||
...tool.annotations,
|
||||
readOnlyHint: readOnly,
|
||||
...(readOnly ? { idempotentHint: true, destructiveHint: false } : {}),
|
||||
...(destructive ? { destructiveHint: true } : {}),
|
||||
...(tool.name === "upload_product_image_from_url" ? { openWorldHint: true } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep text responses for existing clients while exposing JSON as machine-readable data. */
|
||||
export function withStructuredContent(result: CallToolResult): CallToolResult {
|
||||
if (result.structuredContent) return result;
|
||||
const textBlocks = result.content.filter((item) => item.type === "text");
|
||||
if (textBlocks.length !== 1) return result;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(textBlocks[0]!.text);
|
||||
if (parsed === null || typeof parsed !== "object") return result;
|
||||
return {
|
||||
...result,
|
||||
structuredContent: Array.isArray(parsed) ? { data: parsed } : parsed as Record<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user