@sea-ecommerce/admin-mcp-server 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21638,6 +21638,15 @@ var AuthManager = class {
21638
21638
  };
21639
21639
 
21640
21640
  // src/client.ts
21641
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
21642
+ function mimeOf(path) {
21643
+ const ext = path.split(".").pop()?.toLowerCase() || "";
21644
+ if (ext === "webp") return "image/webp";
21645
+ if (ext === "png") return "image/png";
21646
+ if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
21647
+ if (ext === "gif") return "image/gif";
21648
+ return "application/octet-stream";
21649
+ }
21641
21650
  var MedusaAdminError = class extends Error {
21642
21651
  constructor(status, message) {
21643
21652
  super(message);
@@ -21685,12 +21694,111 @@ var MedusaAdminClient = class {
21685
21694
  getProduct(id) {
21686
21695
  return this.request("GET", `/admin/products/${encodeURIComponent(id)}`);
21687
21696
  }
21697
+ /** 列出销售渠道(用于默认渠道解析) */
21698
+ listSalesChannels(params = {}) {
21699
+ return this.request("GET", `/admin/sales-channels${buildQuery(params)}`);
21700
+ }
21688
21701
  createProduct(body) {
21689
21702
  return this.request("POST", "/admin/products", body);
21690
21703
  }
21691
21704
  updateProduct(id, body) {
21692
21705
  return this.request("POST", `/admin/products/${encodeURIComponent(id)}`, body);
21693
21706
  }
21707
+ /**
21708
+ * 整体替换某变体的价格(多币种)。Medusa v2 价格挂在「变体」上,
21709
+ * POST /admin/products/{id} 携带 variants:[{id, prices}] 即整体替换该变体价格(不会累加)。
21710
+ * prices 数组形如 [{ currency_code, amount }, ...],amount 为主货币单位(元),切勿 ×100。
21711
+ */
21712
+ updateProductPrices(productId, variantId, prices) {
21713
+ return this.request("POST", `/admin/products/${encodeURIComponent(productId)}`, {
21714
+ variants: [{ id: variantId, prices }]
21715
+ });
21716
+ }
21717
+ /** 批量写入翻译(POST /admin/translations/batch)。reference 一般为 "product"。 */
21718
+ async batchTranslations(create, update = []) {
21719
+ return this.request("POST", "/admin/translations/batch", { create, update });
21720
+ }
21721
+ /**
21722
+ * 把一组商品加入销售渠道(POST /admin/sales-channels/{id}/products)。
21723
+ * ⚠️ 注意:Medusa v2 该端点挂在「渠道」下,不是商品下;body 为 { add:[productIds], remove:[] }。
21724
+ * 商品只有挂到渠道,storefront(按 publishable key → 渠道)才能查到,否则详情页拉不到。
21725
+ */
21726
+ addProductsToSalesChannel(channelId, productIds) {
21727
+ return this.request("POST", `/admin/sales-channels/${encodeURIComponent(channelId)}/products`, {
21728
+ add: productIds,
21729
+ remove: []
21730
+ });
21731
+ }
21732
+ // ─── 商品详情页富内容(product_content 模块)───
21733
+ /**
21734
+ * 读取某商品某语言的富内容块(GET /admin/product-contents)。
21735
+ * locale 用区域码:en-US / zh-CN / ms-MY / id-ID(与 storefront useProductContent 的 localeMap 一致)。
21736
+ * 返回 { product_content: { id, product_id, locale, status, blocks(JSON 字符串) } | null }。
21737
+ */
21738
+ getProductContent(productId, locale = "en-US", status = "published") {
21739
+ return this.request(
21740
+ "GET",
21741
+ `/admin/product-contents${buildQuery({ product_id: productId, locale, status })}`
21742
+ );
21743
+ }
21744
+ /**
21745
+ * 创建/更新某商品某语言的富内容块(POST /admin/product-contents)。
21746
+ * body: { product_id, locale, status?, blocks };blocks 为 ContentBlock[] 数组
21747
+ * (type ∈ hero/richtext/image_gallery/video/faq/specs)。按 (product_id, locale) upsert。
21748
+ */
21749
+ createProductContent(body) {
21750
+ return this.request("POST", "/admin/product-contents", body);
21751
+ }
21752
+ /**
21753
+ * 上传一组文件到 Medusa 文件服务(POST /admin/uploads,multipart 字段名 files),
21754
+ * 返回上传后的公开 URL 列表。
21755
+ */
21756
+ async uploadFiles(files) {
21757
+ if (!files.length) return [];
21758
+ const form = new FormData();
21759
+ for (const f of files) {
21760
+ form.append("files", new Blob([new Uint8Array(f.buffer)], { type: f.mimetype }), f.filename);
21761
+ }
21762
+ const doPost = async () => {
21763
+ const res = await fetch(`${this.config.backendUrl}/admin/uploads`, {
21764
+ method: "POST",
21765
+ headers: this.auth.headers(),
21766
+ body: form
21767
+ });
21768
+ if (res.status === 401) {
21769
+ this.auth.clear();
21770
+ await this.auth.login();
21771
+ return doPost();
21772
+ }
21773
+ if (!res.ok) {
21774
+ const text = await res.text();
21775
+ throw new MedusaAdminError(res.status, text || res.statusText);
21776
+ }
21777
+ return await res.json();
21778
+ };
21779
+ const data = await doPost();
21780
+ return (data.files || []).map((f) => f.url);
21781
+ }
21782
+ /**
21783
+ * 把一组「本地文件路径 或 图片 URL」解析为可挂到商品的 URL:
21784
+ * - http(s) URL 原样透传;
21785
+ * - 本地路径读取后走 /admin/uploads 上传,返回 URL。
21786
+ */
21787
+ async uploadProductAssets(pathsOrUrls) {
21788
+ const urls = [];
21789
+ const toUpload = [];
21790
+ for (const p of pathsOrUrls) {
21791
+ if (/^https?:\/\//i.test(p)) {
21792
+ urls.push(p);
21793
+ continue;
21794
+ }
21795
+ if (!existsSync2(p)) throw new MedusaAdminError(0, `\u56FE\u7247\u6587\u4EF6\u4E0D\u5B58\u5728: ${p}`);
21796
+ const buf = readFileSync2(p);
21797
+ toUpload.push({ filename: p.split("/").pop() || "image", mimetype: mimeOf(p), buffer: buf });
21798
+ }
21799
+ if (toUpload.length) urls.push(...await this.uploadFiles(toUpload));
21800
+ return urls;
21801
+ }
21694
21802
  // ─── 仓库(StockLocation)/ 库存 ───
21695
21803
  listStockLocations(params = {}) {
21696
21804
  return this.request("GET", `/admin/stock-locations${buildQuery(params)}`);
@@ -21781,7 +21889,9 @@ var productTools = [
21781
21889
  description: `\u5728\u540E\u53F0\u589E\u52A0\u4E00\u4E2A\u5546\u54C1\u3002
21782
21890
  \u89C4\u5219\uFF08\xA716.1\uFF09\uFF1AHandle \u53EA\u80FD\u542B\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u8FDE\u5B57\u7B26(-)\uFF0C\u5DE5\u5177\u4F1A\u81EA\u52A8\u5F52\u4E00\u5316\uFF0C\u975E\u6CD5\u5219\u62A5\u9519\u3002
21783
21891
  \u591A\u8BED\u8A00\uFF08\xA716.2\uFF09\uFF1Atranslations \u5FC5\u987B\u5305\u542B en/zh/ms/id \u56DB\u79CD\u8BED\u8A00\u7684 title\uFF1B\u7F3A\u5931\u65F6\u9700 confirm=true \u4E14 acknowledge_missing_languages=true\u3002
21784
- \u9ED8\u8BA4 dry_run\uFF08confirm=false\uFF09\u53EA\u8FD4\u56DE\u9884\u89C8\uFF0C\u4E0D\u771F\u6B63\u521B\u5EFA\u3002`,
21892
+ \u9ED8\u8BA4 dry_run\uFF08confirm=false\uFF09\u53EA\u8FD4\u56DE\u9884\u89C8\uFF0C\u4E0D\u771F\u6B63\u521B\u5EFA\u3002
21893
+ \u56FE\u7247\uFF1Aimages \u53EF\u4F20\u672C\u5730\u6587\u4EF6\u8DEF\u5F84\uFF08\u81EA\u52A8\u4E0A\u4F20\u5230\u6587\u4EF6\u670D\u52A1\uFF09\u6216\u56FE\u7247 URL\uFF0C\u4EC5\u5728 confirm=true \u65F6\u624D\u771F\u6B63\u4E0A\u4F20/\u6302\u63A5\u3002
21894
+ SKU / \u89C4\u683C\uFF1Asku \u5199\u5165\u9ED8\u8BA4\u53D8\u4F53\uFF1Bmetadata \u53EF\u5B58\u4EFB\u610F\u952E\u503C\uFF08\u5982\u89C4\u683C\u53C2\u6570\u3001\u6765\u6E90\u94FE\u63A5\uFF09\u3002`,
21785
21895
  schema: {
21786
21896
  title: external_exports.string(),
21787
21897
  handle: external_exports.string().optional().describe("\u4E0D\u586B\u5219\u7528 title \u5F52\u4E00\u5316\u751F\u6210"),
@@ -21792,9 +21902,13 @@ var productTools = [
21792
21902
  // 并非最小单位(分)。官方 ESLint 规则 prices-in-major-units 明确:
21793
21903
  // "Medusa stores prices in major units",期望 19.99 而非 1999。
21794
21904
  // 本项目 seed 与 storefront 均遵循此官方约定,故直接透传,不要 ×100。
21795
- price: external_exports.number().nonnegative().optional().describe("\u4E3B\u8D27\u5E01\u5355\u4F4D\u91D1\u989D\uFF0C\u5982 200.51 \u5373 RM200.51\uFF1BMedusa \u4EE5\u4E3B\u5355\u4F4D\u5B58\u50A8\uFF0C\u5207\u52FF\u6362\u7B97"),
21905
+ price: external_exports.number().nonnegative().optional().describe("\u4E3B\u8D27\u5E01\u5355\u4F4D\u91D1\u989D\uFF0C\u5982 200.51 \u5373 RM200.51\uFF1BMedusa \u4EE5\u4E3B\u5355\u4F4D\u5B58\u50A8\uFF0C\u5207\u52FF\u6362\u7B97\u3002\u4E0E prices \u4E8C\u9009\u4E00\uFF0Cprices \u4F18\u5148"),
21796
21906
  currency_code: external_exports.string().default("MYR"),
21907
+ prices: external_exports.array(external_exports.object({ currency_code: external_exports.string(), amount: external_exports.number().nonnegative() })).optional().describe('\u591A\u5E01\u79CD\u4EF7\u683C\uFF08\u4E3B\u8D27\u5E01\u5355\u4F4D\uFF09\uFF0C\u5982 [{currency_code:"myr",amount:420},{currency_code:"sgd",amount:127}]\u3002\u4E1C\u5357\u4E9A\u5E97\u901A\u5E38\u9700\u8986\u76D6 myr/sgd/idr/thb/php/cny\u3002\u4F18\u5148\u4E8E\u5355\u4E00 price'),
21797
21908
  translations: external_exports.record(external_exports.string(), translationShape).optional(),
21909
+ images: external_exports.array(external_exports.string()).optional().describe("\u672C\u5730\u56FE\u7247\u8DEF\u5F84\u6216\u56FE\u7247 URL\uFF0Cconfirm=true \u65F6\u4E0A\u4F20\u5E76\u6302\u5230\u5546\u54C1"),
21910
+ sku: external_exports.string().optional().describe("\u5199\u5165\u9ED8\u8BA4\u53D8\u4F53\u7684 SKU"),
21911
+ metadata: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("\u4EFB\u610F\u952E\u503C\uFF0C\u5982\u89C4\u683C\u53C2\u6570 / source_url"),
21798
21912
  confirm: external_exports.boolean().default(false),
21799
21913
  acknowledge_missing_languages: external_exports.boolean().default(false)
21800
21914
  },
@@ -21817,6 +21931,10 @@ var productTools = [
21817
21931
  status: input.status,
21818
21932
  price: input.price,
21819
21933
  currency_code: input.currency_code,
21934
+ prices: input.prices,
21935
+ sku: input.sku,
21936
+ images: input.images,
21937
+ metadata: input.metadata,
21820
21938
  missing_translations: missing
21821
21939
  },
21822
21940
  note: "\u672A confirm\uFF0C\u4EC5\u9884\u89C8\u3002\u786E\u8BA4\u8BF7\u8BBE confirm=true\u3002"
@@ -21825,25 +21943,54 @@ var productTools = [
21825
21943
  if (missing.length > 0 && !input.acknowledge_missing_languages) {
21826
21944
  return `\u9519\u8BEF\uFF1A\u7F3A\u5C11\u4EE5\u4E0B\u8BED\u8A00\u7684 title\uFF1A${missing.join(", ")}\u3002\u5982\u786E\u8BA4\u4ECD\u8981\u521B\u5EFA\uFF0C\u8BF7\u8BBE acknowledge_missing_languages=true\u3002`;
21827
21945
  }
21946
+ let imageUrls = [];
21947
+ if (input.images?.length) {
21948
+ try {
21949
+ imageUrls = await client2.uploadProductAssets(input.images);
21950
+ } catch (e) {
21951
+ return `\u9519\u8BEF\uFF1A\u56FE\u7247\u4E0A\u4F20\u5931\u8D25 \u2014\u2014 ${e?.message || e}`;
21952
+ }
21953
+ }
21828
21954
  const payload = {
21829
21955
  title: input.title,
21830
21956
  handle,
21831
21957
  status: input.status,
21832
21958
  subtitle: input.subtitle,
21833
- description: input.description
21959
+ description: input.description,
21960
+ options: [{ title: "Default", values: ["Default"] }]
21834
21961
  };
21835
- if (input.translations) payload.translations = input.translations;
21836
- if (input.price != null) {
21837
- payload.variants = [
21838
- {
21839
- title: "Default",
21840
- prices: [{ amount: input.price, currency_code: input.currency_code }],
21841
- manage_inventory: false
21842
- }
21843
- ];
21962
+ if (input.metadata) payload.metadata = input.metadata;
21963
+ if (imageUrls.length) payload.images = imageUrls.map((url) => ({ url }));
21964
+ const variantPrices = input.prices && input.prices.length ? input.prices.map((p) => ({ amount: p.amount, currency_code: p.currency_code })) : input.price != null ? [{ amount: input.price, currency_code: input.currency_code }] : void 0;
21965
+ if (variantPrices) {
21966
+ const variant = {
21967
+ title: "Default",
21968
+ options: { Default: "Default" },
21969
+ prices: variantPrices,
21970
+ manage_inventory: false
21971
+ };
21972
+ if (input.sku) variant.sku = input.sku;
21973
+ payload.variants = [variant];
21844
21974
  }
21845
21975
  const data = await client2.createProduct(payload);
21846
- return { created: data, warning_missing_languages: missing };
21976
+ let translationResult = null;
21977
+ if (input.translations && Object.keys(input.translations).length) {
21978
+ const productId = data?.product?.id || data?.id;
21979
+ if (productId) {
21980
+ const create = Object.entries(input.translations).map(([locale, t]) => ({
21981
+ reference: "product",
21982
+ reference_id: productId,
21983
+ locale_code: locale,
21984
+ translations: { title: t?.title, description: t?.description }
21985
+ }));
21986
+ try {
21987
+ translationResult = await client2.batchTranslations(create);
21988
+ } catch (e) {
21989
+ return { created: data, warning_translations: `\u7FFB\u8BD1\u5199\u5165\u5931\u8D25 \u2014\u2014 ${e?.message || e}` };
21990
+ }
21991
+ }
21992
+ }
21993
+ return { created: data, translations: translationResult, warning_missing_languages: missing };
21847
21994
  }
21848
21995
  },
21849
21996
  {
@@ -21870,9 +22017,58 @@ var productTools = [
21870
22017
  if (input.status) patch.status = input.status;
21871
22018
  if (input.subtitle) patch.subtitle = input.subtitle;
21872
22019
  if (input.description) patch.description = input.description;
21873
- if (input.translations) patch.translations = input.translations;
21874
22020
  if (!input.confirm) return { dry_run: true, will_update: patch };
21875
- return client2.updateProduct(input.id, patch);
22021
+ const data = await client2.updateProduct(input.id, patch);
22022
+ let translationResult = null;
22023
+ if (input.translations && Object.keys(input.translations).length) {
22024
+ const create = Object.entries(input.translations).map(([locale, t]) => ({
22025
+ reference: "product",
22026
+ reference_id: input.id,
22027
+ locale_code: locale,
22028
+ translations: { title: t?.title, description: t?.description }
22029
+ }));
22030
+ try {
22031
+ translationResult = await client2.batchTranslations(create);
22032
+ } catch (e) {
22033
+ return { updated: data, warning_translations: `\u7FFB\u8BD1\u5199\u5165\u5931\u8D25 \u2014\u2014 ${e?.message || e}` };
22034
+ }
22035
+ }
22036
+ return { updated: data, translations: translationResult };
22037
+ }
22038
+ },
22039
+ {
22040
+ name: "update_product_prices",
22041
+ description: `\u8BBE\u7F6E/\u66FF\u6362\u67D0\u5546\u54C1\u7684\uFF08\u53D8\u4F53\uFF09\u4EF7\u683C\uFF0C\u652F\u6301\u591A\u5E01\u79CD\u3002Medusa \u4EF7\u683C\u6302\u5728\u300C\u53D8\u4F53\u300D\u4E0A\uFF0C\u5199\u5165\u5373\u6574\u4F53\u66FF\u6362\u8BE5\u53D8\u4F53\u7684\u5168\u90E8\u4EF7\u683C\uFF08\u4E0D\u4F1A\u7D2F\u52A0\u65E7\u4EF7\uFF09\u3002
22042
+ \u4EF7\u683C\u4EE5\u4E3B\u8D27\u5E01\u5355\u4F4D\uFF08\u5143\uFF09\u5B58\u50A8\uFF0C\u5207\u52FF \xD7100\u3002\u4E1C\u5357\u4E9A\u8DE8\u5883\u5E97\u901A\u5E38\u9700\u8986\u76D6 myr/sgd/idr/thb/php/cny\u3002
22043
+ \u4E0D\u6307\u5B9A variant_id \u65F6\u4F5C\u7528\u4E8E\u7B2C\u4E00\u4E2A\u53D8\u4F53\uFF08\u5355 SKU \u5546\u54C1\u5373\u552F\u4E00\u53D8\u4F53\uFF09\u3002\u9ED8\u8BA4 dry_run\uFF08confirm=false\uFF09\u4EC5\u9884\u89C8\u3002`,
22044
+ schema: {
22045
+ product_id: external_exports.string(),
22046
+ prices: external_exports.array(external_exports.object({ currency_code: external_exports.string(), amount: external_exports.number().nonnegative() })).describe('\u591A\u5E01\u79CD\u4EF7\u683C\u6570\u7EC4\uFF08\u4E3B\u8D27\u5E01\u5355\u4F4D\uFF09\uFF0C\u5982 [{currency_code:"myr",amount:420},{currency_code:"sgd",amount:127}]'),
22047
+ variant_id: external_exports.string().optional().describe("\u76EE\u6807\u53D8\u4F53 id\uFF1B\u7701\u7565\u5219\u53D6\u7B2C\u4E00\u4E2A\u53D8\u4F53"),
22048
+ confirm: external_exports.boolean().default(false)
22049
+ },
22050
+ async run(input, { client: client2 }) {
22051
+ if (!input.confirm) {
22052
+ return {
22053
+ dry_run: true,
22054
+ product_id: input.product_id,
22055
+ will_set_prices: input.prices,
22056
+ variant_id: input.variant_id || "(\u7B2C\u4E00\u4E2A\u53D8\u4F53)"
22057
+ };
22058
+ }
22059
+ const prod = await client2.getProduct(input.product_id).catch(() => null);
22060
+ const product = prod?.product ?? prod;
22061
+ if (!product) return `\u9519\u8BEF\uFF1A\u627E\u4E0D\u5230\u5546\u54C1 ${input.product_id}`;
22062
+ const variants = product.variants || [];
22063
+ if (!variants.length) return `\u9519\u8BEF\uFF1A\u5546\u54C1\u6CA1\u6709\u53D8\u4F53\uFF0C\u65E0\u6CD5\u8BBE\u7F6E\u4EF7\u683C\u3002`;
22064
+ const target = input.variant_id ? variants.find((v) => v.id === input.variant_id) : variants[0];
22065
+ if (!target) return `\u9519\u8BEF\uFF1A\u627E\u4E0D\u5230\u76EE\u6807\u53D8\u4F53\uFF08\u6307\u5B9A ${input.variant_id} \u6216\u7B2C\u4E00\u4E2A\u53D8\u4F53\uFF09\u3002`;
22066
+ const data = await client2.updateProductPrices(
22067
+ input.product_id,
22068
+ target.id,
22069
+ input.prices.map((p) => ({ amount: p.amount, currency_code: p.currency_code }))
22070
+ );
22071
+ return { updated: data };
21876
22072
  }
21877
22073
  },
21878
22074
  {
@@ -21886,6 +22082,132 @@ var productTools = [
21886
22082
  if (!input.confirm) return { dry_run: true, will_set_status: "archived" };
21887
22083
  return client2.updateProduct(input.id, { status: "archived" });
21888
22084
  }
22085
+ },
22086
+ {
22087
+ name: "upload_product_image",
22088
+ description: `\u7ED9\u5DF2\u5B58\u5728\u7684\u5546\u54C1\u8FFD\u52A0\u56FE\u7247\u3002\u652F\u6301\u672C\u5730\u6587\u4EF6\u8DEF\u5F84\uFF08\u81EA\u52A8\u4E0A\u4F20\u5230\u6587\u4EF6\u670D\u52A1\uFF09\u6216\u56FE\u7247 URL\u3002
22089
+ \u9ED8\u8BA4 dry_run\uFF08confirm=false\uFF09\u4EC5\u9884\u89C8\uFF1Bconfirm=true \u624D\u4E0A\u4F20\u5E76\u6302\u5230\u5546\u54C1\uFF08\u4E0E\u73B0\u6709\u56FE\u7247\u5408\u5E76\uFF0C\u4E0D\u4F1A\u8986\u76D6\u5DF2\u6709\u56FE\u7247\uFF09\u3002`,
22090
+ schema: {
22091
+ product_id: external_exports.string(),
22092
+ images: external_exports.array(external_exports.string()).describe("\u672C\u5730\u56FE\u7247\u8DEF\u5F84\u6216\u56FE\u7247 URL"),
22093
+ confirm: external_exports.boolean().default(false)
22094
+ },
22095
+ async run(input, { client: client2 }) {
22096
+ if (!input.confirm) {
22097
+ return { dry_run: true, product_id: input.product_id, will_upload: input.images };
22098
+ }
22099
+ let urls = [];
22100
+ try {
22101
+ urls = await client2.uploadProductAssets(input.images);
22102
+ } catch (e) {
22103
+ return `\u9519\u8BEF\uFF1A\u56FE\u7247\u4E0A\u4F20\u5931\u8D25 \u2014\u2014 ${e?.message || e}`;
22104
+ }
22105
+ const existing = await client2.getProduct(input.product_id).catch(() => null);
22106
+ const prod = existing?.product ?? existing ?? {};
22107
+ const existingUrls = (prod.images || []).map((i) => i.url).filter(Boolean);
22108
+ const merged = Array.from(/* @__PURE__ */ new Set([...existingUrls, ...urls]));
22109
+ const data = await client2.updateProduct(input.product_id, {
22110
+ images: merged.map((u) => ({ url: u }))
22111
+ });
22112
+ return { updated: data };
22113
+ }
22114
+ },
22115
+ {
22116
+ name: "add_product_to_sales_channel",
22117
+ description: `\u628A\u5546\u54C1\u52A0\u5165\u9500\u552E\u6E20\u9053\uFF0C\u4F7F\u5176\u5728\u524D\u7AEF\u5546\u57CE\uFF08\u6309 publishable key \u2192 \u6E20\u9053\u8FC7\u6EE4\uFF09\u53EF\u89C1\u3002
22118
+ \u26A0\uFE0F Medusa v2 \u7AEF\u70B9\u6302\u5728\u6E20\u9053\u4E0B\uFF1APOST /admin/sales-channels/{id}/products\uFF0C\u800C\u975E\u5546\u54C1\u4E0B\u3002
22119
+ \u4E0D\u6307\u5B9A channel_id \u65F6\u9ED8\u8BA4\u52A0\u5165\u7B2C\u4E00\u4E2A\u542F\u7528\u7684\u6E20\u9053\u3002\u9ED8\u8BA4 dry_run\uFF08confirm=false\uFF09\u4EC5\u9884\u89C8\u3002`,
22120
+ schema: {
22121
+ product_ids: external_exports.array(external_exports.string()).describe("\u8981\u52A0\u5165\u6E20\u9053\u7684\u5546\u54C1 id \u5217\u8868"),
22122
+ channel_id: external_exports.string().optional().describe("\u9500\u552E\u6E20\u9053 id\uFF1B\u7701\u7565\u5219\u53D6\u7B2C\u4E00\u4E2A\u542F\u7528\u7684\u6E20\u9053"),
22123
+ confirm: external_exports.boolean().default(false)
22124
+ },
22125
+ async run(input, { client: client2, config: config3 }) {
22126
+ if (!input.confirm) {
22127
+ return { dry_run: true, will_add: input.product_ids, channel_id: input.channel_id || "(\u9ED8\u8BA4\u7B2C\u4E00\u4E2A\u542F\u7528\u6E20\u9053)" };
22128
+ }
22129
+ let channelId = input.channel_id;
22130
+ if (!channelId) {
22131
+ const ch = await client2.listSalesChannels?.();
22132
+ const list = ch?.sales_channels || [];
22133
+ const firstEnabled = list.find((c) => !c.is_disabled) || list[0];
22134
+ if (!firstEnabled) return "\u9519\u8BEF\uFF1A\u627E\u4E0D\u5230\u53EF\u7528\u7684\u9500\u552E\u6E20\u9053\u3002";
22135
+ channelId = firstEnabled.id;
22136
+ }
22137
+ const data = await client2.addProductsToSalesChannel(channelId, input.product_ids);
22138
+ return { added_to_channel: channelId, result: data };
22139
+ }
22140
+ },
22141
+ {
22142
+ name: "get_product_content",
22143
+ description: `\u8BFB\u53D6\u5546\u54C1\u8BE6\u60C5\u9875\u5BCC\u5185\u5BB9\u5757\uFF08GET /admin/product-contents\uFF09\u3002
22144
+ locale \u7528\u533A\u57DF\u7801\uFF1Aen-US / zh-CN / ms-MY / id-ID\uFF08\u4E0E\u524D\u7AEF useProductContent \u7684 localeMap \u4E00\u81F4\uFF09\u3002
22145
+ \u8FD4\u56DE { product_content: { id, product_id, locale, status, blocks(\u5DF2\u89E3\u6790\u6570\u7EC4) } | null }\u3002`,
22146
+ schema: {
22147
+ product_id: external_exports.string(),
22148
+ locale: external_exports.enum(["en-US", "zh-CN", "ms-MY", "id-ID"]).default("en-US"),
22149
+ status: external_exports.enum(["draft", "published"]).default("published")
22150
+ },
22151
+ async run(input, { client: client2 }) {
22152
+ const data = await client2.getProductContent(input.product_id, input.locale, input.status);
22153
+ const pc = data?.product_content;
22154
+ if (!pc) return { product_content: null };
22155
+ let blocks = [];
22156
+ try {
22157
+ blocks = pc.blocks ? JSON.parse(pc.blocks) : [];
22158
+ } catch {
22159
+ blocks = [];
22160
+ }
22161
+ return { product_content: { ...pc, blocks } };
22162
+ }
22163
+ },
22164
+ {
22165
+ name: "create_product_content",
22166
+ description: `\u521B\u5EFA/\u66F4\u65B0\u5546\u54C1\u8BE6\u60C5\u9875\u5BCC\u5185\u5BB9\u5757\uFF08POST /admin/product-contents\uFF0C\u6309 product_id+locale upsert\uFF09\u3002
22167
+ storefront \u8BE6\u60C5\u9875\u300C\u5BCC\u5185\u5BB9\u300D\u533A\u7531\u8FD9\u4E9B\u5757\u6E32\u67D3\u2014\u2014\u4EC5\u586B product.description \u4E0D\u4F1A\u663E\u793A\u5728\u8FD9\u91CC\uFF0C\u5FC5\u987B\u5EFA\u5185\u5BB9\u5757\uFF0C\u5426\u5219\u8BE6\u60C5\u9875\u4E3B\u4F53\u7A7A\u767D\u3002
22168
+ \u533A\u5757\u7C7B\u578B type \u2208 hero / richtext / image_gallery / video / faq / specs\uFF1A
22169
+ - richtext: data.{ html }\uFF08\u76F4\u63A5\u6E32\u67D3 HTML\uFF0C\u53EF\u542B <p>/<h2>/<ul> \u7B49\uFF09
22170
+ - image_gallery: data.{ images:string[], caption? }
22171
+ - hero: data.{ image?, title?, subtitle? }
22172
+ - video: data.{ url, poster? }\uFF08\u652F\u6301 YouTube\uFF09
22173
+ - faq: data.{ items: {question,answer}[] }
22174
+ - specs: data.{ rows: {label,value}[] }
22175
+ \u26A0\uFE0F locale \u5FC5\u987B\u7528\u533A\u57DF\u7801 en-US / zh-CN / ms-MY / id-ID\uFF08\u4E0E\u524D\u7AEF\u4E00\u81F4\uFF09\uFF1B\u5546\u54C1\u6807\u9898/\u63CF\u8FF0\u7684\u7FFB\u8BD1\u7528\u77ED\u7801 en/zh/ms/id\uFF0C\u4E8C\u8005\u4E0D\u540C\u3002
22176
+ \u9ED8\u8BA4 dry_run\uFF08confirm=false\uFF09\u4EC5\u9884\u89C8\u3002`,
22177
+ schema: {
22178
+ product_id: external_exports.string(),
22179
+ locale: external_exports.enum(["en-US", "zh-CN", "ms-MY", "id-ID"]).default("en-US"),
22180
+ status: external_exports.enum(["draft", "published"]).default("published"),
22181
+ blocks: external_exports.array(
22182
+ external_exports.object({
22183
+ id: external_exports.string().optional(),
22184
+ type: external_exports.enum(["hero", "richtext", "image_gallery", "video", "faq", "specs"]),
22185
+ data: external_exports.record(external_exports.string(), external_exports.any())
22186
+ })
22187
+ ).describe("ContentBlock[] \u6570\u7EC4"),
22188
+ confirm: external_exports.boolean().default(false)
22189
+ },
22190
+ async run(input, { client: client2 }) {
22191
+ if (!input.confirm) {
22192
+ return {
22193
+ dry_run: true,
22194
+ will_create: {
22195
+ product_id: input.product_id,
22196
+ locale: input.locale,
22197
+ status: input.status,
22198
+ block_count: input.blocks.length,
22199
+ types: input.blocks.map((b) => b.type)
22200
+ }
22201
+ };
22202
+ }
22203
+ const data = await client2.createProductContent({
22204
+ product_id: input.product_id,
22205
+ locale: input.locale,
22206
+ status: input.status,
22207
+ blocks: input.blocks
22208
+ });
22209
+ return { created: data };
22210
+ }
21889
22211
  }
21890
22212
  ];
21891
22213
 
@@ -22012,11 +22334,11 @@ var opsTools = [
22012
22334
  ];
22013
22335
 
22014
22336
  // src/resources/docs.ts
22015
- import { readdirSync, readFileSync as readFileSync2, existsSync as existsSync2, statSync } from "node:fs";
22337
+ import { readdirSync, readFileSync as readFileSync3, existsSync as existsSync3, statSync } from "node:fs";
22016
22338
  import { join as join2 } from "node:path";
22017
22339
  var cache = null;
22018
22340
  function walk(dir, out = []) {
22019
- if (!existsSync2(dir)) return out;
22341
+ if (!existsSync3(dir)) return out;
22020
22342
  for (const entry of readdirSync(dir)) {
22021
22343
  const full = join2(dir, entry);
22022
22344
  try {
@@ -22028,7 +22350,7 @@ function walk(dir, out = []) {
22028
22350
  return out;
22029
22351
  }
22030
22352
  function parseFile(path) {
22031
- const raw = readFileSync2(path, "utf8");
22353
+ const raw = readFileSync3(path, "utf8");
22032
22354
  const lines = raw.split("\n");
22033
22355
  const title = lines.find((l) => l.startsWith("# "))?.slice(2).trim() || path;
22034
22356
  const sections = [];
@@ -22139,7 +22461,7 @@ var config2 = loadConfig();
22139
22461
  var auth = new AuthManager(config2);
22140
22462
  var client = new MedusaAdminClient(config2, auth);
22141
22463
  var ctx = { client, config: config2 };
22142
- var server = new McpServer({ name: "derlux-admin", version: "0.1.0" });
22464
+ var server = new McpServer({ name: "derlux-admin", version: "0.1.1" });
22143
22465
  for (const def of toolDefs) {
22144
22466
  server.tool(def.name, def.description, def.schema, async (args) => {
22145
22467
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sea-ecommerce/admin-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "让运营同学用本地 AI agent(Codex / Workbuddy / Claude / Cursor / CodeBuddy)运营 Medusa 后台的 MCP server。",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
- "README.md"
12
+ "README.md",
13
+ "skill"
13
14
  ],
14
15
  "scripts": {
15
16
  "dev": "tsx src/index.ts",
package/skill/SKILL.md ADDED
@@ -0,0 +1,197 @@
1
+ ---
2
+ name: derlux-admin-mcp-ops
3
+ description: 运营使用本地 AI agent(derlux-admin MCP server)操作 DERLUX Store 后台的指南。覆盖商品创建/修改/下架、仓库与库存调整、订单/退货/仓库作业查询、后台使用问答。当用户要让 agent 创建、修改或下架商品、调整某仓库库存、查询订单或退货、或询问「后台怎么操作某功能」时使用。
4
+ ---
5
+
6
+ # DERLUX 运营后台 MCP 操作指南(derlux-admin)
7
+
8
+ > 本文件随 npm 包 `@sea-ecommerce/admin-mcp-server` 一同发布,运营/agent 可经 npm 公开文件链接读取,
9
+ > 例如:`https://unpkg.com/@sea-ecommerce/admin-mcp-server/skill/SKILL.md`
10
+ > 或 `https://cdn.jsdelivr.net/npm/@sea-ecommerce/admin-mcp-server/skill/SKILL.md`
11
+
12
+ ## 你是谁 / 这个 skill 是做什么的
13
+
14
+ 你已接入一个名为 **`derlux-admin`** 的 MCP server。它把 DERLUX Store(面向马来西亚/印尼的东南亚电商,法律主体「香港德樂仕股份有限公司」)Medusa 后台的运营动作封装成一批语义化工具(tool),由你直接调用,代替运营同学在后台界面手动点选。
15
+
16
+ 关键前提(务必记牢):
17
+
18
+ - **认证复用真实后台管理员账号**(须持有 `csr` 或 `super_admin` 角色),权限受现有 RBAC 约束。敏感动作若账号无权限,后端会直接拦截。
19
+ - **所有写操作默认是「预览(dry_run)」**,只有显式设 `confirm=true` 才会真正落到后台。这是给运营的兜底保护,不要跳过预览。
20
+ - 每次调用都会写入审计日志 `~/.derlux/mcp-audit.log`,操作可追溯。
21
+
22
+ ## ⚠️ 必须遵守的硬规则(每次调用写工具前自查)
23
+
24
+ 1. **预览优先,确认后执行**:任何写工具先以 `confirm=false`(默认)跑一次,把返回的预览读给运营确认;得到明确确认后,再用 `confirm=true` 真正执行。绝不在未确认时直接写库。
25
+ 2. **价格单位 = 主货币单位(元),不要 ×100**:金额直接透传主单位,例如 `200.51` 就是 RM200.51 / MYR200.51。Medusa v2.21 以主单位存储金额,**绝不要写成 20051**。
26
+ 3. **Handle 自动归一化**:商品 `handle` 会被自动转小写、空格/下划线/点→连字符、剔除其余非法字符。归一化后仍非法(空或全部被剔除)工具会直接报错,你无需手工处理,但应避免给无意义输入;未填时由 `title` 自动生成。
27
+ 4. **多语言必填**:`create_product` / `update_product` 的 `translations` 应覆盖 `en` / `zh` / `ms` / `id` 四种语言的 `title`(建议也带 `description`)。缺语言时:预览会告警;真正写入需 `confirm=true` **且** `acknowledge_missing_languages=true`。创建前主动提醒运营补全翻译,不要默默放行。工具会**自动**把翻译写入 Medusa v2.21 专用的 `/admin/translations/batch` 端点(商品创建体本身不接受 `translations` 字段),你无需手动调用该端点。
28
+ 5. **审计可追溯**:无需你处理,但要知道每次动作都在 `~/.derlux/mcp-audit.log` 留痕。
29
+ 6. **商品必须挂销售渠道才在前端可见**:新建商品默认**不在任何销售渠道**,storefront(按 publishable key → 渠道过滤)拉不到它,详情页表现成「空/404」。录入后用 `add_product_to_sales_channel`(`confirm=true`)把它加入渠道(默认第一个启用渠道;马来西亚站用 `sc_01M4K4F0D6HBHZ0NH6WB7H5KPT`「MY Storefront」)。
30
+ 7. **两套 locale 码,别混**:
31
+ - 商品标题/描述的**翻译**(`create_product` 的 `translations`、后端 `/admin/translations/batch`):用**简码** `en` / `zh` / `ms` / `id`。store `/store/products?locale=zh` 即回退到 zh 翻译。
32
+ - 商品详情页**富内容块**(`create_product_content` 的 `locale`):必须用**区域码** `en-US` / `zh-CN` / `ms-MY` / `id-ID`(与前端 `useProductContent` 的 localeMap 一致)。用简码会导致该语言详情页读不到内容。
33
+ 8. **详情页「富内容」区靠 `create_product_content`,不是 `description`**:storefront 详情页下方大块内容由 `product_content` 模块渲染(`richtext`/`specs`/`image_gallery` 等)。只填 `product.description` 不会显示在那里——录入完整商品时必须再建一份 `product_content`(建议每语言一份)。`get_product` 默认不含该字段,核对请用 `get_product_content`。
34
+
35
+ ## 工具速查表
36
+
37
+ | 工具 | 类型 | 写库? | 默认行为 |
38
+ |---|---|---|---|
39
+ | `list_products` | 读 | 否 | 直接返回 |
40
+ | `get_product` | 读 | 否 | 直接返回 |
41
+ | `list_stock_locations` | 读 | 否 | 直接返回 |
42
+ | `get_inventory_levels` | 读 | 否 | 直接返回(按 location_id 列库存条目与水平)|
43
+ | `list_orders` | 读 | 否 | 直接返回 |
44
+ | `get_order` | 读 | 否 | 直接返回 |
45
+ | `list_warehouse_tasks` | 读 | 否 | 直接返回(自定义端点)|
46
+ | `list_return_requests` | 读 | 否 | 直接返回(自定义端点)|
47
+ | `search_docs` | 读 | 否 | 检索后台知识库 |
48
+ | `ask_usage` | 读 | 否 | 回答「后台怎么操作」类问题 |
49
+ | `create_product` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览 |
50
+ | `update_product` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览 |
51
+ | `deactivate_product` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览,置 `archived` |
52
+ | `upload_product_image` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览,给已有商品追加图片 |
53
+ | `add_product_to_sales_channel` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览,把商品加入销售渠道使其在前端可见 |
54
+ | `create_product_content` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览,写商品详情页富内容块(hero/richtext/image_gallery/video/faq/specs)|
55
+ | `get_product_content` | 读 | 否 | 读取某商品某语言的富内容块 |
56
+ | `create_stock_location` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览 |
57
+ | `update_inventory_level` | 写 | 是(`confirm=true` 才写)| `confirm=false` → 仅预览 |
58
+
59
+ ## 各工具参数与说明
60
+
61
+ ### 读 —— 商品
62
+ - **`list_products`**:查询商品列表。
63
+ - `q`(string,可选)关键词;`status`(enum:`draft|published|proposed|rejected|archived`,可选)状态过滤;`handle`(string,可选);`limit`(int 1–100,默认 20)。
64
+ - **`get_product`**:按商品 id 或 handle 获取详情。
65
+ - `id_or_handle`(string,**必填**)。
66
+
67
+ ### 读 —— 仓库与库存
68
+ - **`list_stock_locations`**:列出仓库(StockLocation)。
69
+ - `q`(string,可选)按名称过滤;`limit`(int 1–100,默认 50)。
70
+ - **`get_inventory_levels`**:列出某仓库下的库存条目及库存水平。
71
+ - `location_id`(string,**必填**);`q`(string,可选);`limit`(int 1–100,默认 50)。
72
+
73
+ ### 读 —— 订单 / 履约 / 退货
74
+ - **`list_orders`**:查询订单列表。
75
+ - `q`(string,可选);`status`(string,可选);`limit`(int 1–100,默认 20)。
76
+ - **`get_order`**:按订单 id 或 `display_id` 获取详情。
77
+ - `id`(string,**必填**)。
78
+ - **`list_warehouse_tasks`**:查询仓库作业任务(自定义端点 `/admin/warehouse-tasks`)。
79
+ - `order_id`(string,可选);`stage`(string,可选);`limit`(int 1–200,默认 200)。
80
+ - **`list_return_requests`**:查询退货/售后请求(自定义端点 `/admin/return-requests`)。
81
+ - `status`(string,可选);`limit`(int 1–100,默认 50)。
82
+
83
+ ### 读 —— 后台知识问答
84
+ - **`search_docs`**:在知识库(docs)中检索相关段落,返回文件、章节与摘要。
85
+ - `query`(string,**必填**);`limit`(int 1–20,默认 5)。
86
+ - **`ask_usage`**:回答「后台怎么操作」类问题,返回最相关段落与出处文件。
87
+ - `question`(string,**必填**);`limit`(int 1–10,默认 5)。若搜不到,提示运营换关键词或查后台手册。
88
+
89
+ ### 写 —— 商品
90
+ - **`create_product`**:新增商品。
91
+ - `title`(string,**必填**);`handle`(string,可选,缺省由 title 归一化);`status`(enum,默认 `draft`);`subtitle`(可选);`description`(可选);`price`(number ≥0,主货币单位,**切勿 ×100**);`currency_code`(默认 `MYR`);`translations`(`record<locale,{title?,description?}>`,建议覆盖 `en/zh/ms/id`);`images`(string[],可选,本地图片路径或图片 URL,工具自动上传并挂接);`sku`(string,可选,写入默认变体 SKU);`metadata`(`record<string,string>`,可选,存规格参数/来源链接等任意键值);`confirm`(bool,默认 false);`acknowledge_missing_languages`(bool,默认 false)。
92
+ - 行为:`confirm=false` 返回预览(含缺语言清单);`confirm=true` 才真正创建;缺语言时须同时 `acknowledge_missing_languages=true`。创建时**自动**补上 Medusa v2 必需的 `options`(单一 Default)、把图片上传到文件服务并挂接、把 `sku` 写入默认变体、把 `translations` 写入 v2.21 专用的 `/admin/translations/batch` 端点。
93
+ - **`update_product`**:修改商品字段(标题/handle/状态/描述/多语言)。
94
+ - `id`(string,**必填**);`title`/`handle`/`status`/`subtitle`/`description`/`translations`(均可选);`confirm`(默认 false)。`confirm=false` 返回预览。
95
+ - **`deactivate_product`**:下架商品(status 置 `archived`)。
96
+ - `id`(string,**必填**);`confirm`(默认 false)。`confirm=false` 返回预览。
97
+ - **`upload_product_image`**:给**已存在**的商品追加图片(与已有图片合并,不覆盖)。
98
+ - `product_id`(string,**必填**);`images`(string[],本地图片路径或图片 URL);`confirm`(默认 false)。`confirm=false` 仅预览;`confirm=true` 才上传并挂接。
99
+ - **`add_product_to_sales_channel`**:把商品加入销售渠道,使其在前端商城可见。
100
+ - `product_ids`(string[],**必填**,商品 id 列表);`channel_id`(string,可选,省略则取第一个启用渠道);`confirm`(默认 false)。`confirm=false` 仅预览。
101
+ - **`get_product_content`**:读取商品详情页富内容块。
102
+ - `product_id`(string,**必填**);`locale`(`en-US` / `zh-CN` / `ms-MY` / `id-ID`,默认 `en-US`);`status`(`draft` / `published`,默认 `published`)。返回 `{ product_content: { id, product_id, locale, status, blocks(已解析数组) } | null }`。
103
+ - **`create_product_content`**:创建/更新商品详情页富内容块(按 `product_id` + `locale` upsert)。**这是填充详情页主体内容的工具**——只填 `description` 详情页大块区域仍是空白。
104
+ - `product_id`(string,**必填**);`locale`(区域码,默认 `en-US`,见硬规则 7);`status`(默认 `published`);`blocks`(ContentBlock[],**必填**);`confirm`(默认 false)。
105
+ - 区块 `type` 与 `data`:
106
+ - `richtext`:`data.{ html }`(直接渲染 HTML,可含 `<p>/<h2>/<ul>`)
107
+ - `image_gallery`:`data.{ images:string[], caption? }`
108
+ - `hero`:`data.{ image?, title?, subtitle? }`
109
+ - `video`:`data.{ url, poster? }`(支持 YouTube)
110
+ - `faq`:`data.{ items: {question,answer}[] }`
111
+ - `specs`:`data.{ rows: {label,value}[] }`
112
+ - `confirm=false` 返回预览(含块数量与类型);`confirm=true` 才写入。
113
+
114
+ ### 写 —— 仓库与库存
115
+ - **`create_stock_location`**:新增仓库(StockLocation)。
116
+ - `name`(string,**必填**);`address`(可选:`{address_1?,address_2?,city?,country_code?,province?,postal_code?,phone?}`);`metadata`(`record<string,string>`,可选);`confirm`(默认 false)。
117
+ - **`update_inventory_level`**:修改某库存条目在指定仓库的库存数量(`stocked_quantity`)。
118
+ - `inventory_item_id`(string,**必填**);`location_id`(string,**必填**);`stocked_quantity`(int ≥0,**必填**);`confirm`(默认 false)。底层走 `POST /admin/inventory-items/{id}/location-levels`,字段/端点以 Medusa 2.21 契约为准。
119
+
120
+ ## 推荐操作流程(SOP)
121
+
122
+ **创建商品**
123
+ 1. 先收集:标题、handle(或告知由标题生成)、价格(主单位)、四语言标题、状态、图片、规格、详情页富文本内容(按语言)。
124
+ 2. 调用 `create_product`(`confirm=false`)→ 展示预览与「缺语言」告警。
125
+ 3. 提醒运营补全缺失语言;确认无误后调用 `create_product`(`confirm=true`,必要时 `acknowledge_missing_languages=true`)。
126
+ 4. **`add_product_to_sales_channel`(`confirm=true`)** → 把商品加入销售渠道,否则前端看不到。
127
+ 5. **`create_product_content`(`confirm=true`,逐语言 `en-US/zh-CN/ms-MY/id-ID`)** → 写详情页富内容块(richtext 描述 + specs 规格 + image_gallery 图集)。否则详情页主体空白。
128
+ 6. 把返回的新商品 id/handle 告知运营。
129
+
130
+ **修改 / 下架商品**
131
+ 1. `get_product` 或 `list_products` 确认目标 id。
132
+ 2. `update_product` / `deactivate_product`(`confirm=false`)→ 展示预览。
133
+ 3. 确认后同名工具 `confirm=true` 执行。
134
+
135
+ **调整库存**
136
+ 1. `list_stock_locations` 拿到 `location_id`;`get_inventory_levels` 拿到 `inventory_item_id`。
137
+ 2. `update_inventory_level`(`confirm=false`)→ 展示将写入的数量。
138
+ 3. 确认后 `confirm=true` 执行。
139
+
140
+ **查询类**:直接调用对应读工具即可,无需 `confirm`。
141
+
142
+ ## 常见任务示例(运营怎么说 / 你怎么调)
143
+
144
+ - 「下架 handle 为 `summer-serum` 的商品」→ `get_product`(id_or_handle=`summer-serum`) 取 id → `deactivate_product`(id, confirm=false 预览 → 确认后 confirm=true)。
145
+ - 「给马来西亚仓的 A 商品加 50 件库存」→ `list_stock_locations` 找 MY 仓 → `get_inventory_levels`(location_id) 找 A 的 inventory_item_id → `update_inventory_level`(confirm=false 预览 → 确认后 confirm=true)。
146
+ - 「查一下最近 10 个待发货订单」→ `list_orders`(status=`pending`, limit=10)。
147
+ - 「后台怎么新建优惠券」→ `ask_usage`(question=`如何新建优惠券`)。
148
+ - 「列出所有退货请求」→ `list_return_requests`()。
149
+ - 「把这份抓取的 Lazada 商品录入:标题 DR.CYJ Hair Filler、价格 420 MYR、4 张本地图 `/Users/fire/temp/images/product_1..4.webp`、SKU `4820...`、规格存 metadata、并补全中/马来/印尼语翻译」→ 先整理数据,再 `create_product`(`confirm=false` 看预览含 images/sku/metadata/缺语言清单 → 确认后 `confirm=true`,必要时 `acknowledge_missing_languages=true`)。工具会自动上传图片、写 SKU/metadata、并把四语言简码翻译写入翻译端点。随后 `add_product_to_sales_channel`(`confirm=true`) 让前端可见,再 `create_product_content`(`locale=en-US/zh-CN/ms-MY/id-ID`, `confirm=true`) 逐语言写富内容块(richtext 描述 + specs 规格 + image_gallery 图集)。
150
+ - 「让这个商品在前端能搜到」→ `get_product` 取 id → `add_product_to_sales_channel`(`product_ids=[id]`, `confirm=true`)。
151
+ - 「补全某商品中文详情页内容」→ `create_product_content`(`product_id`, `locale=zh-CN`, `blocks=[{type:'richtext',data:{html:'...'}},{type:'specs',data:{rows:[...]}}]`, `confirm=true`)。可用 `get_product_content` 先核对现状。
152
+
153
+ ## 给运营的一次性安装配置
154
+
155
+ agent 客户端(Codex / Claude Desktop / Cursor / Workbuddy / CodeBuddy 等)的 MCP 配置里加一条即可(机器只需 Node ≥ 18,无需 clone 仓库、无需 build):
156
+
157
+ ```json
158
+ {
159
+ "mcpServers": {
160
+ "derlux-admin": {
161
+ "command": "npx",
162
+ "args": ["-y", "@sea-ecommerce/admin-mcp-server"],
163
+ "env": {
164
+ "MEDUSA_BACKEND_URL": "https://admin.internal.derlux.com",
165
+ "DERLUX_ADMIN_EMAIL": "你的后台账号",
166
+ "DERLUX_ADMIN_PASSWORD": "你的密码"
167
+ }
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ ### 环境变量说明
174
+
175
+ | 变量 | 必填 | 默认 | 说明 |
176
+ |---|---|---|---|
177
+ | `MEDUSA_BACKEND_URL` | 否 | `http://localhost:9000` | 后端地址(生产填内部域名)|
178
+ | `DERLUX_ADMIN_EMAIL` | **是** | — | 后台管理员账号(须持 `csr`/`super_admin`)|
179
+ | `DERLUX_ADMIN_PASSWORD` | **是** | — | 该账号密码 |
180
+ | `DERLUX_AUTH_PATH` | 否 | `/auth/user/emailpass` | 登录端点 |
181
+ | `DERLUX_SESSION_FILE` | 否 | `~/.derlux/mcp-session` | 会话持久化文件(权限 0600)|
182
+ | `DERLUX_AUDIT_FILE` | 否 | `~/.derlux/mcp-audit.log` | 审计日志 |
183
+ | `DERLUX_DOCS_DIR` | 否 | `./docs` | 问答知识源目录(npx 形态下通常留默认或指向本地 docs 副本)|
184
+ | `DERLUX_REQUIRED_LOCALES` | 否 | `en,zh,ms,id` | 商品必须覆盖的语言 |
185
+
186
+ ## 已知限制 / 注意事项
187
+
188
+ - 本 MCP **不改动任何数据库 schema**,仅复用现有 `/admin/*` 接口;运营动作本身按既有逻辑写库。
189
+ - 登录走**用户账号**而非 API Key(现有 RBAC 依赖 `user.metadata.rbac_roles`)。
190
+ - 金额以**主货币单位**存储(见上「硬规则 2」),不要换算成分。
191
+ - **Medusa v2.21 差异(重要)**:
192
+ - 有变体就必须有 product options,工具已自动补一个 `Default` 选项,你无需关心。
193
+ - 商品创建/更新体**不接受 `translations` 字段**;翻译由工具自动走 `/admin/translations/batch`。`get_product` 默认字段不含 `translations` 关系,想核对翻译请用 `ask_usage` 或后台「多语言」面板,不要凭 `get_product` 返回为空就误判没写入。
194
+ - 图片走 `/admin/uploads` 上传后再挂到商品(无独立 `/admin/products/{id}/images` 端点)。
195
+ - 库存写入的端点/字段以 Medusa 2.21 契约为准;自定义端点 `warehouse-tasks` / `return-requests` 为本项目扩展。
196
+ - 文档问答(`search_docs` / `ask_usage`)依赖知识源目录;npx 形态下默认 `./docs`,若问不到相关内容,提示运营换关键词或查后台手册。
197
+ - 用受限的 `csr` 账号即可完成日常运营;敏感动作由后端 RBAC 拦截。