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