ebay-mcp-remote-edition 3.3.0 → 3.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.
package/README.md CHANGED
@@ -458,6 +458,7 @@ The hosted backend now includes a deployment-oriented validation pipeline for no
458
458
  Current module layout:
459
459
 
460
460
  - [`src/validation/types.ts`](src/validation/types.ts) — request/response contracts for validation runs, decision payloads, debug payloads, and provider signal types
461
+ - [`src/validation/effective-context.ts`](src/validation/effective-context.ts) — source-aware normalization layer that converts raw request payloads into a first-class effective validation context for item and event runs
461
462
  - [`src/validation/run-validation.ts`](src/validation/run-validation.ts) — orchestration entrypoint that validates input, queries providers, merges signals, and returns writes/decision/debug output
462
463
  - [`src/validation/recommendation.ts`](src/validation/recommendation.ts) — recommendation and automation decision logic
463
464
  - [`src/validation/providers/ebay.ts`](src/validation/providers/ebay.ts) — live eBay browse-market snapshot provider using the server's existing user-scoped eBay API client
@@ -489,8 +490,26 @@ Operationally, validation works like this:
489
490
  3. The route looks up the configured validation runner user ID for that environment.
490
491
  4. The server loads that user's stored refresh-token-backed credentials from the existing hosted auth store.
491
492
  5. The validation orchestrator calls all six provider domains and gathers browse/current-market, sold enrichment, Terapeak/research contract data, social support signals, chart stub output, and previous-comeback research output.
492
- 6. [`runValidation()`](src/validation/run-validation.ts:106) deterministically merges the provider outputs into normalized field writes.
493
- 7. The response returns those writes, a conservative buy/track decision block, and provider debug metadata for downstream systems.
493
+ 6. Before provider execution, [`runValidation()`](src/validation/run-validation.ts) builds a normalized `effectiveContext` so downstream logic consumes a source-aware model (`item` or `event`) instead of relying on empty item placeholders.
494
+ 7. [`runValidation()`](src/validation/run-validation.ts) deterministically merges the provider outputs into normalized field writes.
495
+ 8. The response returns those writes, a conservative buy/track decision block, and provider debug metadata for downstream systems.
496
+
497
+ #### Effective validation context
498
+
499
+ Validation runs now normalize incoming request data into an internal effective context before provider query planning and recommendation logic execute.
500
+
501
+ - **Item-scope runs** normalize to an item-oriented context with the resolved artist, album/item phrase, location, and resolved search query.
502
+ - **Event-scope runs** normalize to an event-oriented context with `searchArtist`, `searchEvent`, `searchItem`, `searchLocation`, timing metadata, and a derived `effectiveSearchQuery` when no direct resolved query is present.
503
+ - Providers and recommendation logic consume that normalized context rather than reasoning about blank `item.recordId` or `item.name` fields.
504
+ - Debug output now exposes `effectiveSourceType`, `effectiveContextMode`, `effectiveSearchQuery`, `hasItem`, and `hasEvent` so operators can confirm whether an event run was normalized correctly.
505
+
506
+ The request schema also now accepts source-aware query-context fields for hosted validation runs:
507
+
508
+ - `resolvedSearchArtist`
509
+ - `resolvedSearchItem`
510
+ - `resolvedSearchEvent`
511
+ - `resolvedSearchLocation`
512
+ - `resolvedSearchQuery`
494
513
 
495
514
  The validation contract is intentionally split between stable route orchestration and swappable providers. That is why the current sold-data source can be replaced later without changing downstream orchestration or the hosted route contract implemented in [`src/validation/run-validation.ts`](src/validation/run-validation.ts).
496
515
 
@@ -595,10 +614,11 @@ Current backend status:
595
614
  - Social support signals are implemented in phase 1.
596
615
  - Chart data remains a stub.
597
616
  - Validation is currently an **admin-operated hosted backend workflow**, not an MCP tool surface.
617
+ - Event-scope validations are now handled as first-class normalized runs instead of as item-shaped requests with null item identity tolerated for compatibility.
598
618
 
599
619
  Provider behavior:
600
620
 
601
- - **Browse/eBay provider:** [`src/validation/providers/ebay.ts`](src/validation/providers/ebay.ts) uses the eBay Browse API plus shared query fallback logic from [`src/validation/providers/query-utils.ts`](src/validation/providers/query-utils.ts). It walks multiple query candidates, records the selected query and tier in debug output, and uses heuristic matching rather than a strict catalog identity join.
621
+ - **Browse/eBay provider:** [`src/validation/providers/ebay.ts`](src/validation/providers/ebay.ts) uses the eBay Browse API plus shared query fallback logic from [`src/validation/providers/query-utils.ts`](src/validation/providers/query-utils.ts). It walks multiple query candidates, records the selected query and tier in debug output, and uses heuristic matching rather than a strict catalog identity join. Event-driven runs now build those fallback queries from normalized event context instead of raw item title assumptions.
602
622
  - **Browse debug semantics:** validation debug now keeps browse candidate generation, selected query/tier, browse-specific sample size, and per-candidate result counts separate from sold-provider result counts so operators can tell whether the browse layer contributed a field, fell back to a weaker query, or returned no usable match.
