This commit is contained in:
Your Name committed 2026-10-11 06:43:08 +07:00
1 parent 9237c0eded
commit f5893c3fad
16 files changed
+1230 -114

No files matched your search

+141 -57
View File
@@ -4,14 +4,41 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type { CallToolRequest, CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import FormData from "form-data";
import { MENU_TOOL_DEFINITIONS, getLegacyFooterMenuDeprecation, handleMenuTool } from "./menu-toolkit";
import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit";
import { STOREFRONT_TOOL_DEFINITIONS, handleStorefrontTool } from "./storefront-toolkit";
import { ASSET_TOOL_DEFINITIONS, handleAssetTool } from "./asset-toolkit";
import { presentToolDefinitions, withStructuredContent } from "./tool-presentation";
import { loadTargetRegistry } from "./target-profiles";
const targetRegistry = loadTargetRegistry();
let activeProfileId = targetRegistry?.defaultProfileId;
const profileTokens = new Map<string, string>();
const TARGET_MANAGEMENT_TOOLS = new Set(["get_target_environment", "list_target_profiles", "select_target_profile", "set_target_environment"]);
function bindToolsToProfile(tools: Tool[]): Tool[] {
if (!targetRegistry) return tools;
return tools.filter((tool) => tool.name !== "upload_product_image_from_url").map((tool) => TARGET_MANAGEMENT_TOOLS.has(tool.name) ? tool : {
...tool,
description: `${tool.description ?? ""} In profile mode, pass expectedProfileId from get_target_environment; the call fails if another chat switched targets.`,
inputSchema: {
...tool.inputSchema,
properties: {
...tool.inputSchema.properties,
expectedProfileId: { type: "string", description: "Active target profile ID to guard against cross-customer calls." },
},
required: [...new Set([...(tool.inputSchema.required ?? []), "expectedProfileId"])],
},
});
}
const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api";
const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api";
let BACKEND_URL = process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
let BACKEND_URL = targetRegistry
? targetRegistry.profiles.find((profile) => profile.id === activeProfileId)!.baseUrl
: process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
const ADMIN_USERNAME = process.env.MCP_ADMIN_USERNAME || process.env.ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MCP_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || "homaroma123";
const CATEGORY_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
@@ -23,6 +50,7 @@ export const api = axios.create({
});
function currentTargetEnvironment() {
if (targetRegistry) return "profile";
if (BACKEND_URL === LOCAL_BACKEND_URL) return "local";
if (BACKEND_URL === PRODUCTION_BACKEND_URL) return "production";
return "custom";
@@ -53,28 +81,37 @@ function normalizeToolError(err: any) {
// MCP server state is process-local because this server runs over stdio.
api.interceptors.request.use(async (config) => {
if (!authToken && config.url !== "/auth/login") {
if (targetRegistry && (!config.url?.startsWith("/") || config.url.startsWith("//") || config.baseURL !== BACKEND_URL)) {
throw new Error("Profile requests must remain on the selected backend.");
}
const token = targetRegistry ? profileTokens.get(activeProfileId!) : authToken;
if (!token && config.url !== "/auth/login") {
console.error("[MCP] No token found. Attempting auto-login...");
await performLogin();
}
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
const currentToken = targetRegistry ? profileTokens.get(activeProfileId!) : authToken;
if (targetRegistry && !currentToken) throw new Error("Target profile authentication failed.");
if (currentToken) {
config.headers.Authorization = `Bearer ${currentToken}`;
}
if (targetRegistry) config.maxRedirects = 0;
return config;
});
async function performLogin() {
try {
const profile = targetRegistry?.profiles.find((item) => item.id === activeProfileId);
const res = await axios.post(`${BACKEND_URL}/auth/login`, {
username: ADMIN_USERNAME,
password: ADMIN_PASSWORD,
});
username: profile ? process.env[profile.usernameEnv] : ADMIN_USERNAME,
password: profile ? process.env[profile.passwordEnv] : ADMIN_PASSWORD,
}, profile ? { maxRedirects: 0 } : undefined);
if (res.data && res.data.token) {
authToken = res.data.token;
if (profile) profileTokens.set(profile.id, res.data.token);
else authToken = res.data.token;
console.error("[MCP] Successfully logged in to Backend API.");
}
} catch (err: any) {
console.error("[MCP] Auto-login failed:", err.message);
console.error("[MCP] Auto-login failed:", targetRegistry ? "profile authentication failed" : err.message);
}
}
@@ -154,12 +191,13 @@ const server = new Server(
capabilities: {
tools: {},
},
instructions: "Check get_target_environment and confirm the exact customer backend URL before writes. In profile mode, use list_target_profiles and select_target_profile to switch approved customer backends; legacy arbitrary URL switching is disabled. Prefer compact discovery tools, then read the target record before editing. Use dryRun and expectedVersion where offered; verify the result by reading it back. Production writes require an explicit user request.",
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
tools: presentToolDefinitions(bindToolsToProfile([
{
name: "get_target_environment",
description: "Return the MCP server's current backend target environment and base URL. Use this before write operations when you need to confirm local vs production.",
@@ -167,7 +205,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "set_target_environment",
description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.",
description: "Legacy mode only: switch the backend target (local, production, or a trusted customUrl). Disabled when target profiles are configured; use select_target_profile instead.",
inputSchema: {
type: "object",
properties: {
@@ -177,17 +215,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
customUrl: {
type: "string",
description: "Optional custom URL to use for the API (e.g., https://api.pkh.kitizone.com/api)"
description: "Optional custom URL to use for the API (e.g., https://api.example.com/api)"
}
},
required: ["env"]
}
},
...(targetRegistry ? [
{
name: "list_target_profiles",
description: "List approved customer backend profiles (ID, display name, kind, URL; no credentials). Read before selecting a customer.",
inputSchema: { type: "object" as const, properties: {} },
},
{
name: "select_target_profile",
description: "Select an approved customer backend profile by ID. All later calls use only that profile's URL and credentials. Confirm the exact URL with get_target_environment before writes.",
inputSchema: { type: "object" as const, properties: { profileId: { type: "string" } }, required: ["profileId"] },
},
] : []),
...MENU_TOOL_DEFINITIONS,
...SECTION_TOOL_DEFINITIONS,
...STOREFRONT_TOOL_DEFINITIONS,
...ASSET_TOOL_DEFINITIONS,
{
name: "ping_backend",
description: "Ping the backend to verify connection and authentication",
description: "Check backend connectivity through the public products endpoint. This does not verify admin authentication.",
inputSchema: { type: "object", properties: {} },
},
{
@@ -525,7 +577,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "create_product",
description: "Create a new product, optionally including its options and variant matrix. Provide categoryId/brandId OR categoryName/brandName.",
description: "Create a product. For a simple product without variants, pass root price and stock (and optional sku/barcode); omitted values default to 0. For products with variants, put price and stock on each variant. Provide categoryId/brandId OR categoryName/brandName.",
inputSchema: {
type: "object",
properties: {
@@ -536,6 +588,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
name: { type: "string" },
description: { type: "string" },
details: { type: "string" },
price: { type: "number", minimum: 0, description: "Simple product price; used only when variants are omitted." },
stock: { type: "number", minimum: 0, description: "Simple product stock; used only when variants are omitted." },
sku: { type: "string", description: "Simple product SKU; used only when variants are omitted." },
barcode: { type: "string", description: "Simple product barcode; used only when variants are omitted." },
translations: {
type: "object",
description: "Object with lang codes as keys (e.g. 'th', 'en') and {name, description, details} as values",
@@ -593,7 +649,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "bulk_create_products",
description: "Create multiple products at once. Each object in the array has the same schema as create_product.",
description: "Create multiple products. For each simple product without variants, pass root price and stock (and optional sku/barcode); omitted values default to 0. For products with variants, set price and stock on each variant.",
inputSchema: {
type: "object",
properties: {
@@ -609,6 +665,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
name: { type: "string" },
description: { type: "string" },
details: { type: "string" },
price: { type: "number", minimum: 0, description: "Simple product price; used only when variants are omitted." },
stock: { type: "number", minimum: 0, description: "Simple product stock; used only when variants are omitted." },
sku: { type: "string", description: "Simple product SKU; used only when variants are omitted." },
barcode: { type: "string", description: "Simple product barcode; used only when variants are omitted." },
translations: {
type: "object",
additionalProperties: {
@@ -1081,11 +1141,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}
}
},
],
] as Tool[])),
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
async function handleToolRequest(request: CallToolRequest) {
const params = request.params.arguments as any;
try {
@@ -1098,25 +1158,55 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (sectionToolResponse) {
return sectionToolResponse;
}
const storefrontToolResponse = await handleStorefrontTool(api, request.params.name, params);
if (storefrontToolResponse) {
return storefrontToolResponse;
}
const assetToolResponse = await handleAssetTool(api, request.params.name, params);
if (assetToolResponse) {
return assetToolResponse;
}
switch (request.params.name) {
case "get_target_environment": {
const activeProfile = targetRegistry?.profiles.find((profile) => profile.id === activeProfileId);
return {
content: [
{
type: "text",
text: JSON.stringify({
mode: targetRegistry ? "profiles" : "legacy",
...(activeProfile ? { activeProfile: { id: activeProfile.id, displayName: activeProfile.displayName, kind: activeProfile.kind } } : {}),
env: currentTargetEnvironment(),
baseUrl: BACKEND_URL,
localBaseUrl: LOCAL_BACKEND_URL,
productionBaseUrl: PRODUCTION_BACKEND_URL,
authenticated: Boolean(authToken),
...(!targetRegistry ? { localBaseUrl: LOCAL_BACKEND_URL, productionBaseUrl: PRODUCTION_BACKEND_URL } : {}),
authenticated: targetRegistry ? Boolean(profileTokens.get(activeProfileId!)) : Boolean(authToken),
}, null, 2)
}
]
};
}
case "list_target_profiles": {
if (!targetRegistry) throw new Error("Target profiles are not configured.");
return { content: [{ type: "text", text: JSON.stringify({
activeProfileId,
profiles: targetRegistry.profiles.map(({ id, displayName, kind, baseUrl }) => ({ id, displayName, kind, baseUrl })),
}, null, 2) }] };
}
case "select_target_profile": {
if (!targetRegistry) throw new Error("Target profiles are not configured.");
const profile = targetRegistry.profiles.find((item) => item.id === params?.profileId);
if (!profile) throw new Error("Unknown target profile ID.");
activeProfileId = profile.id;
BACKEND_URL = profile.baseUrl;
api.defaults.baseURL = profile.baseUrl;
return { content: [{ type: "text", text: JSON.stringify({
mode: "profiles", activeProfile: { id: profile.id, displayName: profile.displayName, kind: profile.kind }, baseUrl: profile.baseUrl,
authenticated: Boolean(profileTokens.get(profile.id)),
}, null, 2) }] };
}
case "set_target_environment": {
if (targetRegistry) throw new Error("Legacy target switching is disabled in profile mode. Use select_target_profile.");
const env = params.env;
const customUrl = params.customUrl;
@@ -1498,11 +1588,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
case "update_theme_tokens": {
const tokenParams = params as Record<string, string>;
const currentSettingsRes = await api.get("/settings");
const currentSettings = currentSettingsRes.data || {};
const currentTokens = currentSettings.themeTokens || {};
const newTokens = { ...currentTokens };
const newTokens: Record<string, string> = {};
const validKeys = [
"brandPrimary", "brandPrimaryHover", "brandAccent", "brandWarm",
@@ -1520,15 +1606,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
}
const mergedSettings = { ...currentSettings, themeTokens: newTokens };
for (const key in mergedSettings) {
if (mergedSettings[key] === null) {
delete mergedSettings[key];
}
if (Object.keys(newTokens).length === 0) {
throw new Error("At least one theme token must be supplied.");
}
const res = await api.put("/settings", mergedSettings);
const res = await api.put("/settings", { themeTokens: newTokens });
return { content: [{ type: "text", text: "Theme tokens updated successfully:\\n" + JSON.stringify(res.data.themeTokens, null, 2) }] };
}
case "get_footer_brand": {
@@ -1545,17 +1626,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
case "update_brand_name": {
const { brandName } = params as any;
const currentSettingsRes = await api.get("/settings");
const currentSettings = currentSettingsRes.data || {};
const mergedSettings = { ...currentSettings, brandName };
for (const key in mergedSettings) {
if (mergedSettings[key] === null) {
delete mergedSettings[key];
}
}
const res = await api.put("/settings", mergedSettings);
const res = await api.put("/settings", { brandName });
return {
content: [
{
@@ -1812,18 +1883,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (params.socialLinks?.tiktokUrl !== undefined) settingsPayload.tiktokUrl = params.socialLinks.tiktokUrl;
if (Object.keys(settingsPayload).length > 0) {
// First fetch existing settings because the backend requires full payload validation
const currentSettingsRes = await api.get("/settings");
const currentSettings = currentSettingsRes.data || {};
const mergedSettings = { ...currentSettings, ...settingsPayload };
// Remove null values so Elysia validation doesn't fail on t.Optional(t.String())
for (const key in mergedSettings) {
if (mergedSettings[key] === null) {
delete mergedSettings[key];
}
}
await api.put("/settings", mergedSettings);
await api.put("/settings", settingsPayload);
results.push({ type: "settings", status: "success" });
}
}
@@ -1850,12 +1910,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (err: any) {
const errorMsg = normalizeToolError(err);
const errorMsg = targetRegistry && axios.isAxiosError(err)
? `Backend request failed${err.response?.status ? ` (HTTP ${err.response.status})` : ""}.`
: normalizeToolError(err);
return {
content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }],
isError: true,
};
}
}
// A profile switch cannot interleave with requests that still use the previous target.
let toolQueue: Promise<void> = Promise.resolve();
server.setRequestHandler(CallToolRequestSchema, (request) => {
const run = toolQueue.then(async () => {
if (targetRegistry && request.params.name === "upload_product_image_from_url") {
return { content: [{ type: "text" as const, text: "PROFILE_TOOL_UNAVAILABLE: upload_product_image_from_url is disabled in profile mode until the downloader has an outbound URL allowlist and resource limits." }], isError: true };
}
if (targetRegistry && !TARGET_MANAGEMENT_TOOLS.has(request.params.name)) {
const args = request.params.arguments;
if (!args || args.expectedProfileId !== activeProfileId) {
return { content: [{ type: "text" as const, text: "Target profile mismatch or missing expectedProfileId. Call get_target_environment and retry only after confirming the intended customer." }], isError: true };
}
const { expectedProfileId: _guard, ...backendArgs } = args;
const boundRequest = { ...request, params: { ...request.params, arguments: backendArgs } } as CallToolRequest;
return withStructuredContent(await handleToolRequest(boundRequest) as CallToolResult);
}
return withStructuredContent(await handleToolRequest(request) as CallToolResult);
});
toolQueue = run.then(() => undefined, () => undefined);
return run;
});
async function main() {