diff --git a/apps/mcp-server/.env.example b/apps/mcp-server/.env.example index 20afe0f..46ee6b6 100644 --- a/apps/mcp-server/.env.example +++ b/apps/mcp-server/.env.example @@ -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 diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 7c8ccd3..e25fcc0 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -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 diff --git a/apps/mcp-server/asset-toolkit.test.ts b/apps/mcp-server/asset-toolkit.test.ts new file mode 100644 index 0000000..8b281a8 --- /dev/null +++ b/apps/mcp-server/asset-toolkit.test.ts @@ -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>) => 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; + 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 }); + } +}); diff --git a/apps/mcp-server/asset-toolkit.ts b/apps/mcp-server/asset-toolkit.ts new file mode 100644 index 0000000..a36030a --- /dev/null +++ b/apps/mcp-server/asset-toolkit.ts @@ -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 = { + 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 { + if (name !== "upload_cms_asset") return null; + const params = rawParams && typeof rawParams === "object" && !Array.isArray(rawParams) ? rawParams as Record : {}; + 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})` : ""}.`); + } +} diff --git a/apps/mcp-server/assets/essential-oils-article-hero.png b/apps/mcp-server/assets/essential-oils-article-hero.png new file mode 100644 index 0000000..2b8417e Binary files /dev/null and b/apps/mcp-server/assets/essential-oils-article-hero.png differ diff --git a/apps/mcp-server/index.contract.test.ts b/apps/mcp-server/index.contract.test.ts new file mode 100644 index 0000000..20fc403 --- /dev/null +++ b/apps/mcp-server/index.contract.test.ts @@ -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; + 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(); + } +}); + diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 8199722..73c594e 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -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(); +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; - - const currentSettingsRes = await api.get("/settings"); - const currentSettings = currentSettingsRes.data || {}; - const currentTokens = currentSettings.themeTokens || {}; - const newTokens = { ...currentTokens }; + const newTokens: Record = {}; 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 = 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() { diff --git a/apps/mcp-server/section-toolkit.test.ts b/apps/mcp-server/section-toolkit.test.ts index cc2ef10..3863e40 100644 --- a/apps/mcp-server/section-toolkit.test.ts +++ b/apps/mcp-server/section-toolkit.test.ts @@ -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", {}); diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts index 47591f2..b91acc3 100644 --- a/apps/mcp-server/section-toolkit.ts +++ b/apps/mcp-server/section-toolkit.ts @@ -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; }; -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 = { @@ -93,7 +99,7 @@ const PAGE_BLOCK_METADATA: Record = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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: "

สำรวจศิลปะแห่งกลิ่นหอมผ่านวัตถุดิบที่คัดสรร

", - en: "

Explore the art of fragrance through carefully selected ingredients.

", + th: "

เรียนรู้เรื่องราวและแนวคิดเบื้องหลังคอลเลกชันนี้

", + en: "

Explore the story and ideas behind this collection.

", }, }, }, @@ -406,14 +412,14 @@ const PAGE_BLOCK_METADATA: Record = { ], 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 = { 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 = { ], }, 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 = { 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 = { 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 = { 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 = { }, 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { ], }, }, + 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 = { 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 = []; + 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" } }); + }); +}); diff --git a/apps/mcp-server/storefront-toolkit.ts b/apps/mcp-server/storefront-toolkit.ts new file mode 100644 index 0000000..0c189e1 --- /dev/null +++ b/apps/mcp-server/storefront-toolkit.ts @@ -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 => !!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(STOREFRONT_TOOL_DEFINITIONS.map((tool) => tool.name)); +const categoryPath = (id: number) => `/categories/${id}/sections`; +async function sectionsForEdit(api: AxiosInstance, categoryId: number): Promise { + 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 { + 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); + } +} diff --git a/apps/mcp-server/target-profiles.example.json b/apps/mcp-server/target-profiles.example.json new file mode 100644 index 0000000..486cb84 --- /dev/null +++ b/apps/mcp-server/target-profiles.example.json @@ -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" + } + ] +} diff --git a/apps/mcp-server/target-profiles.test.ts b/apps/mcp-server/target-profiles.test.ts new file mode 100644 index 0000000..4c8bff8 --- /dev/null +++ b/apps/mcp-server/target-profiles.test.ts @@ -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((resolve) => { signalAStarted = resolve; }); + const aReleased = new Promise((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; + 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 }); + } +}); diff --git a/apps/mcp-server/target-profiles.ts b/apps/mcp-server/target-profiles.ts new file mode 100644 index 0000000..7d75e2f --- /dev/null +++ b/apps/mcp-server/target-profiles.ts @@ -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 { + 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(); + const credentialNames = new Set(); + const urls = new Set(); + 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 }; +} diff --git a/apps/mcp-server/tool-presentation.test.ts b/apps/mcp-server/tool-presentation.test.ts new file mode 100644 index 0000000..8b233a6 --- /dev/null +++ b/apps/mcp-server/tool-presentation.test.ts @@ -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); + }); +}); diff --git a/apps/mcp-server/tool-presentation.ts b/apps/mcp-server/tool-presentation.ts new file mode 100644 index 0000000..c4724ca --- /dev/null +++ b/apps/mcp-server/tool-presentation.ts @@ -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, + }; + } catch { + return result; + } +}