603
623
  - **Sold provider:** [`src/validation/providers/ebay-sold.ts`](src/validation/providers/ebay-sold.ts) uses a temporary external sold-data source configured by `SOLD_ITEMS_API_URL` and `SOLD_ITEMS_API_KEY`. It uses the same query-fallback strategy as the browse provider and returns sold-price ranges, sample sold items, and recent sold-velocity buckets when available.
604
624
  - **Terapeak / eBay research provider:** [`src/validation/providers/terapeak.ts`](src/validation/providers/terapeak.ts) is currently a stable placeholder contract. It already defines the query/debug shape and output fields used by orchestration, including `previousPobAvgPriceUsd` and `previousPobSellThroughPct`, but live authenticated Terapeak/eBay research retrieval is not implemented yet.
@@ -609,6 +629,7 @@ Provider behavior:
609
629
  Recommendation behavior:
610
630
 
611
631
  - [`src/validation/recommendation.ts`](src/validation/recommendation.ts) now accepts Terapeak and research inputs alongside browse, sold, social, and chart signals.
632
+ - Recommendation generation also consumes the normalized effective context so event runs can carry source-aware monitoring notes and avoid item-only assumptions when no usable item identity exists.
612
633
  - The decisioning remains intentionally conservative: Terapeak and research data can improve monitoring notes and confidence context, but the system still avoids aggressive automatic buy-state changes from partial or proxy signals alone.
613
634
 
614
635
  Known limitations in the current implementation:
