@productmaker/mcp 1.16.3 → 1.17.0

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.
package/README.md CHANGED
@@ -86,6 +86,9 @@ Sign up at [productmaker.app](https://productmaker.app) and create a key (prefix
86
86
  | `publish_to_tiktok` | Create a TikTok Ads campaign from a task (always created PAUSED). Same `angleIndexes` + `campaignConfig` shape as Meta |
87
87
  | `get_meta_campaign_status` | Read-only status + lifetime insights (spend/impressions/clicks/reach) of a Meta campaign |
88
88
  | `get_tiktok_campaign_status` | Read-only status + lifetime report of a TikTok campaign (requires `advertiserId`) |
89
+ | `find_winning_products` | Research winning dropshipping products for a country/category with AI-explained reasons, risks, and real Meta Ads evidence. **Costs 2000 credits per run** — requires explicit user confirmation (`confirmed: true`) before charging. Covers CO, MX, PE, EC, CL |
90
+ | `get_product_research_run` | Read the full report (score, verdict, confidence, reasons, risks, ad evidence) of a research run by `runId`. Free |
91
+ | `list_product_research_runs` | List the user's past research runs, most recent first. Free |
89
92
 
90
93
  ## Video duration and winner review
91
94
 
package/dist/http.js CHANGED
@@ -12,6 +12,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
12
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
13
 
14
14
  // src/api-client.ts
15
+ import { createHash } from "crypto";
15
16
  var ApiHttpError = class extends Error {
16
17
  static {
17
18
  __name(this, "ApiHttpError");
@@ -38,6 +39,15 @@ var ApiClient = class {
38
39
  Authorization: `Bearer ${this.apiKey}`
39
40
  };
40
41
  }
42
+ /**
43
+ * Stable per-key identifier. The MCP never sees the userId — the API key is
44
+ * the only per-user handle it holds — so this stands in as the user
45
+ * component of derived idempotency keys. Never log or return it raw to a
46
+ * caller: it is derived from a secret.
47
+ */
48
+ fingerprint() {
49
+ return createHash("sha256").update(this.apiKey).digest("hex").slice(0, 32);
50
+ }
41
51
  async getJson(path3) {
42
52
  const res = await fetch(`${this.baseUrl}${path3}`, {
43
53
  headers: this.authHeaders()
@@ -132,7 +142,7 @@ var ApiClient = class {
132
142
  };
133
143
 
134
144
  // src/index.ts
135
- var MCP_VERSION = "1.16.3";
145
+ var MCP_VERSION = "1.17.0";
136
146
 
137
147
  // src/tools/tasks.ts
138
148
  import { z as z39 } from "zod";
@@ -3149,6 +3159,125 @@ var AdminQualityReportListResponse = z24.object({
3149
3159
  var UpdateQualityReportStatusRequest = z24.object({
3150
3160
  status: QualityReportStatus
3151
3161
  });
3162
+ var AdminSupportConversationStatus = z24.enum([
3163
+ "bot_active",
3164
+ "human_pending",
3165
+ "human_active",
3166
+ "closed"
3167
+ ]);
3168
+ var AdminSupportDeliveryStatus = z24.enum([
3169
+ "draft",
3170
+ "pending",
3171
+ "sent",
3172
+ "delivered",
3173
+ "read",
3174
+ "failed",
3175
+ "blocked"
3176
+ ]);
3177
+ var AdminSupportMessage = z24.object({
3178
+ id: z24.string().uuid(),
3179
+ direction: z24.enum([
3180
+ "inbound",
3181
+ "outbound"
3182
+ ]),
3183
+ senderKind: z24.enum([
3184
+ "user",
3185
+ "bot",
3186
+ "admin",
3187
+ "system"
3188
+ ]),
3189
+ messageType: z24.enum([
3190
+ "text",
3191
+ "image",
3192
+ "audio",
3193
+ "document",
3194
+ "interactive",
3195
+ "status"
3196
+ ]),
3197
+ bodyRedacted: z24.string().nullable(),
3198
+ deliveryStatus: AdminSupportDeliveryStatus.nullable(),
3199
+ createdAt: z24.string().datetime()
3200
+ });
3201
+ var AdminSupportTicket = z24.object({
3202
+ id: z24.string().uuid(),
3203
+ category: z24.enum([
3204
+ "how_to",
3205
+ "credits",
3206
+ "billing",
3207
+ "task_status",
3208
+ "task_error",
3209
+ "video_quality",
3210
+ "landing_quality",
3211
+ "image_quality",
3212
+ "feature_request",
3213
+ "other"
3214
+ ]),
3215
+ failureMode: z24.string().nullable(),
3216
+ status: z24.enum([
3217
+ "open",
3218
+ "triaged",
3219
+ "resolved",
3220
+ "rejected"
3221
+ ]),
3222
+ summary: z24.string(),
3223
+ taskId: z24.string().nullable(),
3224
+ mixTaskId: z24.string().nullable(),
3225
+ qualityReportId: z24.string().nullable(),
3226
+ handoffReason: z24.string().nullable(),
3227
+ createdAt: z24.string().datetime(),
3228
+ updatedAt: z24.string().datetime()
3229
+ });
3230
+ var AdminSupportConversation = z24.object({
3231
+ id: z24.string().uuid(),
3232
+ status: AdminSupportConversationStatus,
3233
+ priority: z24.enum([
3234
+ "low",
3235
+ "normal",
3236
+ "high",
3237
+ "urgent"
3238
+ ]),
3239
+ currentIntent: z24.string().nullable(),
3240
+ assignedAdminId: z24.string().nullable(),
3241
+ customerServiceWindowEndsAt: z24.string().datetime().nullable(),
3242
+ lastInboundAt: z24.string().datetime().nullable(),
3243
+ createdAt: z24.string().datetime(),
3244
+ updatedAt: z24.string().datetime(),
3245
+ contact: z24.object({
3246
+ id: z24.string().uuid(),
3247
+ userId: z24.string().nullable(),
3248
+ verifiedAt: z24.string().datetime().nullable(),
3249
+ botMode: z24.enum([
3250
+ "enabled",
3251
+ "paused",
3252
+ "opted_out"
3253
+ ]),
3254
+ locale: z24.string()
3255
+ }),
3256
+ messages: z24.array(AdminSupportMessage),
3257
+ tickets: z24.array(AdminSupportTicket)
3258
+ });
3259
+ var AdminSupportListQuery = z24.object({
3260
+ status: AdminSupportConversationStatus.optional(),
3261
+ take: z24.coerce.number().int().min(1).max(100).default(50),
3262
+ cursorId: z24.string().uuid().optional()
3263
+ }).strict();
3264
+ var AdminSupportListResponse = z24.object({
3265
+ conversations: z24.array(AdminSupportConversation),
3266
+ nextCursor: z24.string().uuid().nullable()
3267
+ });
3268
+ var AdminSupportReplyRequest = z24.object({
3269
+ body: z24.string().trim().min(1).max(4096),
3270
+ idempotencyKey: z24.string().regex(/^[A-Za-z0-9_-]{8,128}$/)
3271
+ }).strict();
3272
+ var AdminSupportEmptyActionRequest = z24.object({}).strict();
3273
+ var AdminSupportActionResponse = z24.object({
3274
+ conversation: AdminSupportConversation
3275
+ });
3276
+ var AdminSupportReplyResponse = z24.object({
3277
+ messageId: z24.string().uuid(),
3278
+ deliveryStatus: AdminSupportDeliveryStatus,
3279
+ duplicate: z24.boolean()
3280
+ });
3152
3281
 
3153
3282
  // ../../packages/types/src/promo.ts
3154
3283
  import { z as z25 } from "zod";
@@ -3984,6 +4113,23 @@ var DEFAULT_PRICING = {
3984
4113
  ourCostCents: 0.4,
3985
4114
  userPriceCents: 0.8
3986
4115
  },
4116
+ // WhatsApp ProductMaker intent proposal. Real OpenRouter usage.cost wins;
4117
+ // this is only the fallback when the relay omits cost.
4118
+ "openrouter:whatsapp-product-agent-intent": {
4119
+ ourCostCents: 0.4,
4120
+ userPriceCents: 0.8
4121
+ },
4122
+ // Internal support calls are cost-only: callers explicitly override user
4123
+ // price to zero. Static COGS estimates apply because OpenAI Responses exposes
4124
+ // token counts but not the provider's dollar charge.
4125
+ "openai:whatsapp-support-classifier": {
4126
+ ourCostCents: 0.1,
4127
+ userPriceCents: 0.2
4128
+ },
4129
+ "openai:whatsapp-support-draft": {
4130
+ ourCostCents: 0.4,
4131
+ userPriceCents: 0.8
4132
+ },
3987
4133
  // Task-derived creative recommendation (angle/persona/script/landing-section).
3988
4134
  // OpenRouter usage normally supplies the exact COGS and the call layer charges
3989
4135
  // 2x. This rule is the fail-closed fallback when usage.cost is unavailable.
@@ -6930,6 +7076,28 @@ async function submitCreative(api, opts) {
6930
7076
  });
6931
7077
  }
6932
7078
  __name(submitCreative, "submitCreative");
7079
+ var ResearchTargetCountryEnum = z38.enum([
7080
+ "CO",
7081
+ "MX",
7082
+ "PE",
7083
+ "EC",
7084
+ "CL"
7085
+ ]);
7086
+ var ResearchCategoryEnum = z38.enum([
7087
+ "auto",
7088
+ "automotive",
7089
+ "kitchen",
7090
+ "pets",
7091
+ "home_organization",
7092
+ "beauty",
7093
+ "wellness",
7094
+ "fitness",
7095
+ "baby",
7096
+ "tech",
7097
+ "tools",
7098
+ "garden",
7099
+ "cleaning"
7100
+ ]);
6933
7101
 
6934
7102
  // src/errors.ts
6935
7103
  var McpErrorCode = {
@@ -6961,6 +7129,7 @@ var McpErrorCode = {
6961
7129
  TASK_NOT_READY: "TASK_NOT_READY",
6962
7130
  CREATIVE_VARIANT_FAILED: "CREATIVE_VARIANT_FAILED",
6963
7131
  CREATIVE_VARIANT_DISPATCH_FAILED: "CREATIVE_VARIANT_DISPATCH_FAILED",
7132
+ PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT: "PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT",
6964
7133
  // Connection presence
6965
7134
  NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
6966
7135
  NO_META_AD_ACCOUNTS: "NO_META_AD_ACCOUNTS",
@@ -7082,12 +7251,13 @@ var CODE_MESSAGES = {
7082
7251
  TASK_NOT_READY: "The task is not ready for this action yet. Wait for it to finish and retry.",
7083
7252
  CREATIVE_VARIANT_FAILED: "The previous creative variant attempt failed. Ask for confirmation before starting a new paid attempt with a new clientRequestId.",
7084
7253
  CREATIVE_VARIANT_DISPATCH_FAILED: "The creative variant could not be queued. Retry as a new paid attempt with a new clientRequestId.",
7254
+ PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT: "A product research run with the same country and category is already running. Check it with `list_product_research_runs` instead of starting a new paid run.",
7085
7255
  // API key
7086
7256
  INVALID_API_KEY: "Invalid API key. Check your PM_API_KEY in the MCP client configuration.",
7087
7257
  REVOKED_API_KEY: "API key revoked. Generate a new one at https://productmaker.app/account/api-keys.",
7088
7258
  // Rate
7089
7259
  RATE_LIMITED: "Too many requests. Wait a few seconds.",
7090
- INSUFFICIENT_CREDITS: "Insufficient credits to create this variant. Check your balance and retry after adding credits.",
7260
+ INSUFFICIENT_CREDITS: "Insufficient credits for this action. Check your balance and add credits at https://productmaker.app before retrying.",
7091
7261
  TASK_QUOTA_EXCEEDED: "You reached the active-task limit. Wait for a task to finish before creating another variant.",
7092
7262
  // Generic
7093
7263
  UPSTREAM_UNAVAILABLE: "The Product Maker service did not respond. Try again shortly.",
@@ -7100,6 +7270,7 @@ var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
7100
7270
  McpErrorCode.INVALID_INPUT,
7101
7271
  McpErrorCode.INSUFFICIENT_CREDITS,
7102
7272
  McpErrorCode.TASK_QUOTA_EXCEEDED,
7273
+ McpErrorCode.PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT,
7103
7274
  McpErrorCode.INVALID_API_KEY,
7104
7275
  McpErrorCode.REVOKED_API_KEY,
7105
7276
  McpErrorCode.IMAGE_PATH_NOT_FOUND,
@@ -7162,6 +7333,7 @@ var PASSTHROUGH_CODES = /* @__PURE__ */ new Set([
7162
7333
  McpErrorCode.INSUFFICIENT_CREDITS,
7163
7334
  McpErrorCode.TASK_QUOTA_EXCEEDED,
7164
7335
  McpErrorCode.CREATIVE_VARIANT_DISPATCH_FAILED,
7336
+ McpErrorCode.PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT,
7165
7337
  // Shopify
7166
7338
  McpErrorCode.SHOPIFY_NOT_CONNECTED,
7167
7339
  McpErrorCode.SHOPIFY_TOKEN_EXPIRED,
@@ -7275,9 +7447,11 @@ function translateError(err) {
7275
7447
  const body = isRecord(err.body) ? err.body : {};
7276
7448
  if (typeof body.code === "string" && PASSTHROUGH_CODES.has(body.code)) {
7277
7449
  const code = body.code;
7278
- return mcp(code, resolveMessage(code, body.message), body.helpUrl ? {
7279
- helpUrl: body.helpUrl
7280
- } : {});
7450
+ const extra = {};
7451
+ if (body.helpUrl !== void 0) extra.helpUrl = body.helpUrl;
7452
+ if (typeof body.requiredCredits === "number") extra.requiredCredits = body.requiredCredits;
7453
+ if (typeof body.availableCredits === "number") extra.availableCredits = body.availableCredits;
7454
+ return mcp(code, resolveMessage(code, body.message), extra);
7281
7455
  }
7282
7456
  if (err.status === 401) {
7283
7457
  return mcp(McpErrorCode.INVALID_API_KEY, resolveMessage(McpErrorCode.INVALID_API_KEY, void 0));
@@ -9645,6 +9819,224 @@ function formatPercent(value) {
9645
9819
  }
9646
9820
  __name(formatPercent, "formatPercent");
9647
9821
 
9822
+ // src/tools/product-research.ts
9823
+ import { z as z49 } from "zod";
9824
+
9825
+ // src/tools/product-research-idempotency.ts
9826
+ import { createHash as createHash2 } from "crypto";
9827
+ var RESEARCH_IDEMPOTENCY_WINDOW_MS = 2 * 60 * 60 * 1e3;
9828
+ var NAMESPACE = "6f9d3a1e-8c47-4b52-9f10-2d7e5a3c8b64";
9829
+ function uuidV5(name, namespace) {
9830
+ const namespaceBytes = Buffer.from(namespace.replace(/-/g, ""), "hex");
9831
+ const digest = createHash2("sha1").update(namespaceBytes).update(name, "utf8").digest();
9832
+ const bytes = Buffer.from(digest.subarray(0, 16));
9833
+ bytes[6] = (bytes[6] ?? 0) & 15 | 80;
9834
+ bytes[8] = (bytes[8] ?? 0) & 63 | 128;
9835
+ const hex = bytes.toString("hex");
9836
+ return [
9837
+ hex.slice(0, 8),
9838
+ hex.slice(8, 12),
9839
+ hex.slice(12, 16),
9840
+ hex.slice(16, 20),
9841
+ hex.slice(20)
9842
+ ].join("-");
9843
+ }
9844
+ __name(uuidV5, "uuidV5");
9845
+ function deriveIdempotencyKey(input) {
9846
+ const window = Math.floor(input.nowMs / RESEARCH_IDEMPOTENCY_WINDOW_MS);
9847
+ const name = `${input.fingerprint}:${input.targetCountry}:${input.categoryKey}:${window}`;
9848
+ return uuidV5(name, NAMESPACE);
9849
+ }
9850
+ __name(deriveIdempotencyKey, "deriveIdempotencyKey");
9851
+
9852
+ // src/tools/product-research-render.ts
9853
+ var num = /* @__PURE__ */ __name((value, suffix = "") => value === void 0 ? "\u2014" : `${Math.round(value * 100) / 100}${suffix}`, "num");
9854
+ function renderMeta(meta) {
9855
+ if (meta.status === "evidence_failed") {
9856
+ return [
9857
+ "- \u26A0\uFE0F **Sin evidencia publicitaria:** no se pudo consultar Meta Ads para este producto. La puntuaci\xF3n se apoya s\xF3lo en datos de venta."
9858
+ ];
9859
+ }
9860
+ if (meta.status === "evidence_empty") {
9861
+ return [
9862
+ "- \u26A0\uFE0F **Sin evidencia publicitaria:** no se encontraron anuncios activos en Meta. Puede ser un mercado virgen o una b\xFAsqueda sin cobertura; la puntuaci\xF3n se apoya s\xF3lo en datos de venta."
9863
+ ];
9864
+ }
9865
+ return [
9866
+ `- **Evidencia en Meta Ads:** ${meta.totalAds} anuncios de ${meta.distinctAdvertisers} anunciantes, m\xE1ximo ${meta.maxActiveDays} d\xEDas activos, oportunidad de \xE1ngulo ${meta.angleOpportunity}/100.`,
9867
+ meta.summary ? ` ${meta.summary}` : ""
9868
+ ].filter(Boolean);
9869
+ }
9870
+ __name(renderMeta, "renderMeta");
9871
+ function renderCandidate(candidate, index) {
9872
+ const name = candidate.nameLocal ?? candidate.nameOriginal;
9873
+ const lines = [
9874
+ `### ${index + 1}. ${name}`,
9875
+ `**${candidate.score}/100 \xB7 ${candidate.label} \xB7 confianza ${candidate.confidence}**`
9876
+ ];
9877
+ if (candidate.insight) lines.push("", candidate.insight);
9878
+ lines.push("", "**Por qu\xE9:**");
9879
+ lines.push(...candidate.reasons.map((reason) => `- ${reason}`));
9880
+ lines.push("", "**Riesgos:**");
9881
+ lines.push(...candidate.risks.length > 0 ? candidate.risks.map((risk) => `- ${risk}`) : [
9882
+ "- No se identificaron riesgos espec\xEDficos."
9883
+ ]);
9884
+ lines.push("", ...renderMeta(candidate.meta));
9885
+ lines.push("", `- **Venta (30d):** ${num(candidate.units30d)} unidades \xB7 ${num(candidate.revenueUsd30d, " USD")} \xB7 crecimiento ${num(candidate.growthPct30d, "%")} \xB7 precio ${num(candidate.priceUsd, " USD")}`);
9886
+ if (candidate.productUrl) lines.push(`- [Ver producto](${candidate.productUrl})`);
9887
+ return lines.join("\n");
9888
+ }
9889
+ __name(renderCandidate, "renderCandidate");
9890
+ function renderResearchReport(run) {
9891
+ if (run.status === "failed" || run.status === "refunded") {
9892
+ return [
9893
+ `\u274C **La investigaci\xF3n no pudo completarse** (estado: ${run.status}).`,
9894
+ run.errorCode ? `C\xF3digo: \`${run.errorCode}\`` : "",
9895
+ run.refunded ? `Los ${run.priceCredits} cr\xE9ditos fueron reembolsados.` : "\u26A0\uFE0F No consta reembolso \u2014 rev\xEDsalo antes de reintentar."
9896
+ ].filter(Boolean).join("\n");
9897
+ }
9898
+ if (run.status === "ready" && run.result === null) {
9899
+ return [
9900
+ `\u274C **No se pudo leer el reporte** (estado: ${run.status}).`,
9901
+ `El cargo de ${run.priceCredits} cr\xE9ditos ya se realiz\xF3, pero el reporte qued\xF3 corrupto y no puede recuperarse.`,
9902
+ `Contacta a soporte con este runId para revisar un reembolso: \`${run.id}\`.`
9903
+ ].join("\n");
9904
+ }
9905
+ if (run.result === null) {
9906
+ return [
9907
+ `\u23F3 **La investigaci\xF3n sigue en curso** (estado: ${run.status}, etapa: ${run.progressStage}).`,
9908
+ `El cargo de ${run.priceCredits} cr\xE9ditos ya se realiz\xF3; el reporte no se pierde.`,
9909
+ `Cons\xFAltalo con \`get_product_research_run\` usando runId \`${run.id}\`.`
9910
+ ].join("\n");
9911
+ }
9912
+ const { result } = run;
9913
+ const degraded = result.candidates.filter((candidate) => candidate.meta.status !== "evidence_available").length;
9914
+ const header = [
9915
+ `\u2705 **Productos ganadores \xB7 ${result.targetCountry} \xB7 ${result.categoryKey}**`,
9916
+ "",
9917
+ `Analizados ${result.methodology.kaloCandidateCount} candidatos del mercado ${result.sourceMarket}; ${result.methodology.metaCoveredCandidateCount} con cobertura de Meta Ads. Datos con ${num(result.freshnessHours, " h")} de antig\xFCedad.`
9918
+ ];
9919
+ if (degraded > 0) {
9920
+ header.push("", `\u26A0\uFE0F ${degraded} de ${result.candidates.length} productos no tienen evidencia publicitaria. Su puntuaci\xF3n se apoya s\xF3lo en datos de venta.`);
9921
+ }
9922
+ return [
9923
+ ...header,
9924
+ "",
9925
+ ...result.candidates.map((candidate, index) => renderCandidate(candidate, index))
9926
+ ].join("\n\n");
9927
+ }
9928
+ __name(renderResearchReport, "renderResearchReport");
9929
+
9930
+ // src/tools/product-research.ts
9931
+ var PRICE_CREDITS = 2e3;
9932
+ var TERMINAL2 = /* @__PURE__ */ new Set([
9933
+ "ready",
9934
+ "failed",
9935
+ "refunded"
9936
+ ]);
9937
+ var POLL_INTERVAL_MS = 5e3;
9938
+ var MAX_WAIT_SECONDS = 120;
9939
+ var FindInput = z49.object({
9940
+ targetCountry: ResearchTargetCountryEnum.describe("Pa\xEDs objetivo. S\xF3lo CO, MX, PE, EC y CL tienen cobertura de investigaci\xF3n."),
9941
+ categoryKey: ResearchCategoryEnum.default("auto").describe('Categor\xEDa a investigar. Usa "auto" para que la IA elija seg\xFAn el mercado.'),
9942
+ confirmed: z49.boolean().describe(`Ponlo en true SOLO despu\xE9s de que el usuario haya aceptado el cargo de ${PRICE_CREDITS} cr\xE9ditos. No lo asumas.`),
9943
+ waitSeconds: z49.number().int().min(0).max(MAX_WAIT_SECONDS).optional().describe("Segundos a esperar el reporte antes de devolver el runId. M\xE1ximo 120.")
9944
+ });
9945
+ var GetInput = z49.object({
9946
+ runId: z49.string().uuid().describe("ID del run devuelto por find_winning_products.")
9947
+ });
9948
+ var ListInput = z49.object({
9949
+ limit: z49.number().int().min(1).max(20).default(10)
9950
+ });
9951
+ function textResult(text) {
9952
+ return {
9953
+ content: [
9954
+ {
9955
+ type: "text",
9956
+ text
9957
+ }
9958
+ ]
9959
+ };
9960
+ }
9961
+ __name(textResult, "textResult");
9962
+ var sleep2 = /* @__PURE__ */ __name((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)), "sleep");
9963
+ async function pollUntilTerminal(api, runId, waitSeconds) {
9964
+ const deadline = Date.now() + Math.min(waitSeconds, MAX_WAIT_SECONDS) * 1e3;
9965
+ let run = await api.getJson(`/v1/product-research/runs/${runId}`);
9966
+ while (!TERMINAL2.has(run.status) && Date.now() < deadline) {
9967
+ await sleep2(POLL_INTERVAL_MS);
9968
+ run = await api.getJson(`/v1/product-research/runs/${runId}`);
9969
+ }
9970
+ return run;
9971
+ }
9972
+ __name(pollUntilTerminal, "pollUntilTerminal");
9973
+ function registerProductResearchTools(server, api) {
9974
+ server.tool("find_winning_products", `Busca productos ganadores de dropshipping para un pa\xEDs y categor\xEDa, y explica POR QU\xC9 la IA eligi\xF3 cada uno (razones, riesgos y evidencia real de anuncios en Meta Ads). CUESTA ${PRICE_CREDITS} CR\xC9DITOS por ejecuci\xF3n. ANTES de invocar, dile al usuario el costo y espera su aceptaci\xF3n expl\xEDcita; s\xF3lo entonces pasa confirmed: true. Consulta primero list_product_research_runs (GRATIS) \u2014 puede que el usuario ya tenga un reporte pagado para ese pa\xEDs/categor\xEDa y no necesites cobrar de nuevo. Pa\xEDses disponibles: CO, MX, PE, EC, CL. Tarda varios minutos: usa waitSeconds para esperar, y si expira consulta el resultado con get_product_research_run \u2014 el reporte NO se pierde.`, FindInput.shape, async (input) => {
9975
+ const parsed = FindInput.parse(input);
9976
+ if (!parsed.confirmed) {
9977
+ throw mcp(McpErrorCode.INVALID_INPUT, `This research run costs ${PRICE_CREDITS} credits. Tell the user the cost, get an explicit yes, then call again with confirmed: true.`);
9978
+ }
9979
+ try {
9980
+ const idempotencyKey = deriveIdempotencyKey({
9981
+ fingerprint: api.fingerprint(),
9982
+ targetCountry: parsed.targetCountry,
9983
+ categoryKey: parsed.categoryKey,
9984
+ nowMs: Date.now()
9985
+ });
9986
+ const created = await api.postJson("/v1/product-research/runs", {
9987
+ targetCountry: parsed.targetCountry,
9988
+ categoryKey: parsed.categoryKey,
9989
+ idempotencyKey
9990
+ });
9991
+ const waitSeconds = parsed.waitSeconds ?? 0;
9992
+ let run = created;
9993
+ if (waitSeconds > 0 && !TERMINAL2.has(created.status)) {
9994
+ try {
9995
+ run = await pollUntilTerminal(api, created.id, waitSeconds);
9996
+ } catch {
9997
+ run = created;
9998
+ }
9999
+ }
10000
+ const notice = created.reused ? `
10001
+
10002
+ _Esta llamada no cobr\xF3 cr\xE9ditos: se reutiliz\xF3 una investigaci\xF3n reciente con los mismos par\xE1metros. Para investigar una categor\xEDa distinta, vuelve a llamar con un \`categoryKey\` expl\xEDcito \u2014 eso inicia una investigaci\xF3n NUEVA con un cargo NUEVO de ${PRICE_CREDITS} cr\xE9ditos, as\xED que pide confirmaci\xF3n expl\xEDcita al usuario antes de hacerlo._` : "";
10003
+ return textResult(`${renderResearchReport(run)}${notice}`);
10004
+ } catch (error) {
10005
+ throw translateError(error);
10006
+ }
10007
+ });
10008
+ server.tool("get_product_research_run", "Devuelve el reporte completo de una investigaci\xF3n de productos ganadores: score, veredicto, confianza, razones, riesgos y evidencia de Meta Ads por producto. GRATIS y read-only. \xDAsalo cuando find_winning_products expir\xF3 la espera, o para releer un reporte anterior encontrado con list_product_research_runs.", GetInput.shape, async (input) => {
10009
+ const parsed = GetInput.parse(input);
10010
+ try {
10011
+ const run = await api.getJson(`/v1/product-research/runs/${parsed.runId}`);
10012
+ return {
10013
+ content: [
10014
+ {
10015
+ type: "text",
10016
+ text: JSON.stringify(run)
10017
+ },
10018
+ {
10019
+ type: "text",
10020
+ text: renderResearchReport(run)
10021
+ }
10022
+ ]
10023
+ };
10024
+ } catch (error) {
10025
+ throw translateError(error);
10026
+ }
10027
+ });
10028
+ server.tool("list_product_research_runs", "Lista las investigaciones de productos ganadores ya pagadas por el usuario, de la m\xE1s reciente a la m\xE1s antigua. GRATIS y read-only. Cons\xFAltalo ANTES de lanzar una nueva: un reporte ya pagado se relee gratis con get_product_research_run.", ListInput.shape, async (input) => {
10029
+ const parsed = ListInput.parse(input);
10030
+ try {
10031
+ const runs = await api.getJson(`/v1/product-research/runs?limit=${parsed.limit}`);
10032
+ return textResult(JSON.stringify(runs));
10033
+ } catch (error) {
10034
+ throw translateError(error);
10035
+ }
10036
+ });
10037
+ }
10038
+ __name(registerProductResearchTools, "registerProductResearchTools");
10039
+
9648
10040
  // src/server.ts
9649
10041
  function createServer(opts) {
9650
10042
  const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
@@ -9661,6 +10053,7 @@ function createServer(opts) {
9661
10053
  });
9662
10054
  const api = new ApiClient(apiBaseUrl, apiKey);
9663
10055
  registerProductCostingTools(server);
10056
+ registerProductResearchTools(server, api);
9664
10057
  registerTaskTools(server, api, {
9665
10058
  proxyBaseUrl,
9666
10059
  transport
package/dist/stdio.js CHANGED
@@ -9,6 +9,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
 
11
11
  // src/api-client.ts
12
+ import { createHash } from "crypto";
12
13
  var ApiHttpError = class extends Error {
13
14
  static {
14
15
  __name(this, "ApiHttpError");
@@ -35,6 +36,15 @@ var ApiClient = class {
35
36
  Authorization: `Bearer ${this.apiKey}`
36
37
  };
37
38
  }
39
+ /**
40
+ * Stable per-key identifier. The MCP never sees the userId — the API key is
41
+ * the only per-user handle it holds — so this stands in as the user
42
+ * component of derived idempotency keys. Never log or return it raw to a
43
+ * caller: it is derived from a secret.
44
+ */
45
+ fingerprint() {
46
+ return createHash("sha256").update(this.apiKey).digest("hex").slice(0, 32);
47
+ }
38
48
  async getJson(path3) {
39
49
  const res = await fetch(`${this.baseUrl}${path3}`, {
40
50
  headers: this.authHeaders()
@@ -129,7 +139,7 @@ var ApiClient = class {
129
139
  };
130
140
 
131
141
  // src/index.ts
132
- var MCP_VERSION = "1.16.3";
142
+ var MCP_VERSION = "1.17.0";
133
143
 
134
144
  // src/tools/tasks.ts
135
145
  import { z as z39 } from "zod";
@@ -3146,6 +3156,125 @@ var AdminQualityReportListResponse = z24.object({
3146
3156
  var UpdateQualityReportStatusRequest = z24.object({
3147
3157
  status: QualityReportStatus
3148
3158
  });
3159
+ var AdminSupportConversationStatus = z24.enum([
3160
+ "bot_active",
3161
+ "human_pending",
3162
+ "human_active",
3163
+ "closed"
3164
+ ]);
3165
+ var AdminSupportDeliveryStatus = z24.enum([
3166
+ "draft",
3167
+ "pending",
3168
+ "sent",
3169
+ "delivered",
3170
+ "read",
3171
+ "failed",
3172
+ "blocked"
3173
+ ]);
3174
+ var AdminSupportMessage = z24.object({
3175
+ id: z24.string().uuid(),
3176
+ direction: z24.enum([
3177
+ "inbound",
3178
+ "outbound"
3179
+ ]),
3180
+ senderKind: z24.enum([
3181
+ "user",
3182
+ "bot",
3183
+ "admin",
3184
+ "system"
3185
+ ]),
3186
+ messageType: z24.enum([
3187
+ "text",
3188
+ "image",
3189
+ "audio",
3190
+ "document",
3191
+ "interactive",
3192
+ "status"
3193
+ ]),
3194
+ bodyRedacted: z24.string().nullable(),
3195
+ deliveryStatus: AdminSupportDeliveryStatus.nullable(),
3196
+ createdAt: z24.string().datetime()
3197
+ });
3198
+ var AdminSupportTicket = z24.object({
3199
+ id: z24.string().uuid(),
3200
+ category: z24.enum([
3201
+ "how_to",
3202
+ "credits",
3203
+ "billing",
3204
+ "task_status",
3205
+ "task_error",
3206
+ "video_quality",
3207
+ "landing_quality",
3208
+ "image_quality",
3209
+ "feature_request",
3210
+ "other"
3211
+ ]),
3212
+ failureMode: z24.string().nullable(),
3213
+ status: z24.enum([
3214
+ "open",
3215
+ "triaged",
3216
+ "resolved",
3217
+ "rejected"
3218
+ ]),
3219
+ summary: z24.string(),
3220
+ taskId: z24.string().nullable(),
3221
+ mixTaskId: z24.string().nullable(),
3222
+ qualityReportId: z24.string().nullable(),
3223
+ handoffReason: z24.string().nullable(),
3224
+ createdAt: z24.string().datetime(),
3225
+ updatedAt: z24.string().datetime()
3226
+ });
3227
+ var AdminSupportConversation = z24.object({
3228
+ id: z24.string().uuid(),
3229
+ status: AdminSupportConversationStatus,
3230
+ priority: z24.enum([
3231
+ "low",
3232
+ "normal",
3233
+ "high",
3234
+ "urgent"
3235
+ ]),
3236
+ currentIntent: z24.string().nullable(),
3237
+ assignedAdminId: z24.string().nullable(),
3238
+ customerServiceWindowEndsAt: z24.string().datetime().nullable(),
3239
+ lastInboundAt: z24.string().datetime().nullable(),
3240
+ createdAt: z24.string().datetime(),
3241
+ updatedAt: z24.string().datetime(),
3242
+ contact: z24.object({
3243
+ id: z24.string().uuid(),
3244
+ userId: z24.string().nullable(),
3245
+ verifiedAt: z24.string().datetime().nullable(),
3246
+ botMode: z24.enum([
3247
+ "enabled",
3248
+ "paused",
3249
+ "opted_out"
3250
+ ]),
3251
+ locale: z24.string()
3252
+ }),
3253
+ messages: z24.array(AdminSupportMessage),
3254
+ tickets: z24.array(AdminSupportTicket)
3255
+ });
3256
+ var AdminSupportListQuery = z24.object({
3257
+ status: AdminSupportConversationStatus.optional(),
3258
+ take: z24.coerce.number().int().min(1).max(100).default(50),
3259
+ cursorId: z24.string().uuid().optional()
3260
+ }).strict();
3261
+ var AdminSupportListResponse = z24.object({
3262
+ conversations: z24.array(AdminSupportConversation),
3263
+ nextCursor: z24.string().uuid().nullable()
3264
+ });
3265
+ var AdminSupportReplyRequest = z24.object({
3266
+ body: z24.string().trim().min(1).max(4096),
3267
+ idempotencyKey: z24.string().regex(/^[A-Za-z0-9_-]{8,128}$/)
3268
+ }).strict();
3269
+ var AdminSupportEmptyActionRequest = z24.object({}).strict();
3270
+ var AdminSupportActionResponse = z24.object({
3271
+ conversation: AdminSupportConversation
3272
+ });
3273
+ var AdminSupportReplyResponse = z24.object({
3274
+ messageId: z24.string().uuid(),
3275
+ deliveryStatus: AdminSupportDeliveryStatus,
3276
+ duplicate: z24.boolean()
3277
+ });
3149
3278
 
3150
3279
  // ../../packages/types/src/promo.ts
3151
3280
  import { z as z25 } from "zod";
@@ -3981,6 +4110,23 @@ var DEFAULT_PRICING = {
3981
4110
  ourCostCents: 0.4,
3982
4111
  userPriceCents: 0.8
3983
4112
  },
4113
+ // WhatsApp ProductMaker intent proposal. Real OpenRouter usage.cost wins;
4114
+ // this is only the fallback when the relay omits cost.
4115
+ "openrouter:whatsapp-product-agent-intent": {
4116
+ ourCostCents: 0.4,
4117
+ userPriceCents: 0.8
4118
+ },
4119
+ // Internal support calls are cost-only: callers explicitly override user
4120
+ // price to zero. Static COGS estimates apply because OpenAI Responses exposes
4121
+ // token counts but not the provider's dollar charge.
4122
+ "openai:whatsapp-support-classifier": {
4123
+ ourCostCents: 0.1,
4124
+ userPriceCents: 0.2
4125
+ },
4126
+ "openai:whatsapp-support-draft": {
4127
+ ourCostCents: 0.4,
4128
+ userPriceCents: 0.8
4129
+ },
3984
4130
  // Task-derived creative recommendation (angle/persona/script/landing-section).
3985
4131
  // OpenRouter usage normally supplies the exact COGS and the call layer charges
3986
4132
  // 2x. This rule is the fail-closed fallback when usage.cost is unavailable.
@@ -6927,6 +7073,28 @@ async function submitCreative(api, opts) {
6927
7073
  });
6928
7074
  }
6929
7075
  __name(submitCreative, "submitCreative");
7076
+ var ResearchTargetCountryEnum = z38.enum([
7077
+ "CO",
7078
+ "MX",
7079
+ "PE",
7080
+ "EC",
7081
+ "CL"
7082
+ ]);
7083
+ var ResearchCategoryEnum = z38.enum([
7084
+ "auto",
7085
+ "automotive",
7086
+ "kitchen",
7087
+ "pets",
7088
+ "home_organization",
7089
+ "beauty",
7090
+ "wellness",
7091
+ "fitness",
7092
+ "baby",
7093
+ "tech",
7094
+ "tools",
7095
+ "garden",
7096
+ "cleaning"
7097
+ ]);
6930
7098
 
6931
7099
  // src/errors.ts
6932
7100
  var McpErrorCode = {
@@ -6958,6 +7126,7 @@ var McpErrorCode = {
6958
7126
  TASK_NOT_READY: "TASK_NOT_READY",
6959
7127
  CREATIVE_VARIANT_FAILED: "CREATIVE_VARIANT_FAILED",
6960
7128
  CREATIVE_VARIANT_DISPATCH_FAILED: "CREATIVE_VARIANT_DISPATCH_FAILED",
7129
+ PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT: "PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT",
6961
7130
  // Connection presence
6962
7131
  NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
6963
7132
  NO_META_AD_ACCOUNTS: "NO_META_AD_ACCOUNTS",
@@ -7079,12 +7248,13 @@ var CODE_MESSAGES = {
7079
7248
  TASK_NOT_READY: "The task is not ready for this action yet. Wait for it to finish and retry.",
7080
7249
  CREATIVE_VARIANT_FAILED: "The previous creative variant attempt failed. Ask for confirmation before starting a new paid attempt with a new clientRequestId.",
7081
7250
  CREATIVE_VARIANT_DISPATCH_FAILED: "The creative variant could not be queued. Retry as a new paid attempt with a new clientRequestId.",
7251
+ PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT: "A product research run with the same country and category is already running. Check it with `list_product_research_runs` instead of starting a new paid run.",
7082
7252
  // API key
7083
7253
  INVALID_API_KEY: "Invalid API key. Check your PM_API_KEY in the MCP client configuration.",
7084
7254
  REVOKED_API_KEY: "API key revoked. Generate a new one at https://productmaker.app/account/api-keys.",
7085
7255
  // Rate
7086
7256
  RATE_LIMITED: "Too many requests. Wait a few seconds.",
7087
- INSUFFICIENT_CREDITS: "Insufficient credits to create this variant. Check your balance and retry after adding credits.",
7257
+ INSUFFICIENT_CREDITS: "Insufficient credits for this action. Check your balance and add credits at https://productmaker.app before retrying.",
7088
7258
  TASK_QUOTA_EXCEEDED: "You reached the active-task limit. Wait for a task to finish before creating another variant.",
7089
7259
  // Generic
7090
7260
  UPSTREAM_UNAVAILABLE: "The Product Maker service did not respond. Try again shortly.",
@@ -7097,6 +7267,7 @@ var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
7097
7267
  McpErrorCode.INVALID_INPUT,
7098
7268
  McpErrorCode.INSUFFICIENT_CREDITS,
7099
7269
  McpErrorCode.TASK_QUOTA_EXCEEDED,
7270
+ McpErrorCode.PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT,
7100
7271
  McpErrorCode.INVALID_API_KEY,
7101
7272
  McpErrorCode.REVOKED_API_KEY,
7102
7273
  McpErrorCode.IMAGE_PATH_NOT_FOUND,
@@ -7159,6 +7330,7 @@ var PASSTHROUGH_CODES = /* @__PURE__ */ new Set([
7159
7330
  McpErrorCode.INSUFFICIENT_CREDITS,
7160
7331
  McpErrorCode.TASK_QUOTA_EXCEEDED,
7161
7332
  McpErrorCode.CREATIVE_VARIANT_DISPATCH_FAILED,
7333
+ McpErrorCode.PRODUCT_RESEARCH_IDEMPOTENCY_CONFLICT,
7162
7334
  // Shopify
7163
7335
  McpErrorCode.SHOPIFY_NOT_CONNECTED,
7164
7336
  McpErrorCode.SHOPIFY_TOKEN_EXPIRED,
@@ -7272,9 +7444,11 @@ function translateError(err) {
7272
7444
  const body = isRecord(err.body) ? err.body : {};
7273
7445
  if (typeof body.code === "string" && PASSTHROUGH_CODES.has(body.code)) {
7274
7446
  const code = body.code;
7275
- return mcp(code, resolveMessage(code, body.message), body.helpUrl ? {
7276
- helpUrl: body.helpUrl
7277
- } : {});
7447
+ const extra = {};
7448
+ if (body.helpUrl !== void 0) extra.helpUrl = body.helpUrl;
7449
+ if (typeof body.requiredCredits === "number") extra.requiredCredits = body.requiredCredits;
7450
+ if (typeof body.availableCredits === "number") extra.availableCredits = body.availableCredits;
7451
+ return mcp(code, resolveMessage(code, body.message), extra);
7278
7452
  }
7279
7453
  if (err.status === 401) {
7280
7454
  return mcp(McpErrorCode.INVALID_API_KEY, resolveMessage(McpErrorCode.INVALID_API_KEY, void 0));
@@ -9601,6 +9775,224 @@ function formatPercent(value) {
9601
9775
  }
9602
9776
  __name(formatPercent, "formatPercent");
9603
9777
 
9778
+ // src/tools/product-research.ts
9779
+ import { z as z49 } from "zod";
9780
+
9781
+ // src/tools/product-research-idempotency.ts
9782
+ import { createHash as createHash2 } from "crypto";
9783
+ var RESEARCH_IDEMPOTENCY_WINDOW_MS = 2 * 60 * 60 * 1e3;
9784
+ var NAMESPACE = "6f9d3a1e-8c47-4b52-9f10-2d7e5a3c8b64";
9785
+ function uuidV5(name, namespace) {
9786
+ const namespaceBytes = Buffer.from(namespace.replace(/-/g, ""), "hex");
9787
+ const digest = createHash2("sha1").update(namespaceBytes).update(name, "utf8").digest();
9788
+ const bytes = Buffer.from(digest.subarray(0, 16));
9789
+ bytes[6] = (bytes[6] ?? 0) & 15 | 80;
9790
+ bytes[8] = (bytes[8] ?? 0) & 63 | 128;
9791
+ const hex = bytes.toString("hex");
9792
+ return [
9793
+ hex.slice(0, 8),
9794
+ hex.slice(8, 12),
9795
+ hex.slice(12, 16),
9796
+ hex.slice(16, 20),
9797
+ hex.slice(20)
9798
+ ].join("-");
9799
+ }
9800
+ __name(uuidV5, "uuidV5");
9801
+ function deriveIdempotencyKey(input) {
9802
+ const window = Math.floor(input.nowMs / RESEARCH_IDEMPOTENCY_WINDOW_MS);
9803
+ const name = `${input.fingerprint}:${input.targetCountry}:${input.categoryKey}:${window}`;
9804
+ return uuidV5(name, NAMESPACE);
9805
+ }
9806
+ __name(deriveIdempotencyKey, "deriveIdempotencyKey");
9807
+
9808
+ // src/tools/product-research-render.ts
9809
+ var num = /* @__PURE__ */ __name((value, suffix = "") => value === void 0 ? "\u2014" : `${Math.round(value * 100) / 100}${suffix}`, "num");
9810
+ function renderMeta(meta) {
9811
+ if (meta.status === "evidence_failed") {
9812
+ return [
9813
+ "- \u26A0\uFE0F **Sin evidencia publicitaria:** no se pudo consultar Meta Ads para este producto. La puntuaci\xF3n se apoya s\xF3lo en datos de venta."
9814
+ ];
9815
+ }
9816
+ if (meta.status === "evidence_empty") {
9817
+ return [
9818
+ "- \u26A0\uFE0F **Sin evidencia publicitaria:** no se encontraron anuncios activos en Meta. Puede ser un mercado virgen o una b\xFAsqueda sin cobertura; la puntuaci\xF3n se apoya s\xF3lo en datos de venta."
9819
+ ];
9820
+ }
9821
+ return [
9822
+ `- **Evidencia en Meta Ads:** ${meta.totalAds} anuncios de ${meta.distinctAdvertisers} anunciantes, m\xE1ximo ${meta.maxActiveDays} d\xEDas activos, oportunidad de \xE1ngulo ${meta.angleOpportunity}/100.`,
9823
+ meta.summary ? ` ${meta.summary}` : ""
9824
+ ].filter(Boolean);
9825
+ }
9826
+ __name(renderMeta, "renderMeta");
9827
+ function renderCandidate(candidate, index) {
9828
+ const name = candidate.nameLocal ?? candidate.nameOriginal;
9829
+ const lines = [
9830
+ `### ${index + 1}. ${name}`,
9831
+ `**${candidate.score}/100 \xB7 ${candidate.label} \xB7 confianza ${candidate.confidence}**`
9832
+ ];
9833
+ if (candidate.insight) lines.push("", candidate.insight);
9834
+ lines.push("", "**Por qu\xE9:**");
9835
+ lines.push(...candidate.reasons.map((reason) => `- ${reason}`));
9836
+ lines.push("", "**Riesgos:**");
9837
+ lines.push(...candidate.risks.length > 0 ? candidate.risks.map((risk) => `- ${risk}`) : [
9838
+ "- No se identificaron riesgos espec\xEDficos."
9839
+ ]);
9840
+ lines.push("", ...renderMeta(candidate.meta));
9841
+ lines.push("", `- **Venta (30d):** ${num(candidate.units30d)} unidades \xB7 ${num(candidate.revenueUsd30d, " USD")} \xB7 crecimiento ${num(candidate.growthPct30d, "%")} \xB7 precio ${num(candidate.priceUsd, " USD")}`);
9842
+ if (candidate.productUrl) lines.push(`- [Ver producto](${candidate.productUrl})`);
9843
+ return lines.join("\n");
9844
+ }
9845
+ __name(renderCandidate, "renderCandidate");
9846
+ function renderResearchReport(run) {
9847
+ if (run.status === "failed" || run.status === "refunded") {
9848
+ return [
9849
+ `\u274C **La investigaci\xF3n no pudo completarse** (estado: ${run.status}).`,
9850
+ run.errorCode ? `C\xF3digo: \`${run.errorCode}\`` : "",
9851
+ run.refunded ? `Los ${run.priceCredits} cr\xE9ditos fueron reembolsados.` : "\u26A0\uFE0F No consta reembolso \u2014 rev\xEDsalo antes de reintentar."
9852
+ ].filter(Boolean).join("\n");
9853
+ }
9854
+ if (run.status === "ready" && run.result === null) {
9855
+ return [
9856
+ `\u274C **No se pudo leer el reporte** (estado: ${run.status}).`,
9857
+ `El cargo de ${run.priceCredits} cr\xE9ditos ya se realiz\xF3, pero el reporte qued\xF3 corrupto y no puede recuperarse.`,
9858
+ `Contacta a soporte con este runId para revisar un reembolso: \`${run.id}\`.`
9859
+ ].join("\n");
9860
+ }
9861
+ if (run.result === null) {
9862
+ return [
9863
+ `\u23F3 **La investigaci\xF3n sigue en curso** (estado: ${run.status}, etapa: ${run.progressStage}).`,
9864
+ `El cargo de ${run.priceCredits} cr\xE9ditos ya se realiz\xF3; el reporte no se pierde.`,
9865
+ `Cons\xFAltalo con \`get_product_research_run\` usando runId \`${run.id}\`.`
9866
+ ].join("\n");
9867
+ }
9868
+ const { result } = run;
9869
+ const degraded = result.candidates.filter((candidate) => candidate.meta.status !== "evidence_available").length;
9870
+ const header = [
9871
+ `\u2705 **Productos ganadores \xB7 ${result.targetCountry} \xB7 ${result.categoryKey}**`,
9872
+ "",
9873
+ `Analizados ${result.methodology.kaloCandidateCount} candidatos del mercado ${result.sourceMarket}; ${result.methodology.metaCoveredCandidateCount} con cobertura de Meta Ads. Datos con ${num(result.freshnessHours, " h")} de antig\xFCedad.`
9874
+ ];
9875
+ if (degraded > 0) {
9876
+ header.push("", `\u26A0\uFE0F ${degraded} de ${result.candidates.length} productos no tienen evidencia publicitaria. Su puntuaci\xF3n se apoya s\xF3lo en datos de venta.`);
9877
+ }
9878
+ return [
9879
+ ...header,
9880
+ "",
9881
+ ...result.candidates.map((candidate, index) => renderCandidate(candidate, index))
9882
+ ].join("\n\n");
9883
+ }
9884
+ __name(renderResearchReport, "renderResearchReport");
9885
+
9886
+ // src/tools/product-research.ts
9887
+ var PRICE_CREDITS = 2e3;
9888
+ var TERMINAL2 = /* @__PURE__ */ new Set([
9889
+ "ready",
9890
+ "failed",
9891
+ "refunded"
9892
+ ]);
9893
+ var POLL_INTERVAL_MS = 5e3;
9894
+ var MAX_WAIT_SECONDS = 120;
9895
+ var FindInput = z49.object({
9896
+ targetCountry: ResearchTargetCountryEnum.describe("Pa\xEDs objetivo. S\xF3lo CO, MX, PE, EC y CL tienen cobertura de investigaci\xF3n."),
9897
+ categoryKey: ResearchCategoryEnum.default("auto").describe('Categor\xEDa a investigar. Usa "auto" para que la IA elija seg\xFAn el mercado.'),
9898
+ confirmed: z49.boolean().describe(`Ponlo en true SOLO despu\xE9s de que el usuario haya aceptado el cargo de ${PRICE_CREDITS} cr\xE9ditos. No lo asumas.`),
9899
+ waitSeconds: z49.number().int().min(0).max(MAX_WAIT_SECONDS).optional().describe("Segundos a esperar el reporte antes de devolver el runId. M\xE1ximo 120.")
9900
+ });
9901
+ var GetInput = z49.object({
9902
+ runId: z49.string().uuid().describe("ID del run devuelto por find_winning_products.")
9903
+ });
9904
+ var ListInput = z49.object({
9905
+ limit: z49.number().int().min(1).max(20).default(10)
9906
+ });
9907
+ function textResult(text) {
9908
+ return {
9909
+ content: [
9910
+ {
9911
+ type: "text",
9912
+ text
9913
+ }
9914
+ ]
9915
+ };
9916
+ }
9917
+ __name(textResult, "textResult");
9918
+ var sleep2 = /* @__PURE__ */ __name((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)), "sleep");
9919
+ async function pollUntilTerminal(api, runId, waitSeconds) {
9920
+ const deadline = Date.now() + Math.min(waitSeconds, MAX_WAIT_SECONDS) * 1e3;
9921
+ let run = await api.getJson(`/v1/product-research/runs/${runId}`);
9922
+ while (!TERMINAL2.has(run.status) && Date.now() < deadline) {
9923
+ await sleep2(POLL_INTERVAL_MS);
9924
+ run = await api.getJson(`/v1/product-research/runs/${runId}`);
9925
+ }
9926
+ return run;
9927
+ }
9928
+ __name(pollUntilTerminal, "pollUntilTerminal");
9929
+ function registerProductResearchTools(server, api) {
9930
+ server.tool("find_winning_products", `Busca productos ganadores de dropshipping para un pa\xEDs y categor\xEDa, y explica POR QU\xC9 la IA eligi\xF3 cada uno (razones, riesgos y evidencia real de anuncios en Meta Ads). CUESTA ${PRICE_CREDITS} CR\xC9DITOS por ejecuci\xF3n. ANTES de invocar, dile al usuario el costo y espera su aceptaci\xF3n expl\xEDcita; s\xF3lo entonces pasa confirmed: true. Consulta primero list_product_research_runs (GRATIS) \u2014 puede que el usuario ya tenga un reporte pagado para ese pa\xEDs/categor\xEDa y no necesites cobrar de nuevo. Pa\xEDses disponibles: CO, MX, PE, EC, CL. Tarda varios minutos: usa waitSeconds para esperar, y si expira consulta el resultado con get_product_research_run \u2014 el reporte NO se pierde.`, FindInput.shape, async (input) => {
9931
+ const parsed = FindInput.parse(input);
9932
+ if (!parsed.confirmed) {
9933
+ throw mcp(McpErrorCode.INVALID_INPUT, `This research run costs ${PRICE_CREDITS} credits. Tell the user the cost, get an explicit yes, then call again with confirmed: true.`);
9934
+ }
9935
+ try {
9936
+ const idempotencyKey = deriveIdempotencyKey({
9937
+ fingerprint: api.fingerprint(),
9938
+ targetCountry: parsed.targetCountry,
9939
+ categoryKey: parsed.categoryKey,
9940
+ nowMs: Date.now()
9941
+ });
9942
+ const created = await api.postJson("/v1/product-research/runs", {
9943
+ targetCountry: parsed.targetCountry,
9944
+ categoryKey: parsed.categoryKey,
9945
+ idempotencyKey
9946
+ });
9947
+ const waitSeconds = parsed.waitSeconds ?? 0;
9948
+ let run = created;
9949
+ if (waitSeconds > 0 && !TERMINAL2.has(created.status)) {
9950
+ try {
9951
+ run = await pollUntilTerminal(api, created.id, waitSeconds);
9952
+ } catch {
9953
+ run = created;
9954
+ }
9955
+ }
9956
+ const notice = created.reused ? `
9957
+
9958
+ _Esta llamada no cobr\xF3 cr\xE9ditos: se reutiliz\xF3 una investigaci\xF3n reciente con los mismos par\xE1metros. Para investigar una categor\xEDa distinta, vuelve a llamar con un \`categoryKey\` expl\xEDcito \u2014 eso inicia una investigaci\xF3n NUEVA con un cargo NUEVO de ${PRICE_CREDITS} cr\xE9ditos, as\xED que pide confirmaci\xF3n expl\xEDcita al usuario antes de hacerlo._` : "";
9959
+ return textResult(`${renderResearchReport(run)}${notice}`);
9960
+ } catch (error) {
9961
+ throw translateError(error);
9962
+ }
9963
+ });
9964
+ server.tool("get_product_research_run", "Devuelve el reporte completo de una investigaci\xF3n de productos ganadores: score, veredicto, confianza, razones, riesgos y evidencia de Meta Ads por producto. GRATIS y read-only. \xDAsalo cuando find_winning_products expir\xF3 la espera, o para releer un reporte anterior encontrado con list_product_research_runs.", GetInput.shape, async (input) => {
9965
+ const parsed = GetInput.parse(input);
9966
+ try {
9967
+ const run = await api.getJson(`/v1/product-research/runs/${parsed.runId}`);
9968
+ return {
9969
+ content: [
9970
+ {
9971
+ type: "text",
9972
+ text: JSON.stringify(run)
9973
+ },
9974
+ {
9975
+ type: "text",
9976
+ text: renderResearchReport(run)
9977
+ }
9978
+ ]
9979
+ };
9980
+ } catch (error) {
9981
+ throw translateError(error);
9982
+ }
9983
+ });
9984
+ server.tool("list_product_research_runs", "Lista las investigaciones de productos ganadores ya pagadas por el usuario, de la m\xE1s reciente a la m\xE1s antigua. GRATIS y read-only. Cons\xFAltalo ANTES de lanzar una nueva: un reporte ya pagado se relee gratis con get_product_research_run.", ListInput.shape, async (input) => {
9985
+ const parsed = ListInput.parse(input);
9986
+ try {
9987
+ const runs = await api.getJson(`/v1/product-research/runs?limit=${parsed.limit}`);
9988
+ return textResult(JSON.stringify(runs));
9989
+ } catch (error) {
9990
+ throw translateError(error);
9991
+ }
9992
+ });
9993
+ }
9994
+ __name(registerProductResearchTools, "registerProductResearchTools");
9995
+
9604
9996
  // src/server.ts
9605
9997
  function createServer(opts) {
9606
9998
  const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
@@ -9617,6 +10009,7 @@ function createServer(opts) {
9617
10009
  });
9618
10010
  const api = new ApiClient(apiBaseUrl, apiKey);
9619
10011
  registerProductCostingTools(server);
10012
+ registerProductResearchTools(server, api);
9620
10013
  registerTaskTools(server, api, {
9621
10014
  proxyBaseUrl,
9622
10015
  transport
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@productmaker/mcp",
3
- "version": "1.16.3",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Product Maker MCP server — cost products, generate dropshipping landing pages, UGC videos, and ad creatives, and publish to Shopify and Meta Ads from Claude, ChatGPT, or Gemini.",
@@ -63,8 +63,9 @@
63
63
  "tsx": "^4.16.0",
64
64
  "typescript": "^7.0.2",
65
65
  "vitest": "^2.1.9",
66
- "@packages/types": "0.1.0",
67
- "@packages/product-economics": "0.1.0"
66
+ "@packages/product-economics": "0.1.0",
67
+ "@packages/product-research": "0.1.0",
68
+ "@packages/types": "0.1.0"
68
69
  },
69
70
  "publishConfig": {
70
71
  "access": "public"