ebay-mcp-remote-edition 4.2.0 → 4.3.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 });
@@ -6,6 +6,7 @@ import { MessageApi } from '../api/communication/message.js';
6
6
  import { NegotiationApi } from '../api/communication/negotiation.js';
7
7
  import { NotificationApi } from '../api/communication/notification.js';
8
8
  import { DeveloperApi } from '../api/developer/developer.js';
9
+ import { MediaApi } from '../api/media/media.js';
9
10
  import { InventoryApi } from '../api/listing-management/inventory.js';
10
11
  import { MetadataApi } from '../api/listing-metadata/metadata.js';
11
12
  import { TaxonomyApi } from '../api/listing-metadata/taxonomy.js';
@@ -41,6 +42,7 @@ export class EbaySellerApi {
41
42
  translation;
42
43
  edelivery;
43
44
  developer;
45
+ media;
44
46
  trading;
45
47
  constructor(config, context) {
46
48
  this.client = new EbayApiClient(config, context);
@@ -63,6 +65,7 @@ export class EbaySellerApi {
63
65
  this.translation = new TranslationApi(this.client);
64
66
  this.edelivery = new EDeliveryApi(this.client);
65
67
  this.developer = new DeveloperApi(this.client);
68
+ this.media = new MediaApi(this.client);
66
69
  const tradingClient = new TradingApiClient(this.client);
67
70
  this.trading = new TradingApi(tradingClient);
68
71
  }
@@ -0,0 +1,124 @@
1
+ import { getBaseUrl } from '../../config/environment.js';
2
+ import axios from 'axios';
3
+ /**
4
+ * Commerce Media API (v1_beta) - Upload and manage images via eBay Picture Services
5
+ * Based on: https://developer.ebay.com/api-docs/commerce/media/resources/image/from_url/methods
6
+ */
7
+ export class MediaApi {
8
+ client;
9
+ basePath = '/commerce/media/v1';
10
+ constructor(client) {
11
+ this.client = client;
12
+ }
13
+ async getAccessToken() {
14
+ return await this.client.getOAuthClient().getAccessToken();
15
+ }
16
+ getMediaBaseUrl() {
17
+ const env = this.client.getConfig().environment;
18
+ return getBaseUrl(env);
19
+ }
20
+ /**
21
+ * Upload an image from a public URL to eBay Picture Services.
22
+ *
23
+ * Steps:
24
+ * 1. POST /commerce/media/v1/image/from_url to create the image
25
+ * 2. GET /commerce/media/v1/image/{imageId} to retrieve the hosted URL
26
+ *
27
+ * Supported formats: JPG, GIF, PNG, BMP, TIFF, AVIF, HEIC, WEBP
28
+ * Max file size: 10MB per image
29
+ *
30
+ * @param imageUrl - Public URL of the image to upload
31
+ * @param description - Optional description for the image
32
+ * @returns Object with image ID and eBay-hosted image URL
33
+ */
34
+ async createImageFromUrl(imageUrl, description) {
35
+ if (!imageUrl || typeof imageUrl !== 'string') {
36
+ throw new Error('imageUrl is required and must be a string');
37
+ }
38
+ const token = await this.getAccessToken();
39
+ const baseUrl = this.getMediaBaseUrl();
40
+ try {
41
+ // Step 1: Create image from URL
42
+ const body = { imageUrl };
43
+ if (description) {
44
+ Object.assign(body, { description });
45
+ }
46
+ const createResponse = await axios.post(`${baseUrl}${this.basePath}/image/from_url`, body, {
47
+ headers: {
48
+ Authorization: `Bearer ${token}`,
49
+ 'Content-Type': 'application/json',
50
+ Prefer: 'return=representation',
51
+ },
52
+ timeout: 30000,
53
+ });
54
+ // Extract image ID from response body or Location header
55
+ const responseData = createResponse.data;
56
+ const imageId = typeof responseData.id === 'string'
57
+ ? responseData.id
58
+ : createResponse.headers.location?.split('/').pop();
59
+ if (!imageId) {
60
+ throw new Error('No image ID returned from create endpoint');
61
+ }
62
+ // Step 2: Fetch image details to get the eBay-hosted URL
63
+ return await this.getImage(imageId);
64
+ }
65
+ catch (error) {
66
+ if (axios.isAxiosError(error)) {
67
+ const status = error.response?.status;
68
+ const data = error.response?.data;
69
+ const message = typeof data === 'object' && data !== null && 'errors' in data
70
+ ? data.errors?.[0]?.longMessage ||
71
+ data.errors?.[0]?.message ||
72
+ error.message
73
+ : error.message;
74
+ throw new Error(`Failed to upload image from URL (status ${status}): ${message}`, {
75
+ cause: error,
76
+ });
77
+ }
78
+ throw new Error(`Failed to upload image from URL: ${error instanceof Error ? error.message : 'Unknown error'}`, { cause: error });
79
+ }
80
+ }
81
+ /**
82
+ * Get image details including the eBay-hosted URL.
83
+ *
84
+ * @param imageId - The image ID returned from createImageFromUrl
85
+ * @returns Image details including hosted URL
86
+ */
87
+ async getImage(imageId) {
88
+ if (!imageId || typeof imageId !== 'string') {
89
+ throw new Error('imageId is required and must be a string');
90
+ }
91
+ const token = await this.getAccessToken();
92
+ const baseUrl = this.getMediaBaseUrl();
93
+ try {
94
+ const response = await axios.get(`${baseUrl}${this.basePath}/image/${imageId}`, {
95
+ headers: {
96
+ Authorization: `Bearer ${token}`,
97
+ Accept: 'application/json',
98
+ },
99
+ timeout: 30000,
100
+ });
101
+ const data = response.data;
102
+ return {
103
+ id: data.id,
104
+ imageUrl: data.imageUrl,
105
+ description: typeof data.description === 'string' ? data.description : undefined,
106
+ };
107
+ }
108
+ catch (error) {
109
+ if (axios.isAxiosError(error)) {
110
+ const status = error.response?.status;
111
+ const data = error.response?.data;
112
+ const message = typeof data === 'object' && data !== null && 'errors' in data
113
+ ? data.errors?.[0]?.longMessage ||
114
+ data.errors?.[0]?.message ||
115
+ error.message
116
+ : error.message;
117
+ throw new Error(`Failed to get image details (status ${status}): ${message}`, {
118
+ cause: error,
119
+ });
120
+ }
121
+ throw new Error(`Failed to get image details: ${error instanceof Error ? error.message : 'Unknown error'}`, { cause: error });
122
+ }
123
+ }
124
+ }
@@ -51,7 +51,8 @@ export class TradingApi {
51
51
  return items?.[0] || result;
52
52
  }
53
53
  async createListing(item) {
54
- return await this.client.execute('AddFixedPriceItem', { Item: item });
54
+ const transformed = this.client.transformItemForXML(item);
55
+ return await this.client.execute('AddFixedPriceItem', { Item: transformed });
55
56
  }
56
57
  async reviseListing(itemId, fields) {
57
58
  if (!itemId)
@@ -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,4 @@
1
- import { MarketplaceId } from '../../types/ebay-enums.js';
1
+ import { MarketplaceId, Condition } from '../../types/ebay-enums.js';
2
2
  import { z } from 'zod';
3
3
  import { zodToJsonSchema } from '../../utils/zod-compat.js';
4
4
  import { bulkInventoryItemRequestSchema, bulkMigrateRequestSchema, bulkOfferRequestSchema, bulkPriceQuantityRequestSchema, bulkPublishRequestSchema, inventoryItemGroupSchema, inventoryItemSchema, listingFeesRequestSchema, locationSchema, offerSchema, productCompatibilitySchema, } from '../schemas.js';
@@ -29,10 +29,31 @@ export const inventoryTools = [
29
29
  },
30
30
  {
31
31
  name: 'ebay_create_inventory_item',
32
- description: 'Create or replace an inventory item.\n\nRequired OAuth Scope: sell.inventory\nMinimum Scope: https://api.ebay.com/oauth/api_scope/sell.inventory',
32
+ description: 'Create or replace an inventory item.\\n\\nRequired OAuth Scope: sell.inventory\\nMinimum Scope: https://api.ebay.com/oauth/api_scope/sell.inventory',
33
33
  inputSchema: {
34
34
  sku: z.string().describe('The seller-defined SKU'),
35
- inventoryItem: inventoryItemSchema.describe('Inventory item details'),
35
+ inventoryItem: inventoryItemSchema.describe('Inventory item details (availability, condition, etc.)'),
36
+ // Accept individual inventory fields as top-level fallback for LLMs that don't wrap in inventoryItem
37
+ availability: z
38
+ .any()
39
+ .optional()
40
+ .describe('[Fallback] Availability details if inventoryItem not provided'),
41
+ condition: z
42
+ .nativeEnum(Condition)
43
+ .optional()
44
+ .describe('[Fallback] Item condition if inventoryItem not provided'),
45
+ conditionDescription: z
46
+ .string()
47
+ .optional()
48
+ .describe('[Fallback] Condition description if inventoryItem not provided'),
49
+ product: z
50
+ .any()
51
+ .optional()
52
+ .describe('[Fallback] Product details if inventoryItem not provided'),
53
+ packageWeightAndSize: z
54
+ .any()
55
+ .optional()
56
+ .describe('[Fallback] Package weight/size if inventoryItem not provided'),
36
57
  },
37
58
  outputSchema: zodToJsonSchema(createInventoryItemOutputSchema, {
38
59
  name: 'CreateInventoryItemResponse',
@@ -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
  },
@@ -68,4 +150,15 @@ export const tradingTools = [
68
150
  },
69
151
  annotations: { readOnlyHint: false },
70
152
  },
153
+ {
154
+ name: 'ebay_upload_images',
155
+ description: 'Upload images to eBay Picture Services using the Commerce Media API and get eBay-hosted image URLs for use in listings.\n\nUploads images from public URLs — eBay fetches and hosts them on their servers. The returned image URLs can be used in ebay_create_listing PicturesDetails.PictureURL array.\n\nSupports: JPG, GIF, PNG, BMP, TIFF, AVIF, HEIC, WEBP. Max 10MB per image.\n\nReturns a result for each uploaded image including the eBay-hosted URL and image ID.',
156
+ inputSchema: {
157
+ imageUrls: z
158
+ .array(z.string().describe('Public URL of the image to upload'))
159
+ .describe('Array of image URLs to upload (1 or more)'),
160
+ description: z.string().optional().describe('Optional description for the uploaded images'),
161
+ },
162
+ annotations: { readOnlyHint: false },
163
+ },
71
164
  ];
@@ -8,6 +8,37 @@ import { getAwaitingFeedbackSchema, getFeedbackRatingSummarySchema, getFeedbackS
8
8
  import { bulkUpdateConversationSchema, getConversationSchema, getConversationsSchema, sendMessageSchema, updateConversationSchema, } from '../utils/communication/message.js';
9
9
  import { findEligibleItemsSchema, getOffersToBuyersSchema, sendOfferToInterestedBuyersSchema, } from '../utils/communication/negotiation.js';
10
10
  import { createDestinationSchema, createSubscriptionFilterSchema, createSubscriptionSchema, deleteDestinationSchema, deleteSubscriptionFilterSchema, deleteSubscriptionSchema, disableSubscriptionSchema, enableSubscriptionSchema, getConfigSchema, getDestinationSchema, getPublicKeySchema, getSubscriptionFilterSchema, getSubscriptionSchema, getSubscriptionsSchema, getTopicSchema, getTopicsSchema, testSubscriptionSchema, updateConfigSchema, updateDestinationSchema, updateSubscriptionSchema, } from '../utils/communication/notification.js';
11
+ /**
12
+ * Normalize enum values that LLMs often send in wrong formats
13
+ * (lowercase, plural, hyphenated) to eBay API uppercase snake_case format
14
+ */
15
+ function normalizeEnumValue(value) {
16
+ return value.toUpperCase().replace(/-/g, '_').replace(/S$/, ''); // Remove trailing S (e.g., "DAYS" -> "DAY")
17
+ }
18
+ /**
19
+ * Normalize time duration unit values
20
+ */
21
+ function normalizeTimeUnit(value) {
22
+ const normalized = value.toUpperCase().replace(/-/g, '_');
23
+ const timeUnitMap = {
24
+ DAY: 'DAY',
25
+ DAYS: 'DAY',
26
+ BUSINESS_DAY: 'BUSINESS_DAY',
27
+ BUSINESS_DAYS: 'BUSINESS_DAY',
28
+ CALENDAR_DAY: 'CALENDAR_DAY',
29
+ HOUR: 'HOUR',
30
+ HOURS: 'HOUR',
31
+ MINUTE: 'MINUTE',
32
+ MINUTES: 'MINUTE',
33
+ SECOND: 'SECOND',
34
+ SECONDS: 'SECOND',
35
+ MONTH: 'MONTH',
36
+ MONTHS: 'MONTH',
37
+ YEAR: 'YEAR',
38
+ YEARS: 'YEAR',
39
+ };
40
+ return timeUnitMap[normalized] || normalized;
41
+ }
11
42
  /**
12
43
  * Get all tool definitions for the MCP server
13
44
  */
@@ -414,8 +445,17 @@ export async function executeTool(api, toolName, args) {
414
445
  case 'ebay_get_return_policies':
415
446
  return await api.account.getReturnPolicies(args.marketplaceId);
416
447
  // Fulfillment Policy CRUD
417
- case 'ebay_create_fulfillment_policy':
418
- return await api.account.createFulfillmentPolicy(args.policy);
448
+ case 'ebay_create_fulfillment_policy': {
449
+ // Normalize handlingTime.unit to valid enum values (LLMs often send lowercase/plural)
450
+ const policy = args.policy;
451
+ if (policy?.handlingTime && typeof policy.handlingTime === 'object') {
452
+ const ht = policy.handlingTime;
453
+ if (typeof ht.unit === 'string') {
454
+ ht.unit = normalizeTimeUnit(ht.unit);
455
+ }
456
+ }
457
+ return await api.account.createFulfillmentPolicy(policy);
458
+ }
419
459
  case 'ebay_get_fulfillment_policy':
420
460
  return await api.account.getFulfillmentPolicy(args.fulfillmentPolicyId);
421
461
  case 'ebay_get_fulfillment_policy_by_name':
@@ -495,8 +535,27 @@ export async function executeTool(api, toolName, args) {
495
535
  return await api.inventory.getInventoryItems(args.limit, args.offset);
496
536
  case 'ebay_get_inventory_item':
497
537
  return await api.inventory.getInventoryItem(args.sku);
498
- case 'ebay_create_inventory_item':
499
- return await api.inventory.createOrReplaceInventoryItem(args.sku, args.inventoryItem);
538
+ case 'ebay_create_inventory_item': {
539
+ // Handle both patterns: nested inventoryItem OR flat fields
540
+ let inventoryItemData = args.inventoryItem;
541
+ if (!inventoryItemData || typeof inventoryItemData !== 'object') {
542
+ // Merge fallback fields into inventoryItem
543
+ const fallbackFields = [
544
+ 'availability',
545
+ 'condition',
546
+ 'conditionDescription',
547
+ 'product',
548
+ 'packageWeightAndSize',
549
+ ];
550
+ inventoryItemData = {};
551
+ for (const field of fallbackFields) {
552
+ if (args[field] !== undefined) {
553
+ inventoryItemData[field] = args[field];
554
+ }
555
+ }
556
+ }
557
+ return await api.inventory.createOrReplaceInventoryItem(args.sku, inventoryItemData);
558
+ }
500
559
  case 'ebay_delete_inventory_item':
501
560
  return await api.inventory.deleteInventoryItem(args.sku);
502
561
  // Bulk Operations
@@ -540,10 +599,20 @@ export async function executeTool(api, toolName, args) {
540
599
  return await api.inventory.getOffers(args.sku, args.marketplaceId, args.limit);
541
600
  case 'ebay_get_offer':
542
601
  return await api.inventory.getOffer(args.offerId);
543
- case 'ebay_create_offer':
544
- return await api.inventory.createOffer(args.offer);
545
- case 'ebay_update_offer':
546
- return await api.inventory.updateOffer(args.offerId, args.offer);
602
+ case 'ebay_create_offer': {
603
+ const offer = args.offer;
604
+ if (offer?.format) {
605
+ offer.format = normalizeEnumValue(offer.format);
606
+ }
607
+ return await api.inventory.createOffer(offer);
608
+ }
609
+ case 'ebay_update_offer': {
610
+ const offer = args.offer;
611
+ if (offer?.format) {
612
+ offer.format = normalizeEnumValue(offer.format);
613
+ }
614
+ return await api.inventory.updateOffer(args.offerId, offer);
615
+ }
547
616
  case 'ebay_delete_offer':
548
617
  return await api.inventory.deleteOffer(args.offerId);
549
618
  case 'ebay_publish_offer':
@@ -1235,6 +1304,29 @@ export async function executeTool(api, toolName, args) {
1235
1304
  return await api.trading.endListing(args.itemId, args.reason);
1236
1305
  case 'ebay_relist_item':
1237
1306
  return await api.trading.relistItem(args.itemId, args.modifications);
1307
+ case 'ebay_upload_images': {
1308
+ const imageUrls = args.imageUrls;
1309
+ const description = args.description;
1310
+ const results = [];
1311
+ for (const imageUrl of imageUrls) {
1312
+ try {
1313
+ const result = await api.media.createImageFromUrl(imageUrl, description);
1314
+ results.push({ success: true, id: result.id, imageUrl: result.imageUrl });
1315
+ }
1316
+ catch (e) {
1317
+ results.push({
1318
+ success: false,
1319
+ error: e instanceof Error ? e.message : String(e),
1320
+ sourceUrl: imageUrl,
1321
+ });
1322
+ }
1323
+ }
1324
+ return {
1325
+ uploaded: results.filter((r) => r.success).length,
1326
+ failed: results.filter((r) => !r.success).length,
1327
+ results,
1328
+ };
1329
+ }
1238
1330
  default:
1239
1331
  throw new Error(`Unknown tool: ${toolName}`);
1240
1332
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { TimeDurationUnit, RegionType, ShippingCostType, ShippingOptionType, DepositType, RefundMethod, ReturnMethod, ReturnShippingCostPayer, Condition, LengthUnit, WeightUnit, PricingVisibility, FormatType, LocationType, MerchantLocationStatus, DayOfWeek, ReasonForRefund, FundingModel, MessageReferenceType, FeedbackRating, ReportedItemType, } from '../types/ebay-enums.js';
2
+ import { RegionType, ShippingCostType, ShippingOptionType, DepositType, RefundMethod, ReturnMethod, ReturnShippingCostPayer, Condition, LengthUnit, WeightUnit, PricingVisibility, FormatType, LocationType, MerchantLocationStatus, DayOfWeek, ReasonForRefund, FundingModel, MessageReferenceType, FeedbackRating, ReportedItemType, TimeDurationUnit, } from '../types/ebay-enums.js';
3
3
  /**
4
4
  * Reusable Zod schemas for eBay API tool input validation
5
5
  *
@@ -11,7 +11,9 @@ import { TimeDurationUnit, RegionType, ShippingCostType, ShippingOptionType, Dep
11
11
  // ============================================================================
12
12
  export const timeDurationSchema = z
13
13
  .object({
14
- unit: z.nativeEnum(TimeDurationUnit),
14
+ unit: z
15
+ .nativeEnum(TimeDurationUnit)
16
+ .describe('Time unit: DAY, BUSINESS_DAY, CALENDAR_DAY, HOUR, MINUTE, SECOND, MONTH, YEAR'),
15
17
  value: z.number(),
16
18
  })
17
19
  .passthrough();
@@ -233,7 +235,7 @@ export const offerSchema = z
233
235
  .object({
234
236
  sku: z.string(),
235
237
  marketplaceId: z.string(),
236
- format: z.nativeEnum(FormatType),
238
+ format: z.string().describe('Listing format: FIXED_PRICE (recommended) or AUCTION'),
237
239
  availableQuantity: z.number().optional(),
238
240
  categoryId: z.string().optional(),
239
241
  listingDescription: z.string().optional(),
@@ -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.3.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",
@@ -69,21 +69,21 @@
69
69
  "dependencies": {
70
70
  "@modelcontextprotocol/sdk": "1.29.0",
71
71
  "@upstash/redis": "^1.37.0",
72
- "axios": "^1.14.0",
72
+ "axios": "^1.16.0",
73
73
  "chalk": "^5.6.2",
74
74
  "cors": "^2.8.6",
75
75
  "dotenv-stringify": "^3.0.1",
76
76
  "express": "^5.2.1",
77
- "fast-xml-parser": "^5.5.10",
77
+ "fast-xml-parser": "^5.7.2",
78
78
  "flatted": "^3.4.2",
79
79
  "helmet": "^8.1.0",
80
- "jose": "^6.2.2",
80
+ "jose": "^6.2.3",
81
81
  "jsonwebtoken": "^9.0.3",
82
82
  "playwright-core": "^1.59.1",
83
83
  "prompts": "^2.4.2",
84
84
  "update-notifier": "^7.3.1",
85
85
  "winston": "^3.19.0",
86
- "zod": "^4.3.6",
86
+ "zod": "^4.4.3",
87
87
  "zod-to-json-schema": "^3.25.2"
88
88
  },
89
89
  "devDependencies": {
@@ -91,27 +91,27 @@
91
91
  "@types/cors": "^2.8.19",
92
92
  "@types/express": "^5.0.6",
93
93
  "@types/jsonwebtoken": "^9.0.10",
94
- "@types/node": "^25.5.2",
94
+ "@types/node": "^25.6.0",
95
95
  "@types/prompts": "^2.4.9",
96
96
  "@types/supertest": "^7.2.0",
97
97
  "@types/update-notifier": "^6.0.8",
98
- "@vitest/coverage-v8": "^4.1.2",
99
- "@vitest/eslint-plugin": "^1.6.14",
100
- "@vitest/ui": "^4.1.2",
101
- "eslint": "^10.2.0",
98
+ "@vitest/coverage-v8": "^4.1.5",
99
+ "@vitest/eslint-plugin": "^1.6.16",
100
+ "@vitest/ui": "^4.1.5",
101
+ "eslint": "^10.3.0",
102
102
  "eslint-config-prettier": "^10.1.8",
103
103
  "eslint-plugin-n": "^17.24.0",
104
- "nock": "^14.0.12",
104
+ "nock": "^14.0.14",
105
105
  "openapi-typescript": "^7.13.0",
106
- "prettier": "^3.8.1",
106
+ "prettier": "^3.8.3",
107
107
  "supertest": "^7.2.2",
108
- "tsc-alias": "^1.8.16",
108
+ "tsc-alias": "^1.8.17",
109
109
  "tsx": "^4.21.0",
110
110
  "typescript": "^5.9.3",
111
- "typescript-eslint": "^8.58.0",
112
- "vitest": "^4.1.2"
111
+ "typescript-eslint": "^8.59.1",
112
+ "vitest": "^4.1.5"
113
113
  },
114
- "packageManager": "pnpm@10.33.0",
114
+ "packageManager": "pnpm@10.33.2",
115
115
  "engines": {
116
116
  "node": ">=18.0.0"
117
117
  },