@@ -0,0 +1,77 @@
1
+ function sanitizeText(value) {
2
+ if (typeof value !== 'string') {
3
+ return null;
4
+ }
5
+ const trimmed = value.trim();
6
+ return trimmed.length > 0 ? trimmed : null;
7
+ }
8
+ function buildCompactText(...parts) {
9
+ const compact = parts
10
+ .map((part) => sanitizeText(part))
11
+ .filter((part) => part !== null)
12
+ .join(' ')
13
+ .replace(/\s+/g, ' ')
14
+ .trim();
15
+ return compact.length > 0 ? compact : null;
16
+ }
17
+ function getQueryContext(request) {
18
+ return request.validation.queryContext;
19
+ }
20
+ function getFallbackArtist(request) {
21
+ return sanitizeText(request.item.canonicalArtists[0]);
22
+ }
23
+ function getFallbackAlbum(request) {
24
+ return sanitizeText(request.item.relatedAlbums[0]);
25
+ }
26
+ function getFallbackItem(request) {
27
+ return sanitizeText(request.item.name);
28
+ }
29
+ export function buildValidationEffectiveContext(request) {
30
+ const queryContext = getQueryContext(request);
31
+ const sourceType = request.sourceContext?.sourceType ?? 'item';
32
+ const searchArtist = sanitizeText(queryContext?.resolvedSearchArtist) ?? getFallbackArtist(request);
33
+ const searchAlbum = sanitizeText(getFallbackAlbum(request));
34
+ const searchItem = sanitizeText(queryContext?.resolvedSearchItem) ??
35
+ (sourceType === 'item' ? getFallbackItem(request) : null);
36
+ const searchEvent = sanitizeText(queryContext?.resolvedSearchEvent);
37
+ const searchLocation = sanitizeText(queryContext?.resolvedSearchLocation);
38
+ const resolvedSearchQuery = sanitizeText(queryContext?.resolvedSearchQuery);
39
+ const itemRecordId = sanitizeText(request.item.recordId);
40
+ const eventRecordId = sanitizeText(request.sourceContext?.eventRecordId);
41
+ const itemName = getFallbackItem(request);
42
+ const hasItem = sourceType === 'item'
43
+ ? (request.sourceContext?.hasItem ?? itemRecordId !== null) || itemName !== null
44
+ : request.sourceContext?.hasItem === true || itemRecordId !== null;
45
+ const hasEvent = sourceType === 'event'
46
+ ? true
47
+ : request.sourceContext?.hasEvent === true || searchEvent !== null || eventRecordId !== null;
48
+ const effectiveSearchQuery = resolvedSearchQuery ??
49
+ (sourceType === 'event'
50
+ ? buildCompactText(searchArtist, searchEvent, searchItem, searchLocation)
51
+ : buildCompactText(searchArtist, searchAlbum ?? searchItem, searchLocation));
52
+ return {
53
+ sourceType,
54
+ mode: sourceType,
55
+ validationScope: sanitizeText(queryContext?.validationScope),
56
+ queryScope: sanitizeText(queryContext?.queryScope),
57
+ directQueryActive: queryContext?.directQueryActive === true,
58
+ resolvedSearchQuery,
59
+ effectiveSearchQuery,
60
+ searchArtist,
61
+ searchAlbum,
62
+ searchItem,
63
+ searchEvent,
64
+ searchLocation,
65
+ hasItem,
66
+ hasEvent,
67
+ itemRecordId,
68
+ eventRecordId,
69
+ itemName,
70
+ eventDate: sanitizeText(request.item.releaseDate),
71
+ dDay: request.validation.dDay,
72
+ requestTimestamp: request.timestamp,
73
+ };
74
+ }
75
+ export function getValidationEffectiveContext(request) {
76
+ return request.effectiveContext ?? buildValidationEffectiveContext(request);
77
+ }
@@ -1,3 +1,4 @@
1
+ import { getValidationEffectiveContext } from '../effective-context.js';
1
2
  const NOISY_VERSION_PATTERNS = [
2
3
  /\((?:weverse albums?|weverse|digipack|platform|photobook|poca|poca album|kit)\s+ver\.?\)/gi,
3
4
  /\((?:weverse albums?|weverse|digipack|platform|photobook|poca|poca album|kit)\s+version\)/gi,
@@ -107,14 +108,28 @@ function dedupeQueries(candidates) {
107
108
  return result;
108
109
  }
109
110
  function getPrimaryArtist(request) {
110
- return request.item.canonicalArtists[0]?.trim() ?? '';
111
+ return (getValidationEffectiveContext(request).searchArtist ??
112
+ request.item.canonicalArtists[0]?.trim() ??
113
+ '');
111
114
  }
112
115
  export function getPrimaryAlbumPhrase(request) {
113
- const relatedAlbum = sanitizeQueryCandidate(request.item.relatedAlbums[0]?.trim() ?? '');
116
+ const effectiveContext = getValidationEffectiveContext(request);
117
+ if (effectiveContext.sourceType === 'event') {
118
+ const eventPhrase = sanitizeQueryCandidate(effectiveContext.searchEvent ?? '');
119
+ if (eventPhrase && extractSemanticTokens(eventPhrase).length > 0) {
120
+ return eventPhrase;
121
+ }
122
+ const itemPhrase = sanitizeQueryCandidate(effectiveContext.searchItem ?? '');
123
+ if (itemPhrase && extractSemanticTokens(itemPhrase).length > 0) {
124
+ return itemPhrase;
125
+ }
126
+ return sanitizeQueryCandidate(effectiveContext.searchLocation ?? '');
127
+ }
128
+ const relatedAlbum = sanitizeQueryCandidate(effectiveContext.searchAlbum ?? request.item.relatedAlbums[0]?.trim() ?? '');
114
129
  if (relatedAlbum && extractSemanticTokens(relatedAlbum).length > 0) {
115
130
  return relatedAlbum;
116
131
  }
117
- const simplifiedTitle = simplifyItemTitle(request.item.name);
132
+ const simplifiedTitle = simplifyItemTitle(effectiveContext.searchItem ?? request.item.name);
118
133
  const withoutArtist = stripArtistsFromText(simplifiedTitle, request.item.canonicalArtists);
119
134
  const cleanedWithoutArtist = sanitizeQueryCandidate(removeBracketedContent(withoutArtist));
120
135
  if (extractSemanticTokens(cleanedWithoutArtist).length > 0) {
@@ -124,6 +139,13 @@ export function getPrimaryAlbumPhrase(request) {
124
139
  return extractSemanticTokens(cleanedTitle).length > 0 ? cleanedTitle : relatedAlbum;
125
140
  }
126
141
  export function getPrimarySocialAlbumPhrase(request) {
142
+ const effectiveContext = getValidationEffectiveContext(request);
143
+ if (effectiveContext.sourceType === 'event') {
144
+ const eventPhrase = buildConversationAlbumPhrase(buildCompactPhrase(effectiveContext.searchEvent ?? '', effectiveContext.searchItem ?? '', effectiveContext.searchLocation ?? ''));
145
+ if (extractSemanticTokens(eventPhrase).length > 0) {
146
+ return eventPhrase;
147
+ }
148
+ }
127
149
  for (const relatedAlbum of request.item.relatedAlbums) {
128
150
  const sanitizedRelatedAlbum = sanitizeQueryCandidate(relatedAlbum?.trim() ?? '');
129
151
  if (!sanitizedRelatedAlbum) {
@@ -204,6 +226,18 @@ function normalizeDescriptorPhrase(value) {
204
226
  .replace(/\b(?:sealed|new|official)\b/gi, ' '));
205
227
  }
206
228
  function collectDescriptorPhrases(request, options) {
229
+ const effectiveContext = getValidationEffectiveContext(request);
230
+ if (effectiveContext.sourceType === 'event') {
231
+ const eventDescriptors = [
232
+ effectiveContext.searchItem,
233
+ effectiveContext.searchLocation,
234
+ options.includeValidationType ? request.validation.validationType : null,
235
+ ]
236
+ .map((value) => normalizeDescriptorPhrase(value ?? ''))
237
+ .filter(Boolean)
238
+ .filter((value) => !GENERIC_DESCRIPTOR_PATTERN.test(value.toLowerCase()));
239
+ return dedupeQueries(eventDescriptors).slice(0, 3);
240
+ }
207
241
  const rawValues = [
208
242
  ...request.item.variation,
209
243
  ...request.item.itemType,
@@ -263,6 +297,93 @@ export function getQueryContext(request) {
263
297
  export function getResolvedSearchQuery(request) {
264
298
  return sanitizeQueryContextValue(getQueryContext(request)?.resolvedSearchQuery);
265
299
  }
300
+ function getUsableResolvedSearchQuery(request) {
301
+ const resolvedSearchQuery = getResolvedSearchQuery(request);
302
+ return resolvedSearchQuery && !isRejectedResolvedQuery(resolvedSearchQuery)
303
+ ? resolvedSearchQuery
304
+ : null;
305
+ }
306
+ function getNormalizedQueryScope(request) {
307
+ return sanitizeQueryContextValue(getQueryContext(request)?.queryScope)?.toLowerCase() ?? null;
308
+ }
309
+ function resolveDeclaredQueryScope(request) {
310
+ const normalizedQueryScope = getNormalizedQueryScope(request);
311
+ if (!normalizedQueryScope) {
312
+ return 'unknown';
313
+ }
314
+ if (normalizedQueryScope.includes('direct query')) {
315
+ return 'direct_query';
316
+ }
317
+ const hasArtist = normalizedQueryScope.includes('artist');
318
+ const hasItem = normalizedQueryScope.includes('item');
319
+ const hasAlbum = normalizedQueryScope.includes('album');
320
+ const hasEvent = normalizedQueryScope.includes('event');
321
+ const hasLocation = normalizedQueryScope.includes('city') ||
322
+ normalizedQueryScope.includes('country') ||
323
+ normalizedQueryScope.includes('state') ||
324
+ normalizedQueryScope.includes('province') ||
325
+ normalizedQueryScope.includes('location');
326
+ if (hasArtist && hasItem && hasLocation) {
327
+ return 'artist_item_location';
328
+ }
329
+ if (hasArtist && hasLocation) {
330
+ return 'artist_location';
331
+ }
332
+ if (hasArtist && hasEvent) {
333
+ return 'artist_event';
334
+ }
335
+ if (hasArtist && hasAlbum) {
336
+ return 'artist_album';
337
+ }
338
+ if (hasArtist && hasItem) {
339
+ return 'artist_item';
340
+ }
341
+ if (normalizedQueryScope === 'artist only' || normalizedQueryScope === 'artist') {
342
+ return 'artist_only';
343
+ }
344
+ return 'unknown';
345
+ }
346
+ function hasExclusiveDirectQueryOverride(request) {
347
+ const queryContext = getQueryContext(request);
348
+ return (queryContext?.directQueryActive === true && getNormalizedQueryScope(request) === 'direct query');
349
+ }
350
+ function finalizeLooseQueryPlan(candidates) {
351
+ return dedupeQueryPlan(candidates).filter((candidate) => isValidConversationQuery(candidate.query));
352
+ }
353
+ function buildArtistOnlyCommerceFallbackPlan(request) {
354
+ return finalizeLooseQueryPlan([
355
+ {
356
+ family: 'artist_only_fallback',
357
+ query: getPrimaryArtist(request),
358
+ },
359
+ ]);
360
+ }
361
+ function buildArtistOnlySocialFallbackPlan(request) {
362
+ return finalizeLooseQueryPlan([
363
+ {
364
+ family: 'artist_only_fallback',
365
+ query: normalizeSocialSearchPhrase(getPrimaryArtist(request)),
366
+ },
367
+ ]);
368
+ }
369
+ function constrainFallbackPlanForScope(request, fallbackPlan, scopeSpecificFallbackPlan) {
370
+ if (getUsableResolvedSearchQuery(request) === null) {
371
+ return fallbackPlan;
372
+ }
373
+ switch (resolveDeclaredQueryScope(request)) {
374
+ case 'artist_only':
375
+ return scopeSpecificFallbackPlan;
376
+ case 'artist_item':
377
+ case 'artist_album':
378
+ case 'unknown':
379
+ return fallbackPlan;
380
+ case 'artist_event':
381
+ case 'artist_location':
382
+ case 'artist_item_location':
383
+ case 'direct_query':
384
+ return [];
385
+ }
386
+ }
266
387
  export function buildProviderQueryResolutionDebug(request, queryContextUsed) {
267
388
  const queryContext = getQueryContext(request);
268
389
  const resolvedSearchQuery = getResolvedSearchQuery(request);
@@ -275,15 +396,14 @@ export function buildProviderQueryResolutionDebug(request, queryContextUsed) {
275
396
  };
276
397
  }
277
398
  export function prependResolvedQueryCandidate(request, fallbackPlan) {
278
- const resolvedSearchQuery = getResolvedSearchQuery(request);
279
- const usableResolvedQuery = resolvedSearchQuery && !isRejectedResolvedQuery(resolvedSearchQuery)
280
- ? resolvedSearchQuery
281
- : null;
399
+ const usableResolvedQuery = getUsableResolvedSearchQuery(request);
282
400
  const queryPlan = usableResolvedQuery
283
- ? dedupeQueryPlan([
284
- { family: 'resolved_query_context', query: usableResolvedQuery },
285
- ...fallbackPlan,
286
- ])
401
+ ? hasExclusiveDirectQueryOverride(request)
402
+ ? [{ family: 'resolved_query_context', query: usableResolvedQuery }]
403
+ : dedupeQueryPlan([
404
+ { family: 'resolved_query_context', query: usableResolvedQuery },
405
+ ...fallbackPlan,
406
+ ])
287
407
  : fallbackPlan;
288
408
  return {
289
409
  queryPlan,
@@ -308,9 +428,12 @@ function finalizeQueryPlan(candidates, primaryArtist, albumPhrase) {
308
428
  return dedupeQueryPlan(candidates).filter((candidate) => isValidCandidate(candidate.query, primaryArtist, albumPhrase));
309
429
  }
310
430
  function buildCorePhrases(request) {
431
+ const effectiveContext = getValidationEffectiveContext(request);
311
432
  const primaryArtist = getPrimaryArtist(request);
312
433
  const albumPhrase = getPrimaryAlbumPhrase(request);
313
- const simplifiedTitle = sanitizeQueryCandidate(removeBracketedContent(simplifyItemTitle(request.item.name)));
434
+ const simplifiedTitle = effectiveContext.sourceType === 'event'
435
+ ? sanitizeQueryCandidate(buildCompactPhrase(primaryArtist, effectiveContext.searchEvent ?? '', effectiveContext.searchItem ?? '', effectiveContext.searchLocation ?? ''))
436
+ : sanitizeQueryCandidate(removeBracketedContent(simplifyItemTitle(request.item.name)));
314
437
  const artistAlbumPhrase = ensureArtistRetention(buildCompactPhrase(primaryArtist, albumPhrase), primaryArtist);
315
438
  const titleWithArtist = ensureArtistRetention(simplifiedTitle, primaryArtist);
316
439
  return {
@@ -344,7 +467,7 @@ export function buildBrowseQueryPlan(request) {
344
467
  ], primaryArtist, albumPhrase);
345
468
  }
346
469
  export function buildResolvedBrowseQueryPlan(request) {
347
- return prependResolvedQueryCandidate(request, buildBrowseQueryPlan(request));
470
+ return prependResolvedQueryCandidate(request, constrainFallbackPlanForScope(request, buildBrowseQueryPlan(request), buildArtistOnlyCommerceFallbackPlan(request)));
348
471
  }
349
472
  export function buildBrowseQueryCandidates(request) {
350
473
  return buildBrowseQueryPlan(request).map((candidate) => candidate.query);
@@ -372,7 +495,7 @@ export function buildSoldQueryPlan(request) {
372
495
  ], primaryArtist, albumPhrase);
373
496
  }
374
497
  export function buildResolvedSoldQueryPlan(request) {
375
- return prependResolvedQueryCandidate(request, buildSoldQueryPlan(request));
498
+ return prependResolvedQueryCandidate(request, constrainFallbackPlanForScope(request, buildSoldQueryPlan(request), buildArtistOnlyCommerceFallbackPlan(request)));
376
499
  }
377
500
  export function buildSoldQueryCandidates(request) {
378
501
  return buildSoldQueryPlan(request).map((candidate) => candidate.query);
@@ -398,7 +521,7 @@ export function buildTwitterQueryPlan(request) {
398
521
  return finalizeConversationQueryPlan(candidates);
399
522
  }
400
523
  export function buildResolvedTwitterQueryPlan(request) {
401
- return prependResolvedQueryCandidate(request, buildTwitterQueryPlan(request));
524
+ return prependResolvedQueryCandidate(request, constrainFallbackPlanForScope(request, buildTwitterQueryPlan(request), buildArtistOnlySocialFallbackPlan(request)));
402
525
  }
403
526
  export function buildTwitterQueryCandidates(request) {
404
527
  return buildTwitterQueryPlan(request).map((candidate) => candidate.query);
@@ -422,7 +545,7 @@ export function buildYouTubeQueryPlan(request) {
422
545
  : [{ family: 'artist_only_media_fallback', query: compactArtist }]);
423
546
  }
424
547
  export function buildResolvedYouTubeQueryPlan(request) {
425
- return prependResolvedQueryCandidate(request, buildYouTubeQueryPlan(request));
548
+ return prependResolvedQueryCandidate(request, constrainFallbackPlanForScope(request, buildYouTubeQueryPlan(request), buildArtistOnlySocialFallbackPlan(request)));
426
549
  }
427
550
  export function buildYouTubeQueryCandidates(request) {
428
551
  return buildYouTubeQueryPlan(request).map((candidate) => candidate.query);
@@ -441,7 +564,7 @@ export function buildRedditQueryPlan(request) {
441
564
  ], primaryArtist, compactAlbum);
442
565
  }
443
566
  export function buildResolvedRedditQueryPlan(request) {
444
- return prependResolvedQueryCandidate(request, buildRedditQueryPlan(request));
567
+ return prependResolvedQueryCandidate(request, constrainFallbackPlanForScope(request, buildRedditQueryPlan(request), buildArtistOnlySocialFallbackPlan(request)));
445
568
  }
446
569
  export function buildRedditQueryCandidates(request) {
447
570
  return buildRedditQueryPlan(request).map((candidate) => candidate.query);
@@ -450,5 +573,5 @@ export function buildValidationQueryCandidates(request) {
450
573
  return buildSoldQueryCandidates(request);
451
574
  }
452
575
  export function buildResolvedValidationQueryPlan(request) {
453
- return prependResolvedQueryCandidate(request, buildSoldQueryPlan(request));
576
+ return prependResolvedQueryCandidate(request, constrainFallbackPlanForScope(request, buildSoldQueryPlan(request), buildArtistOnlyCommerceFallbackPlan(request)));
454
577
  }
@@ -1,7 +1,9 @@
1
+ import { getValidationEffectiveContext } from '../effective-context.js';
1
2
  export async function getPreviousComebackResearchSignals(request) {
2
3
  await Promise.resolve();
3
4
  const hasPerplexityKey = (process.env.PERPLEXITY_API_KEY ?? '').trim().length > 0;
4
- const primaryAlbum = request.item.relatedAlbums[0] ?? null;
5
+ const effectiveContext = getValidationEffectiveContext(request);
6
+ const primaryAlbum = effectiveContext.searchAlbum ?? effectiveContext.searchEvent ?? effectiveContext.searchItem;
5
7
  return {
6
8
  previousAlbumTitle: null,
7
9
  previousComebackFirstWeekSales: null,
@@ -1,5 +1,6 @@
1
1
  import axios from 'axios';
2
2
  import { buildConversationAlbumPhrase, buildResolvedRedditQueryPlan, buildResolvedTwitterQueryPlan, buildResolvedYouTubeQueryPlan, extractSemanticTokens, getPrimaryAlbumPhrase, getPrimarySocialAlbumPhrase, normalizeWhitespace, } from './query-utils.js';
3
+ import { getValidationEffectiveContext } from '../effective-context.js';
3
4
  const buildResolvedTwitterQueryPlanSafe = buildResolvedTwitterQueryPlan;
4
5
  const buildResolvedYouTubeQueryPlanSafe = buildResolvedYouTubeQueryPlan;
5
6
  const buildResolvedRedditQueryPlanSafe = buildResolvedRedditQueryPlan;
@@ -20,7 +21,9 @@ const YOUTUBE_OFFICIAL_CHANNEL_PATTERN = /\bofficial\b|\btopic\b/;
20
21
  const YOUTUBE_BRANDED_CHANNEL_PATTERN = /entertainment|music|records|labels?|media|studio|vevo/;
21
22
  const YOUTUBE_DEMOTED_CHANNEL_PATTERN = /shop\b|store\b|merch|reseller|resale|unboxing|fan\b|collector|trading|market/;
22
23
  function getPrimaryArtist(request) {
23
- return request.item.canonicalArtists[0]?.trim() ?? '';
24
+ return (getValidationEffectiveContext(request).searchArtist ??
25
+ request.item.canonicalArtists[0]?.trim() ??
26
+ '');
24
27
  }
25
28
  function buildTwitterCountsUrl(query) {
26
29
  return `https://api.x.com/2/tweets/counts/recent?query=${encodeURIComponent(query)}`;
@@ -4,7 +4,7 @@ export async function getTerapeakValidationSignals(_api, request) {
4
4
  const { queryPlan, queryResolution } = buildResolvedValidationQueryPlan(request);
5
5
  const queryCandidates = queryPlan.map((candidate) => candidate.query);
6
6
  const currentQuery = queryCandidates[0] ?? null;
7
- const previousPobQuery = queryCandidates[1] ?? currentQuery;
7
+ const previousPobQuery = queryCandidates[1] ?? null;
8
8
  return {
9
9
  avgWatchersPerListing: null,
10
10
  preOrderListingsCount: null,
@@ -17,10 +17,18 @@ export function buildValidationRecommendation(request, signals) {
17
17
  signals.sold.soldMedianPriceUsd ??
18
18
  signals.ebay.marketPriceUsd;
19
19
  const preorderListingsCount = signals.terapeak.preOrderListingsCount ?? signals.ebay.preOrderListingsCount;
20
- const wholesale = request.item.wholesalePrice;
20
+ const wholesale = signals.effectiveContext.hasItem ? request.item.wholesalePrice : null;
21
21
  const marginRatio = marketPrice !== null && wholesale !== null && wholesale > 0
22
22
  ? (marketPrice - wholesale) / wholesale
23
23
  : null;
24
+ const subjectLabel = signals.effectiveContext.sourceType === 'event'
25
+ ? (signals.effectiveContext.searchEvent ??
26
+ signals.effectiveContext.effectiveSearchQuery ??
27
+ 'event opportunity')
28
+ : (signals.effectiveContext.searchAlbum ??
29
+ signals.effectiveContext.searchItem ??
30
+ request.item.name ??
31
+ 'release');
24
32
  const recentSoldCount = [
25
33
  signals.sold.soldVelocity.day1Sold,
26
34
  signals.sold.soldVelocity.day2Sold,
@@ -28,13 +36,12 @@ export function buildValidationRecommendation(request, signals) {
28
36
  ].reduce((sum, value) => sum + (value ?? 0), 0);
29
37
  let latestAiRecommendation = 'Continue watching until stronger market signal appears.';
30
38
  let latestAiConfidence = 'Medium';
31
- let monitoringNotes = 'Baseline recommendation generated from current validation state.';
39
+ let monitoringNotes = `Baseline recommendation generated from current ${signals.effectiveContext.mode} validation state for ${subjectLabel}.`;
32
40
  if (!shouldAutoTrack) {
33
41
  latestAiRecommendation =
34
42
  'Automatic tracking paused because the validation is no longer in a watchable state.';
35
43
  latestAiConfidence = 'High';
36
- monitoringNotes =
37
- 'Stop conditions were met, so automation will not schedule another validation run.';
44
+ monitoringNotes = `Stop conditions were met, so automation will not schedule another ${signals.effectiveContext.mode} validation run for ${subjectLabel}.`;
38
45
  }
39
46
  else if (marginRatio !== null &&
40
47
  marginRatio >= 1 &&
@@ -7,6 +7,7 @@ import { getChartValidationSignals } from './providers/chart.js';
7
7
  import { getPreviousComebackResearchSignals } from './providers/research.js';
8
8
  import { buildProviderQueryResolutionDebug } from './providers/query-utils.js';
9
9
  import { buildValidationRecommendation } from './recommendation.js';
10
+ import { buildValidationEffectiveContext } from './effective-context.js';
10
11
  function addMinutes(timestamp, minutes) {
11
12
  return new Date(new Date(timestamp).getTime() + minutes * 60 * 1000).toISOString();
12
13
  }
@@ -210,12 +211,17 @@ export async function runValidation(api, input) {
210
211
  };
211
212
  }
212
213
  try {
213
- const ebay = await getEbayValidationSignals(api, request);
214
- const sold = await getEbaySoldValidationSignals(request);
215
- const terapeak = await getTerapeakValidationSignals(api, request);
216
- const social = await getSocialValidationSignals(request);
217
- const chart = getChartValidationSignals(request);
218
- const research = await getPreviousComebackResearchSignals(request);
214
+ const effectiveContext = buildValidationEffectiveContext(request);
215
+ const effectiveRequest = {
216
+ ...request,
217
+ effectiveContext,
218
+ };
219
+ const ebay = await getEbayValidationSignals(api, effectiveRequest);
220
+ const sold = await getEbaySoldValidationSignals(effectiveRequest);
221
+ const terapeak = await getTerapeakValidationSignals(api, effectiveRequest);
222
+ const social = await getSocialValidationSignals(effectiveRequest);
223
+ const chart = getChartValidationSignals(effectiveRequest);
224
+ const research = await getPreviousComebackResearchSignals(effectiveRequest);
219
225
  const mergedAvgWatchers = terapeak.avgWatchersPerListing ?? ebay.avgWatchersPerListing;
220
226
  const mergedPreorderListings = terapeak.preOrderListingsCount ?? ebay.preOrderListingsCount;
221
227
  const marketPriceUsd = terapeak.marketPriceUsd ?? sold.soldMedianPriceUsd ?? ebay.marketPriceUsd;
@@ -229,16 +235,17 @@ export async function runValidation(api, input) {
229
235
  day5Sold: sold.soldVelocity.day5Sold ?? ebay.soldVelocity.day5Sold,
230
236
  daysTracked: sold.soldVelocity.daysTracked ?? ebay.soldVelocity.daysTracked,
231
237
  };
232
- const recommendation = buildValidationRecommendation(request, {
238
+ const recommendation = buildValidationRecommendation(effectiveRequest, {
233
239
  ebay,
234
240
  sold,
235
241
  terapeak,
236
242
  social,
237
243
  chart,
238
244
  research,
245
+ effectiveContext,
239
246
  });
240
- const requestQueryResolution = buildProviderQueryResolutionDebug(request, Boolean(ebay.queryResolution?.queryContextUsed));
241
- const mergedSignals = { ebay, sold, terapeak, social, chart, research };
247
+ const requestQueryResolution = buildProviderQueryResolutionDebug(effectiveRequest, Boolean(ebay.queryResolution?.queryContextUsed));
248
+ const mergedSignals = { effectiveContext, ebay, sold, terapeak, social, chart, research };
242
249
  const socialWrites = {
243
250
  ...(social.twitterTrending !== null ? { twitterTrending: social.twitterTrending } : {}),
244
251
  ...(social.youtubeViews24hMillions !== null
@@ -340,6 +347,13 @@ export async function runValidation(api, input) {
340
347
  nextCheckAt: recommendation.nextCheckAt,
341
348
  },
342
349
  debug: {
350
+ sourceContext: effectiveRequest.sourceContext ?? null,
351
+ effectiveSourceType: effectiveContext.sourceType,
352
+ effectiveContextMode: effectiveContext.mode,
353
+ effectiveSearchQuery: effectiveContext.effectiveSearchQuery,
354
+ hasItem: effectiveContext.hasItem,
355
+ hasEvent: effectiveContext.hasEvent,
356
+ effectiveContext,
343
357
  ebayQuery: ebay.ebayQuery,
344
358
  soldQuery: sold.query,
345
359
  queryCandidates: {
@@ -352,7 +366,7 @@ export async function runValidation(api, input) {
352
366
  omittedOptionalWrites,
353
367
  writeResolution,
354
368
  sourceSet: ['ebay', 'sold', 'terapeak', 'social', 'chart', 'research'],
355
- providers: buildProviderDebug(request, ebay, sold, terapeak, social, chart, research),
369
+ providers: buildProviderDebug(effectiveRequest, ebay, sold, terapeak, social, chart, research),
356
370
  queryContextUsed: requestQueryResolution.queryContextUsed,
357
371
  querySource: requestQueryResolution.querySource,
358
372
  resolvedSearchQuery: requestQueryResolution.resolvedSearchQuery,
@@ -18,29 +18,45 @@ export const validationCurrentMetricsSchema = z.object({
18
18
  daysTracked: z.number().nullable(),
19
19
  });
20
20
  export const validationQueryContextSchema = z.object({
21
+ directQueryActive: z.boolean().nullable().optional(),
22
+ directSearchQuery: z.string().nullable().optional(),
23
+ resolvedSearchArtist: z.string().nullable().optional(),
24
+ resolvedSearchItem: z.string().nullable().optional(),
25
+ resolvedSearchEvent: z.string().nullable().optional(),
26
+ resolvedSearchLocation: z.string().nullable().optional(),
21
27
  resolvedSearchQuery: z.string().nullable().optional(),
22
28
  validationScope: z.string().nullable().optional(),
23
29
  queryScope: z.string().nullable().optional(),
24
30
  });
25
- export const validationRunRequestSchema = z.object({
31
+ export const validationSourceContextSchema = z.object({
32
+ sourceType: z.enum(['item', 'event']).optional(),
33
+ hasItem: z.boolean().optional(),
34
+ hasEvent: z.boolean().optional(),
35
+ itemRecordId: z.string().nullable().optional(),
36
+ eventRecordId: z.string().nullable().optional(),
37
+ });
38
+ const validationItemSchema = z.object({
39
+ recordId: z.string().min(1).nullable(),
40
+ name: z.string(),
41
+ variation: z.array(z.string()),
42
+ itemType: z.array(z.string()),
43
+ releaseType: z.array(z.string()),
44
+ releaseDate: z.string().datetime({ offset: true }).nullable(),
45
+ releasePeriod: z.array(z.string()),
46
+ availability: z.array(z.string()),
47
+ wholesalePrice: z.number().nullable(),
48
+ supplierNames: z.array(z.string()),
49
+ canonicalArtists: z.array(z.string()),
50
+ relatedAlbums: z.array(z.string()),
51
+ });
52
+ export const validationRunRequestSchema = z
53
+ .object({
26
54
  validationId: z.string().min(1),
27
55
  runType: z.enum(['scheduled', 'manual']),
28
56
  cadence: trackingCadenceSchema,
29
57
  timestamp: z.string().datetime({ offset: true }),
30
- item: z.object({
31
- recordId: z.string().min(1),
32
- name: z.string().min(1),
33
- variation: z.array(z.string()),
34
- itemType: z.array(z.string()),
35
- releaseType: z.array(z.string()),
36
- releaseDate: z.string().datetime({ offset: true }).nullable(),
37
- releasePeriod: z.array(z.string()),
38
- availability: z.array(z.string()),
39
- wholesalePrice: z.number().nullable(),
40
- supplierNames: z.array(z.string()),
41
- canonicalArtists: z.array(z.string()),
42
- relatedAlbums: z.array(z.string()),
43
- }),
58
+ sourceContext: validationSourceContextSchema.optional(),
59
+ item: validationItemSchema,
44
60
  validation: z.object({
45
61
  validationType: z.string(),
46
62
  buyDecision: z.string(),
@@ -53,6 +69,28 @@ export const validationRunRequestSchema = z.object({
53
69
  queryContext: validationQueryContextSchema.optional(),
54
70
  currentMetrics: validationCurrentMetricsSchema,
55
71
  }),
72
+ })
73
+ .superRefine((value, ctx) => {
74
+ const sourceType = value.sourceContext?.sourceType ?? 'item';
75
+ const itemRecordId = value.item.recordId?.trim() ?? '';
76
+ const itemName = value.item.name.trim();
77
+ if (sourceType === 'event') {
78
+ return;
79
+ }
80
+ if (itemRecordId.length === 0) {
81
+ ctx.addIssue({
82
+ code: z.ZodIssueCode.custom,
83
+ path: ['item', 'recordId'],
84
+ message: 'Item-driven validations require item.recordId.',
85
+ });
86
+ }
87
+ if (itemName.length === 0) {
88
+ ctx.addIssue({
89
+ code: z.ZodIssueCode.custom,
90
+ path: ['item', 'name'],
91
+ message: 'Item-driven validations require item.name.',
92
+ });
93
+ }
56
94
  });
57
95
  export const validationWritesSchema = z.object({
58
96
  avgWatchersPerListing: z.number().nullable().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ebay-mcp-remote-edition",
3
- "version": "3.3.0",
3
+ "version": "3.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",