ebay-mcp-remote-edition 4.2.0 → 4.2.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.
@@ -42,14 +42,234 @@ export class TradingApiClient {
42
42
  getBaseUrl() {
43
43
  return this.baseUrl;
44
44
  }
45
+ /**
46
+ * Transform a Trading API item object into a structure that fast-xml-parser
47
+ * can serialize into valid eBay Trading API XML.
48
+ *
49
+ * Handles nested objects like PrimaryCategory, ShippingDetails, ReturnPolicy,
50
+ * PicturesDetails, and ItemSpecifics that require special XML structure.
51
+ */
52
+ transformItemForXML(item) {
53
+ const transformed = {};
54
+ for (const [key, value] of Object.entries(item)) {
55
+ if (value === undefined || value === null)
56
+ continue;
57
+ switch (key) {
58
+ case 'PrimaryCategory':
59
+ if (typeof value === 'object') {
60
+ transformed[key] = { CategoryID: value.CategoryID };
61
+ }
62
+ else {
63
+ transformed[key] = value;
64
+ }
65
+ break;
66
+ case 'StartPrice':
67
+ transformed[key] = this.transformPrice(value);
68
+ break;
69
+ case 'Currency':
70
+ transformed[key] = value;
71
+ break;
72
+ case 'ListingDuration': {
73
+ // Normalize listing duration - map invalid/common values to valid ones
74
+ const duration = value;
75
+ if (duration === 'GMS' || duration === 'GN') {
76
+ // GMS (Good Month) and GN (Good 'til Cancelled) may not be available
77
+ transformed[key] = 'Days_30';
78
+ }
79
+ else {
80
+ transformed[key] = value;
81
+ }
82
+ break;
83
+ }
84
+ case 'ShippingDetails':
85
+ transformed[key] = this.transformShippingDetails(value);
86
+ break;
87
+ case 'ReturnPolicy':
88
+ transformed[key] = this.transformReturnPolicy(value);
89
+ break;
90
+ case 'PicturesDetails':
91
+ transformed[key] = this.transformPicturesDetails(value);
92
+ break;
93
+ case 'ItemSpecifics':
94
+ transformed[key] = this.transformItemSpecifics(value);
95
+ break;
96
+ case 'PaymentMethods':
97
+ // Ensure array of strings becomes array of PaymentMethod elements
98
+ if (Array.isArray(value)) {
99
+ transformed[key] = value;
100
+ }
101
+ else {
102
+ transformed[key] = value;
103
+ }
104
+ break;
105
+ default:
106
+ transformed[key] = value;
107
+ break;
108
+ }
109
+ }
110
+ // Ensure Currency is always present - extract from StartPrice if not provided
111
+ if (!transformed.Currency && item.StartPrice) {
112
+ const sp = item.StartPrice;
113
+ if (typeof sp === 'object' && sp.currencyID) {
114
+ transformed.Currency = sp.currencyID;
115
+ }
116
+ else {
117
+ transformed.Currency = 'USD';
118
+ }
119
+ }
120
+ return transformed;
121
+ }
122
+ /**
123
+ * Transform price fields that may have currency attributes.
124
+ * eBay Trading API expects: <StartPrice currencyID="USD">34.99</StartPrice>
125
+ */
126
+ transformPrice(price) {
127
+ if (typeof price === 'string') {
128
+ return price;
129
+ }
130
+ // Use fast-xml-parser attribute convention: @_<attrName> for attributes, #text for content
131
+ return {
132
+ '#text': String(price.value),
133
+ ...(price.currencyID && { '@_currencyID': price.currencyID }),
134
+ };
135
+ }
136
+ /**
137
+ * Transform ShippingDetails into proper nested XML structure.
138
+ */
139
+ transformShippingDetails(sd) {
140
+ const transformed = {};
141
+ if (sd.HandlingTime !== undefined) {
142
+ transformed.HandlingTime = sd.HandlingTime;
143
+ }
144
+ if (sd.ShippingServiceOptions) {
145
+ const options = sd.ShippingServiceOptions;
146
+ const serviceOption = {};
147
+ if (options.ShippingServicePriority) {
148
+ serviceOption.ShippingServicePriority = options.ShippingServicePriority;
149
+ }
150
+ if (options.ShippingServiceID) {
151
+ serviceOption.ShippingServiceID = options.ShippingServiceID;
152
+ }
153
+ if (options.ShippingServiceCost) {
154
+ serviceOption.ShippingServiceCost = this.transformPrice(options.ShippingServiceCost);
155
+ }
156
+ if (options.ShippingType) {
157
+ serviceOption.ShippingType = options.ShippingType;
158
+ }
159
+ transformed.ShippingServiceOptions = serviceOption;
160
+ }
161
+ if (sd.InternationalShippingServiceOption) {
162
+ const intlOptions = sd.InternationalShippingServiceOption;
163
+ transformed.InternationalShippingServiceOption = intlOptions.map((option) => {
164
+ const transformedOption = {};
165
+ if (option.ShippingServicePriority) {
166
+ transformedOption.ShippingServicePriority = option.ShippingServicePriority;
167
+ }
168
+ if (option.ShippingServiceID) {
169
+ transformedOption.ShippingServiceID = option.ShippingServiceID;
170
+ }
171
+ if (option.ShippingServiceCost) {
172
+ transformedOption.ShippingServiceCost = this.transformPrice(option.ShippingServiceCost);
173
+ }
174
+ if (option.ShippingType) {
175
+ transformedOption.ShippingType = option.ShippingType;
176
+ }
177
+ if (option.Country) {
178
+ transformedOption.Country = Array.isArray(option.Country)
179
+ ? option.Country
180
+ : [option.Country];
181
+ }
182
+ return transformedOption;
183
+ });
184
+ }
185
+ return transformed;
186
+ }
187
+ /**
188
+ * Transform ReturnPolicy into proper nested XML structure.
189
+ * Maps user-friendly values to eBay Trading API enum values.
190
+ */
191
+ transformReturnPolicy(rp) {
192
+ const transformed = {};
193
+ if (rp.ReturnsAcceptedOption !== undefined) {
194
+ const val = rp.ReturnsAcceptedOption;
195
+ // Map user-friendly values to eBay enum values
196
+ if (val.toLowerCase() === 'yes') {
197
+ transformed.ReturnsAcceptedOption = 'ReturnsAccepted';
198
+ }
199
+ else if (val.toLowerCase() === 'no') {
200
+ transformed.ReturnsAcceptedOption = 'ReturnsNotAccepted';
201
+ }
202
+ else {
203
+ transformed.ReturnsAcceptedOption = val;
204
+ }
205
+ }
206
+ if (rp.ReturnsWithinOption !== undefined) {
207
+ const val = rp.ReturnsWithinOption;
208
+ // Map user-friendly values to eBay enum values (Days30 → Days_30)
209
+ transformed.ReturnsWithinOption = val.replace(/^Days(\d+)$/, 'Days_$1');
210
+ }
211
+ if (rp.Description !== undefined) {
212
+ transformed.Description = rp.Description;
213
+ }
214
+ if (rp.RefundOption !== undefined) {
215
+ transformed.RefundOption = rp.RefundOption;
216
+ }
217
+ return transformed;
218
+ }
219
+ /**
220
+ * Transform PicturesDetails into proper nested XML structure.
221
+ */
222
+ transformPicturesDetails(pd) {
223
+ const transformed = {};
224
+ if (pd.GalleryType) {
225
+ transformed.GalleryType = pd.GalleryType;
226
+ }
227
+ if (pd.PictureURL) {
228
+ const urls = pd.PictureURL;
229
+ transformed.PictureURL = Array.isArray(urls) ? urls : [urls];
230
+ }
231
+ return transformed;
232
+ }
233
+ /**
234
+ * Transform ItemSpecifics into proper NameValueList XML structure.
235
+ * eBay expects:
236
+ * <ItemSpecifics>
237
+ * <NameValueList><Name>Brand</Name><Value>Nike</Value></NameValueList>
238
+ * </ItemSpecifics>
239
+ */
240
+ transformItemSpecifics(specifics) {
241
+ if (!Array.isArray(specifics))
242
+ return specifics;
243
+ // eBay expects: <ItemSpecifics><NameValueList><Name>...</Name><Value>...</Value></NameValueList>...</ItemSpecifics>
244
+ // fast-xml-parser XMLBuilder: when ItemSpecifics is in isArray list, it creates one <ItemSpecifics> per array item.
245
+ // So we wrap each spec in { NameValueList: { Name, Value } } and return as a single object.
246
+ // The caller (transformItemForXML) sets transformed['ItemSpecifics'] = this result.
247
+ // To get a single <ItemSpecifics> with multiple <NameValueList>, we need to NOT return an array
248
+ // but instead return the NameValueList array directly, and let the XMLBuilder handle it.
249
+ // Actually: the cleanest approach is to return the array of NameValueList objects,
250
+ // and since transformItemForXML assigns it to transformed['ItemSpecifics'], and
251
+ // ItemSpecifics IS in the isArray list, XMLBuilder will emit multiple <ItemSpecifics>.
252
+ // FIX: Return a single object with NameValueList array inside.
253
+ return {
254
+ NameValueList: specifics.map((spec) => ({
255
+ Name: spec.name,
256
+ Value: Array.isArray(spec.value) ? spec.value : [spec.value],
257
+ })),
258
+ };
259
+ }
45
260
  async execute(callName, params) {
46
261
  const token = await this.restClient.getOAuthClient().getAccessToken();
47
262
  const requestTag = `${callName}Request`;
48
263
  const responseTag = `${callName}Response`;
264
+ // Transform Item field for proper XML serialization
265
+ const transformedParams = { ...params };
266
+ if (transformedParams.Item && typeof transformedParams.Item === 'object') {
267
+ transformedParams.Item = this.transformItemForXML(transformedParams.Item);
268
+ }
49
269
  const xmlObj = {};
50
270
  xmlObj[requestTag] = {
51
271
  '@_xmlns': 'urn:ebay:apis:eBLBaseComponents',
52
- ...params,
272
+ ...transformedParams,
53
273
  };
54
274
  const xmlBody = `<?xml version="1.0" encoding="utf-8"?>\n${this.builder.build(xmlObj)}`;
55
275
  apiLogger.debug(`Trading API ${callName}`, { xmlBody });
@@ -31,6 +31,27 @@ console.log(`[auth-store] Active KV backend: ${authStore.backendName}`);
31
31
  function getServerBaseUrl() {
32
32
  return CONFIG.publicBaseUrl || `http://localhost:${CONFIG.port}`;
33
33
  }
34
+ function getExpectedOAuthCallbackUrl(serverUrl = getServerBaseUrl()) {
35
+ return `${serverUrl.replace(/\/+$/, '')}/oauth/callback`;
36
+ }
37
+ function isLocalDevelopmentBaseUrl(baseUrl) {
38
+ if (!baseUrl) {
39
+ return false;
40
+ }
41
+ try {
42
+ const { hostname } = new URL(baseUrl);
43
+ const normalizedHost = hostname.toLowerCase();
44
+ return (normalizedHost === 'localhost' ||
45
+ normalizedHost === '127.0.0.1' ||
46
+ normalizedHost === '::1' ||
47
+ normalizedHost === 'ebay-local.test' ||
48
+ normalizedHost.endsWith('.localhost') ||
49
+ normalizedHost.endsWith('.test'));
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
34
55
  function htmlEscape(value) {
35
56
  return value
36
57
  .replace(/&/g, '&')
@@ -749,6 +770,8 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
749
770
  serverLogger.info(`[${prefix || 'root'}/authorize] Redirecting to eBay OAuth`, {
750
771
  state: stateRecord.state,
751
772
  environment,
773
+ ruName: ebayConfig.redirectUri,
774
+ expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
752
775
  });
753
776
  res.redirect(oauthUrl);
754
777
  }
@@ -848,6 +871,13 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
848
871
  }
849
872
  const stateRecord = await authStore.createOAuthState(environment, returnTo);
850
873
  const oauthUrl = getOAuthAuthorizationUrl(ebayConfig.clientId, ebayConfig.redirectUri, environment, getHostedOauthScopes(environment), undefined, stateRecord.state);
874
+ serverLogger.info(`[${prefix || 'root'}/oauth/start] Redirecting to eBay OAuth`, {
875
+ state: stateRecord.state,
876
+ environment,
877
+ ruName: ebayConfig.redirectUri,
878
+ expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
879
+ returnTo,
880
+ });
851
881
  res.redirect(oauthUrl);
852
882
  }
853
883
  catch (error) {
@@ -1066,6 +1096,8 @@ async function handleOAuthCallback(req, res, serverUrl) {
1066
1096
  hasCode: !!code,
1067
1097
  hasState: !!state,
1068
1098
  oauthError,
1099
+ expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
1100
+ actualCallbackUrl: `${serverUrl}${req.originalUrl}`,
1069
1101
  });
1070
1102
  if (oauthError) {
1071
1103
  res
@@ -1256,11 +1288,29 @@ async function main() {
1256
1288
  const app = createApp();
1257
1289
  const certPath = process.env.EBAY_LOCAL_TLS_CERT_PATH;
1258
1290
  const keyPath = process.env.EBAY_LOCAL_TLS_KEY_PATH;
1259
- const useHttps = CONFIG.publicBaseUrl.startsWith('https://') && !!certPath && !!keyPath;
1291
+ const localTlsEligible = isLocalDevelopmentBaseUrl(CONFIG.publicBaseUrl);
1292
+ const useHttps = CONFIG.publicBaseUrl.startsWith('https://') && localTlsEligible && !!certPath && !!keyPath;
1260
1293
  const serverUrl = getServerBaseUrl();
1294
+ serverLogger.info('[startup] Public OAuth URL diagnostics', {
1295
+ publicBaseUrl: CONFIG.publicBaseUrl || '(unset)',
1296
+ serverUrl,
1297
+ expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
1298
+ localTlsEligible,
1299
+ hasLocalTlsCertPath: !!certPath,
1300
+ hasLocalTlsKeyPath: !!keyPath,
1301
+ useHttps,
1302
+ });
1303
+ if (CONFIG.publicBaseUrl.startsWith('https://') && !localTlsEligible && (certPath || keyPath)) {
1304
+ serverLogger.warn('[startup] Ignoring local TLS certificate settings for hosted base URL', {
1305
+ publicBaseUrl: CONFIG.publicBaseUrl,
1306
+ hasLocalTlsCertPath: !!certPath,
1307
+ hasLocalTlsKeyPath: !!keyPath,
1308
+ });
1309
+ }
1261
1310
  const onListening = () => {
1262
1311
  const protocol = useHttps ? 'HTTPS' : 'HTTP';
1263
1312
  console.log(`Server running at ${serverUrl} [${protocol}]`);
1313
+ console.log(`OAuth callback: ${getExpectedOAuthCallbackUrl(serverUrl)}`);
1264
1314
  console.log(`MCP (default): ${serverUrl}/mcp`);
1265
1315
  console.log(`MCP (sandbox): ${serverUrl}/sandbox/mcp`);
1266
1316
  console.log(`MCP (production): ${serverUrl}/production/mcp`);
@@ -1,4 +1,88 @@
1
1
  import { z } from 'zod';
2
+ // Schema for Trading API price fields that include currency
3
+ const PriceWithCurrencySchema = z.union([
4
+ z.string(),
5
+ z.object({
6
+ value: z.string().or(z.number()).describe('Price value'),
7
+ currencyID: z.string().optional().describe('Currency code, e.g., USD, EUR, GBP'),
8
+ }),
9
+ ]);
10
+ // Schema for Trading API item specifics (NameValueList)
11
+ const ItemSpecificsSchema = z.array(z.object({
12
+ name: z.string().describe('Specific name, e.g., Brand, Colour, Size'),
13
+ value: z.union([z.string(), z.array(z.string())]).describe('Specific value(s)'),
14
+ }));
15
+ // Schema for Trading API item object
16
+ const TradingItemSchema = z.object({
17
+ // Required fields
18
+ Title: z.string().describe('Item title (max 80 characters)'),
19
+ PrimaryCategory: z
20
+ .object({
21
+ CategoryID: z.string().describe('eBay category ID, e.g., "15032"'),
22
+ })
23
+ .describe('Primary category for the listing'),
24
+ StartPrice: PriceWithCurrencySchema.describe('Starting price or fixed price'),
25
+ ConditionID: z.union([z.number(), z.string()]).describe('eBay condition ID, e.g., 1000 for New'),
26
+ Country: z.string().describe('ISO country code, e.g., US, GB, DE'),
27
+ Currency: z.string().optional().describe('Currency code, e.g., USD, EUR, GBP'),
28
+ DispatchTimeMax: z.union([z.number(), z.string()]).describe('Max dispatch time in days'),
29
+ ListingDuration: z
30
+ .string()
31
+ .describe('Listing duration — use Days_30 (recommended), Days_7, Days_10, Days_14, Days_21, Days_30, Days_60, Days_90. GMS/GN may not be available for all accounts.'),
32
+ ListingType: z.literal('FixedPriceItem').describe('Must be "FixedPriceItem"'),
33
+ Quantity: z.union([z.number(), z.string()]).describe('Number of items available'),
34
+ SKU: z.string().describe('Stock keeping unit / inventory identifier'),
35
+ // Optional fields
36
+ Subtitle: z.string().optional().describe('Item subtitle (max 35 characters)'),
37
+ Description: z.string().optional().describe('HTML description of the item'),
38
+ ItemSpecifics: ItemSpecificsSchema.optional().describe('Product specifics/attributes'),
39
+ PicturesDetails: z
40
+ .object({
41
+ GalleryType: z.string().optional().describe('Gallery type, e.g., Plus, Summary'),
42
+ PictureURL: z.array(z.string()).describe('Array of image URLs'),
43
+ })
44
+ .optional()
45
+ .describe('Picture details for the listing'),
46
+ ShippingDetails: z
47
+ .object({
48
+ ShippingServiceOptions: z
49
+ .object({
50
+ ShippingServicePriority: z.string().optional().describe('Shipping priority (1-9)'),
51
+ ShippingServiceID: z.string().describe('eBay shipping service ID'),
52
+ ShippingServiceCost: PriceWithCurrencySchema.describe('Shipping cost'),
53
+ ShippingType: z.string().describe('Shipping type: Flat, FlatPlusHandling, or Calculated'),
54
+ })
55
+ .describe('Domestic shipping service options'),
56
+ InternationalShippingServiceOption: z
57
+ .array(z.object({
58
+ ShippingServicePriority: z.string().optional().describe('Shipping priority (1-9)'),
59
+ ShippingServiceID: z.string().describe('eBay shipping service ID'),
60
+ ShippingServiceCost: PriceWithCurrencySchema.describe('Shipping cost'),
61
+ ShippingType: z.string().describe('Shipping type'),
62
+ Country: z.array(z.string()).describe('Array of destination country codes'),
63
+ }))
64
+ .optional()
65
+ .describe('International shipping service options'),
66
+ HandlingTime: z.union([z.number(), z.string()]).optional().describe('Handling time in days'),
67
+ })
68
+ .optional()
69
+ .describe('Shipping details for the listing'),
70
+ ReturnPolicy: z
71
+ .object({
72
+ ReturnsAcceptedOption: z
73
+ .string()
74
+ .describe('Returns accepted: ReturnsAccepted or ReturnsNotAccepted'),
75
+ ReturnsWithinOption: z
76
+ .string()
77
+ .describe('Return window: Days_30, Days_60, Days_90 (with underscore)'),
78
+ Description: z.string().describe('Return policy description'),
79
+ RefundOption: z.string().optional().describe('Refund type: MoneyBack or Replacement'),
80
+ })
81
+ .optional()
82
+ .describe('Return policy for the listing'),
83
+ PaymentMethods: z.array(z.string()).optional().describe('Accepted payment methods'),
84
+ ProxyItem: z.boolean().optional().describe('Whether this is a proxy item'),
85
+ });
2
86
  export const tradingTools = [
3
87
  {
4
88
  name: 'ebay_get_active_listings',
@@ -19,11 +103,9 @@ export const tradingTools = [
19
103
  },
20
104
  {
21
105
  name: 'ebay_create_listing',
22
- description: 'Create a new fixed-price listing.\n\nUses the Trading API (AddFixedPriceItem). Requires complete item details.\n\nRequired: User OAuth token.',
106
+ description: 'Create a new fixed-price listing.\\n\\nUses the Trading API (AddFixedPriceItem). Requires complete item details.\\n\\nIMPORTANT: Photos are REQUIRED and MUST be hosted on eBay servers. External URLs (even HTTPS) are rejected. To get eBay-hosted photo URLs:\\n1. Upload photos via eBay Seller Hub UI\\n2. Use the eBay Image Hosting API (if enabled for your app)\\n3. Reference existing eBay-hosted URLs from previous listings\\n\\nRequired: User OAuth token.',
23
107
  inputSchema: {
24
- item: z
25
- .record(z.string(), z.unknown())
26
- .describe('Item details object. Required fields: Title, PrimaryCategory.CategoryID, StartPrice, ConditionID, Country, Currency, DispatchTimeMax, ListingDuration, ListingType ("FixedPriceItem"), Quantity, SKU.'),
108
+ item: TradingItemSchema.describe('Trading API item object with eBay-specific field structure.'),
27
109
  },
28
110
  annotations: { readOnlyHint: false },
29
111
  },
@@ -1,3 +1,4 @@
1
+ import { computeWeightedValidationScore, shouldApplyAgeAwareCalibration, } from './threshold-calibration.js';
1
2
  function addHours(timestamp, hours) {
2
3
  return new Date(new Date(timestamp).getTime() + hours * 60 * 60 * 1000).toISOString();
3
4
  }
@@ -105,6 +106,14 @@ export function buildValidationRecommendation(request, signals) {
105
106
  : signals.research.previousAlbumTitle !== null ||
106
107
  signals.research.previousComebackFirstWeekSales !== null ||
107
108
  signals.research.perplexityHistoricalContextScore > 0;
109
+ // ── Age-aware weighted scoring (threshold calibration) ─────────────────
110
+ // Computes a weighted composite score that adjusts momentum/velocity emphasis
111
+ // based on how many days the item has been tracked. New items (< 7 days)
112
+ // get 70% momentum weight (since velocity data is sparse), while established
113
+ // items get 70% velocity weight (since sales history is reliable).
114
+ const weightedScore = shouldApplyAgeAwareCalibration(request)
115
+ ? computeWeightedValidationScore(request, signals.momentumScore)
116
+ : null;
108
117
  let latestAiRecommendation = 'Continue watching until stronger market signal appears.';
109
118
  let latestAiConfidence = 'Medium';
110
119
  let monitoringNotes = `Baseline recommendation generated from current ${signals.effectiveContext.mode} validation state for ${subjectLabel}.`;
@@ -172,6 +181,16 @@ export function buildValidationRecommendation(request, signals) {
172
181
  if (hasUsableHistoricalResearch && signals.research.historicalContextNotes.length > 0) {
173
182
  monitoringNotes += ` Historical context (${signals.research.confidence}, score ${signals.research.perplexityHistoricalContextScore}/20): ${signals.research.historicalContextNotes}`;
174
183
  }
184
+ // Append weighted scoring summary to monitoring notes if available
185
+ if (weightedScore) {
186
+ monitoringNotes +=
187
+ `\nAge-weighted score: ${weightedScore.compositeScore}/100 (${weightedScore.classification}). ` +
188
+ `Item type: ${weightedScore.isNewItem ? 'new (<7 days)' : 'established (>=7 days)'}. ` +
189
+ `Weights: momentum ${Math.round(weightedScore.weights.momentum * 100)}%, ` +
190
+ `velocity ${Math.round(weightedScore.weights.velocity * 100)}%. ` +
191
+ `Effective thresholds — momentum BUY/Watch: ${weightedScore.effectiveThresholds.momentumBuy}/${weightedScore.effectiveThresholds.momentumWatch}, ` +
192
+ `velocity BUY/Watch: ${weightedScore.effectiveThresholds.velocityBuy}/${weightedScore.effectiveThresholds.velocityWatch}.`;
193
+ }
175
194
  return {
176
195
  buyDecision: request.validation.buyDecision,
177
196
  automationStatus: shouldAutoTrack ? 'Watching' : 'Paused',
@@ -181,5 +200,7 @@ export function buildValidationRecommendation(request, signals) {
181
200
  latestAiRecommendation,
182
201
  latestAiConfidence,
183
202
  monitoringNotes,
203
+ // Weighted scoring metadata (used by downstream Airtable formulas)
204
+ weightedValidationScore: weightedScore ?? undefined,
184
205
  };
185
206
  }
@@ -429,6 +429,8 @@ export async function runValidation(api, input) {
429
429
  chart,
430
430
  research,
431
431
  effectiveContext,
432
+ // Artist momentum score from Artist/Group table — passed via currentMetrics
433
+ momentumScore: effectiveRequest.validation.currentMetrics.artistMomentumScore ?? null,
432
434
  });
433
435
  const requestQueryResolution = buildProviderQueryResolutionDebug(effectiveRequest, Boolean(ebay.queryResolution?.queryContextUsed));
434
436
  const mergedSignals = { effectiveContext, ebay, sold, terapeak, social, chart, research };
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Validation Threshold Calibration — Age-Weighted Scoring
3
+ *
4
+ * Problem: Current validation thresholds (75/60/45) treat all items the same,
5
+ * regardless of whether they have eBay sales history or are brand new listings.
6
+ * New items (< 7 days tracked) get penalized because velocity data is missing,
7
+ * even when momentum signals are strong.
8
+ *
9
+ * Solution: Weighted scoring that adjusts momentum vs. velocity emphasis based
10
+ * on item age (days tracked):
11
+ * - New items (< 7 days): 70% momentum + 30% velocity
12
+ * - Established (>= 7 days): 30% momentum + 70% velocity
13
+ * - Smooth transition between 0-14 days
14
+ *
15
+ * Velocity thresholds by tier: 5 (buy) / 10 (watch) / 20 (skip)
16
+ * Momentum thresholds by tier: 75 (buy) / 60 (watch) / 45 (skip)
17
+ */
18
+ // ── Constants ────────────────────────────────────────────────────────────────
19
+ const NEW_ITEM_THRESHOLD_DAYS = 7; // < 7 days = new item
20
+ const WEIGHT_TRANSITION_END = 14; // full transition completes at day 14
21
+ // Momentum thresholds (out of 100)
22
+ const MOMENTUM_THRESHOLDS = {
23
+ buy: 75,
24
+ watch: 60,
25
+ skip: 45,
26
+ };
27
+ // Velocity thresholds (total sold units across day1-day5)
28
+ const VELOCITY_THRESHOLDS = {
29
+ buy: 5,
30
+ watch: 10,
31
+ skip: 20,
32
+ };
33
+ // Default weights for new vs established
34
+ const NEW_ITEM_WEIGHTS = {
35
+ momentum: 0.7,
36
+ velocity: 0.3,
37
+ };
38
+ const ESTABLISHED_ITEM_WEIGHTS = {
39
+ momentum: 0.3,
40
+ velocity: 0.7,
41
+ };
42
+ // ── Core Functions ───────────────────────────────────────────────────────────
43
+ /**
44
+ * Calculate scoring weights based on item age (days tracked).
45
+ *
46
+ * Days 0-6: 70% momentum / 30% velocity (new item weights)
47
+ * Days 7-14: Linear transition from new → established weights
48
+ * Days 14+: 30% momentum / 70% velocity (established item weights)
49
+ */
50
+ export function calculateAgeAwareWeights(daysTracked) {
51
+ if (daysTracked === null || daysTracked < 0) {
52
+ return {
53
+ weights: { ...NEW_ITEM_WEIGHTS },
54
+ isNewItem: true,
55
+ };
56
+ }
57
+ if (daysTracked >= WEIGHT_TRANSITION_END) {
58
+ return {
59
+ weights: { ...ESTABLISHED_ITEM_WEIGHTS },
60
+ isNewItem: false,
61
+ };
62
+ }
63
+ if (daysTracked < NEW_ITEM_THRESHOLD_DAYS) {
64
+ return {
65
+ weights: { ...NEW_ITEM_WEIGHTS },
66
+ isNewItem: true,
67
+ };
68
+ }
69
+ // Linear interpolation between NEW_ITEM_THRESHOLD_DAYS and WEIGHT_TRANSITION_END
70
+ const progress = (daysTracked - NEW_ITEM_THRESHOLD_DAYS) / (WEIGHT_TRANSITION_END - NEW_ITEM_THRESHOLD_DAYS);
71
+ const momentumWeight = NEW_ITEM_WEIGHTS.momentum +
72
+ (ESTABLISHED_ITEM_WEIGHTS.momentum - NEW_ITEM_WEIGHTS.momentum) * progress;
73
+ const velocityWeight = 1 - momentumWeight;
74
+ return {
75
+ weights: {
76
+ momentum: Math.round(momentumWeight * 100) / 100,
77
+ velocity: Math.round(velocityWeight * 100) / 100,
78
+ },
79
+ isNewItem: daysTracked < NEW_ITEM_THRESHOLD_DAYS,
80
+ };
81
+ }
82
+ /**
83
+ * Calculate effective thresholds adjusted by the current weights.
84
+ *
85
+ * When momentum weight is high (new items), the momentum threshold effectively
86
+ * matters more, so we lower the velocity bar proportionally, and vice versa.
87
+ *
88
+ * The formula scales thresholds by the inverse of their weight:
89
+ * effective_momentum_threshold = momentum_threshold * (1 - momentum_weight + momentum_weight * 0.5)
90
+ * effective_velocity_threshold = velocity_threshold * (1 - velocity_weight + velocity_weight * 0.5)
91
+ *
92
+ * This gives ~25% leeway on the dominant signal while keeping the threshold meaningful.
93
+ */
94
+ export function calculateEffectiveThresholds(weights) {
95
+ const momentumScale = 1 - weights.momentum + weights.momentum * 0.5;
96
+ const velocityScale = 1 - weights.velocity + weights.velocity * 0.5;
97
+ return {
98
+ momentumBuy: Math.round(MOMENTUM_THRESHOLDS.buy * momentumScale * 10) / 10,
99
+ momentumWatch: Math.round(MOMENTUM_THRESHOLDS.watch * momentumScale * 10) / 10,
100
+ velocityBuy: Math.round(VELOCITY_THRESHOLDS.buy * velocityScale * 10) / 10,
101
+ velocityWatch: Math.round(VELOCITY_THRESHOLDS.watch * velocityScale * 10) / 10,
102
+ };
103
+ }
104
+ /**
105
+ * Classify a momentum score against effective thresholds.
106
+ */
107
+ function classifyMomentum(score, thresholds) {
108
+ if (score === null)
109
+ return 'SKIP';
110
+ if (score >= thresholds.momentumBuy)
111
+ return 'BUY';
112
+ if (score >= thresholds.momentumWatch)
113
+ return 'WATCH';
114
+ return 'SKIP';
115
+ }
116
+ /**
117
+ * Classify velocity (total sold units) against effective thresholds.
118
+ */
119
+ function classifyVelocity(score, thresholds) {
120
+ if (score === null)
121
+ return 'WATCH'; // No velocity data = neutral, not automatic skip
122
+ if (score >= thresholds.velocityBuy)
123
+ return 'BUY';
124
+ if (score >= thresholds.velocityWatch)
125
+ return 'WATCH';
126
+ return 'SKIP';
127
+ }
128
+ /**
129
+ * Extract momentum score from request validation context.
130
+ * Uses artist momentum score from the request's currentMetrics or item data.
131
+ */
132
+ function extractMomentumScore(request) {
133
+ // Check if currentMetrics has a momentum field (may be added by Airtable formula)
134
+ const _metrics = request.validation.currentMetrics;
135
+ // Check artist-tier or momentum from the request
136
+ // The momentum score comes from Artist/Group table, not directly in currentMetrics
137
+ // It would be available as part of the artistTier context
138
+ return null; // Fallback — populated by caller if available
139
+ }
140
+ /**
141
+ * Calculate total velocity (sold units) from current metrics.
142
+ */
143
+ function extractVelocityScore(request) {
144
+ const m = request.validation.currentMetrics;
145
+ const day1 = m.day1Sold ?? 0;
146
+ const day2 = m.day2Sold ?? 0;
147
+ const day3 = m.day3Sold ?? 0;
148
+ const day4 = m.day4Sold ?? 0;
149
+ const day5 = m.day5Sold ?? 0;
150
+ const total = day1 + day2 + day3 + day4 + day5;
151
+ return total > 0 ? total : null;
152
+ }
153
+ /**
154
+ * Map classification to a 0-100 composite score.
155
+ */
156
+ function classificationToScore(classification) {
157
+ switch (classification) {
158
+ case 'BUY':
159
+ return 80;
160
+ case 'WATCH':
161
+ return 50;
162
+ case 'SKIP':
163
+ return 20;
164
+ }
165
+ }
166
+ /**
167
+ * Main weighted scoring function.
168
+ *
169
+ * Takes the validation request, optional external momentum score, and computes
170
+ * an age-aware weighted composite score with buy/watch/skip classification.
171
+ */
172
+ export function computeWeightedValidationScore(request, momentumScoreOverride) {
173
+ const daysTracked = request.validation.currentMetrics.daysTracked;
174
+ const { weights, isNewItem } = calculateAgeAwareWeights(daysTracked);
175
+ const thresholds = calculateEffectiveThresholds(weights);
176
+ const momentumScore = momentumScoreOverride ?? extractMomentumScore(request);
177
+ const velocityScore = extractVelocityScore(request);
178
+ const momentumClass = classifyMomentum(momentumScore, thresholds);
179
+ const velocityClass = classifyVelocity(velocityScore, thresholds);
180
+ // Composite: weighted combination of component scores
181
+ const momentumComponent = classificationToScore(momentumClass);
182
+ const velocityComponent = classificationToScore(velocityClass);
183
+ const compositeScore = Math.round(momentumComponent * weights.momentum + velocityComponent * weights.velocity);
184
+ // Final classification: most restrictive (unless either is BUY)
185
+ // If either component says BUY, composite is at least WATCH
186
+ // If both say BUY, composite is BUY
187
+ let classification;
188
+ if (momentumClass === 'BUY' && velocityClass === 'BUY') {
189
+ classification = 'BUY';
190
+ }
191
+ else if (momentumClass === 'BUY' || velocityClass === 'BUY') {
192
+ classification = 'WATCH'; // One strong signal + one neutral = watch
193
+ }
194
+ else if (momentumClass === 'SKIP' && velocityClass === 'SKIP') {
195
+ classification = 'SKIP';
196
+ }
197
+ else {
198
+ classification = 'WATCH'; // Mixed signals = watch
199
+ }
200
+ // Override: if composite score is high enough, upgrade to BUY
201
+ if (compositeScore >= 70 && classification === 'WATCH') {
202
+ classification = 'BUY';
203
+ }
204
+ // Build reasoning
205
+ const reasoning = [];
206
+ reasoning.push(`Item age: ${daysTracked !== null ? `${daysTracked} days` : 'unknown'}, ` +
207
+ `type: ${isNewItem ? 'new' : 'established'}`);
208
+ reasoning.push(`Weights: momentum ${Math.round(weights.momentum * 100)}%, ` +
209
+ `velocity ${Math.round(weights.velocity * 100)}%`);
210
+ reasoning.push(`Effective thresholds — momentum BUY/Watch: ${thresholds.momentumBuy}/${thresholds.momentumWatch}, ` +
211
+ `velocity BUY/Watch: ${thresholds.velocityBuy}/${thresholds.velocityWatch}`);
212
+ if (momentumScore !== null) {
213
+ reasoning.push(`Momentum: ${momentumScore}/100 → ${momentumClass}`);
214
+ }
215
+ else {
216
+ reasoning.push('Momentum: no data → treated as SKIP (neutral for new items)');
217
+ }
218
+ if (velocityScore !== null) {
219
+ reasoning.push(`Velocity: ${velocityScore} units sold (day1-day5) → ${velocityClass}`);
220
+ }
221
+ else {
222
+ reasoning.push('Velocity: no sales data → treated as neutral (WATCH)');
223
+ }
224
+ reasoning.push(`Composite score: ${compositeScore}/100 → ${classification}`);
225
+ return {
226
+ compositeScore,
227
+ momentumScore,
228
+ velocityScore,
229
+ classification,
230
+ momentumClassification: momentumClass,
231
+ velocityClassification: velocityClass,
232
+ weights,
233
+ daysTracked,
234
+ isNewItem,
235
+ effectiveThresholds: thresholds,
236
+ reasoning,
237
+ };
238
+ }
239
+ /**
240
+ * Convenience: check if the request qualifies for age-aware calibration.
241
+ */
242
+ export function shouldApplyAgeAwareCalibration(request) {
243
+ // Apply when the item is in a watchable/tracking state
244
+ return (request.validation.autoCheckEnabled === true &&
245
+ request.validation.buyDecision === 'Watching' &&
246
+ request.validation.automationStatus === 'Watching');
247
+ }
248
+ /**
249
+ * Compare old flat scoring vs new age-weighted scoring for analysis.
250
+ */
251
+ export function calibrateAndCompare(itemLabel, daysTracked, momentumScore, velocityScore) {
252
+ // Old scoring: flat thresholds, 50/50 weight
253
+ const oldThresholds = {
254
+ momentumBuy: MOMENTUM_THRESHOLDS.buy,
255
+ momentumWatch: MOMENTUM_THRESHOLDS.watch,
256
+ velocityBuy: VELOCITY_THRESHOLDS.buy,
257
+ velocityWatch: VELOCITY_THRESHOLDS.watch,
258
+ };
259
+ const oldMomentumClass = classifyMomentum(momentumScore, oldThresholds);
260
+ const oldVelocityClass = classifyVelocity(velocityScore, oldThresholds);
261
+ const oldMomentumComp = classificationToScore(oldMomentumClass);
262
+ const oldVelocityComp = classificationToScore(oldVelocityClass);
263
+ const oldCompositeScore = Math.round((oldMomentumComp + oldVelocityComp) / 2);
264
+ let oldClassification;
265
+ if (oldMomentumClass === 'BUY' && oldVelocityClass === 'BUY')
266
+ oldClassification = 'BUY';
267
+ else if (oldMomentumClass === 'BUY' || oldVelocityClass === 'BUY')
268
+ oldClassification = 'WATCH';
269
+ else if (oldMomentumClass === 'SKIP' && oldVelocityClass === 'SKIP')
270
+ oldClassification = 'SKIP';
271
+ else
272
+ oldClassification = 'WATCH';
273
+ if (oldCompositeScore >= 70 && oldClassification === 'WATCH')
274
+ oldClassification = 'BUY';
275
+ // New scoring: age-weighted
276
+ const { weights, isNewItem } = calculateAgeAwareWeights(daysTracked);
277
+ const effectiveThresholds = calculateEffectiveThresholds(weights);
278
+ const newMomentumClass = classifyMomentum(momentumScore, effectiveThresholds);
279
+ const newVelocityClass = classifyVelocity(velocityScore, effectiveThresholds);
280
+ const newMomentumComp = classificationToScore(newMomentumClass);
281
+ const newVelocityComp = classificationToScore(newVelocityClass);
282
+ const newCompositeScore = Math.round(newMomentumComp * weights.momentum + newVelocityComp * weights.velocity);
283
+ let newClassification;
284
+ if (newMomentumClass === 'BUY' && newVelocityClass === 'BUY')
285
+ newClassification = 'BUY';
286
+ else if (newMomentumClass === 'BUY' || newVelocityClass === 'BUY')
287
+ newClassification = 'WATCH';
288
+ else if (newMomentumClass === 'SKIP' && newVelocityClass === 'SKIP')
289
+ newClassification = 'SKIP';
290
+ else
291
+ newClassification = 'WATCH';
292
+ if (newCompositeScore >= 70 && newClassification === 'WATCH')
293
+ newClassification = 'BUY';
294
+ return {
295
+ itemLabel,
296
+ daysTracked,
297
+ momentumScore,
298
+ velocityScore,
299
+ oldMomentumClass,
300
+ oldVelocityClass,
301
+ oldCompositeScore,
302
+ oldClassification,
303
+ newCompositeScore,
304
+ newClassification,
305
+ newWeights: weights,
306
+ isNewItem,
307
+ effectiveThresholds,
308
+ classificationChanged: oldClassification !== newClassification,
309
+ scoreDelta: newCompositeScore - oldCompositeScore,
310
+ };
311
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ebay-mcp-remote-edition",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "description": "Remote + Local MCP server for eBay APIs - provides access to eBay developer functionality through MCP (Model Context Protocol)",
5
5
  "type": "module",
6
6
  "main": "build/index.js",