ebay-mcp-remote-edition 4.3.1 → 4.5.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.
@@ -0,0 +1,43 @@
1
+ import { getBaseUrl } from '../../config/environment.js';
2
+ /**
3
+ * Browse API - Search and browse eBay product catalog
4
+ * Based on: eBay Buy Browse API
5
+ * Base endpoint: /buy/browse/v1
6
+ */
7
+ export class BrowseApi {
8
+ client;
9
+ constructor(client) {
10
+ this.client = client;
11
+ }
12
+ /**
13
+ * Search the eBay product catalog by query.
14
+ * Supports category filtering, sorting, and price filtering.
15
+ */
16
+ async searchProducts(query, options) {
17
+ const environment = this.client.getConfig().environment;
18
+ const browseUrl = new URL('/buy/browse/v1/item_summary/search', getBaseUrl(environment)).toString();
19
+ const params = { q: query };
20
+ if (options?.categoryId)
21
+ params.category_ids = options.categoryId;
22
+ if (options?.limit)
23
+ params.limit = options.limit;
24
+ if (options?.sort)
25
+ params.sort = options.sort;
26
+ if (options?.filter)
27
+ params.filter = options.filter;
28
+ if (options?.aspectFilter)
29
+ params.aspect_filter = options.aspectFilter;
30
+ return await this.client.getWithFullUrl(browseUrl, params);
31
+ }
32
+ /**
33
+ * Get listing/product suggestions based on search query.
34
+ * Returns relevant item summaries with pricing and shipping info.
35
+ */
36
+ async getSuggestions(query, options) {
37
+ return await this.searchProducts(query, {
38
+ marketplaceId: options?.marketplaceId,
39
+ limit: options?.limit ?? 20,
40
+ sort: 'bestMatch',
41
+ });
42
+ }
43
+ }
@@ -49,7 +49,7 @@ export class TradingApiClient {
49
49
  * Handles nested objects like PrimaryCategory, ShippingDetails, ReturnPolicy,
50
50
  * PicturesDetails, and ItemSpecifics that require special XML structure.
51
51
  */
52
- transformItemForXML(item) {
52
+ transformItemForXML(item, options = { ensureCountry: true }) {
53
53
  const transformed = {};
54
54
  for (const [key, value] of Object.entries(item)) {
55
55
  if (value === undefined || value === null)
@@ -117,6 +117,13 @@ export class TradingApiClient {
117
117
  transformed.Currency = 'USD';
118
118
  }
119
119
  }
120
+ // Ensure Country is present for full listing creation/publish transforms.
121
+ // ReviseFixedPriceItem may update only a subset of fields; injecting a default Country
122
+ // can accidentally attempt to change an active listing's country and eBay rejects it as
123
+ // "Input data is invalid." Skip this default for partial revise payloads.
124
+ if (options.ensureCountry !== false && !transformed.Country) {
125
+ transformed.Country = 'USA';
126
+ }
120
127
  return transformed;
121
128
  }
122
129
  /**
@@ -124,13 +131,14 @@ export class TradingApiClient {
124
131
  * eBay Trading API expects: <StartPrice currencyID="USD">34.99</StartPrice>
125
132
  */
126
133
  transformPrice(price) {
127
- if (typeof price === 'string') {
128
- return price;
134
+ if (typeof price === 'string' || typeof price === 'number') {
135
+ return String(price);
129
136
  }
137
+ const currencyID = price.currencyID ?? price.currency;
130
138
  // Use fast-xml-parser attribute convention: @_<attrName> for attributes, #text for content
131
139
  return {
132
140
  '#text': String(price.value),
133
- ...(price.currencyID && { '@_currencyID': price.currencyID }),
141
+ ...(currencyID && { '@_currencyID': currencyID }),
134
142
  };
135
143
  }
136
144
  /**
@@ -264,7 +272,7 @@ export class TradingApiClient {
264
272
  // Transform Item field for proper XML serialization
265
273
  const transformedParams = { ...params };
266
274
  if (transformedParams.Item && typeof transformedParams.Item === 'object') {
267
- transformedParams.Item = this.transformItemForXML(transformedParams.Item);
275
+ transformedParams.Item = this.transformItemForXML(transformedParams.Item, { ensureCountry: callName !== 'ReviseFixedPriceItem' });
268
276
  }
269
277
  const xmlObj = {};
270
278
  xmlObj[requestTag] = {
@@ -19,6 +19,7 @@ import { EDeliveryApi } from '../api/other/edelivery.js';
19
19
  import { IdentityApi } from '../api/other/identity.js';
20
20
  import { TranslationApi } from '../api/other/translation.js';
21
21
  import { VeroApi } from '../api/other/vero.js';
22
+ import { BrowseApi } from '../api/browse/browse.js';
22
23
  import { TradingApiClient } from '../api/client-trading.js';
23
24
  import { TradingApi } from '../api/trading/trading.js';
24
25
  export class EbaySellerApi {
@@ -44,6 +45,7 @@ export class EbaySellerApi {
44
45
  developer;
45
46
  media;
46
47
  trading;
48
+ browse;
47
49
  constructor(config, context) {
48
50
  this.client = new EbayApiClient(config, context);
49
51
  this.account = new AccountApi(this.client);
@@ -68,6 +70,7 @@ export class EbaySellerApi {
68
70
  this.media = new MediaApi(this.client);
69
71
  const tradingClient = new TradingApiClient(this.client);
70
72
  this.trading = new TradingApi(tradingClient);
73
+ this.browse = new BrowseApi(this.client);
71
74
  }
72
75
  async initialize() {
73
76
  await this.client.initialize();
@@ -110,3 +113,4 @@ export * from '../api/other/vero.js';
110
113
  export * from '../api/developer/developer.js';
111
114
  export * from '../api/trading/trading.js';
112
115
  export * from '../api/client-trading.js';
116
+ export * from '../api/browse/browse.js';
@@ -360,17 +360,15 @@ export class InventoryApi {
360
360
  }
361
361
  }
362
362
  /**
363
- * Get all offers
363
+ * Get offers for a specific SKU (sku is required)
364
364
  * @throws Error if parameters are invalid
365
365
  */
366
366
  async getOffers(sku, marketplaceId, limit) {
367
367
  const params = {};
368
- if (sku !== undefined) {
369
- if (typeof sku !== 'string') {
370
- throw new Error('sku must be a string when provided');
371
- }
372
- params.sku = sku;
368
+ if (!sku || typeof sku !== 'string') {
369
+ throw new Error('sku is required for getOffers');
373
370
  }
371
+ params.sku = sku;
374
372
  if (marketplaceId !== undefined) {
375
373
  if (typeof marketplaceId !== 'string') {
376
374
  throw new Error('marketplaceId must be a string when provided');
@@ -1,12 +1,13 @@
1
- import { getBaseUrl } from '../../config/environment.js';
1
+ import { processImageForUpload, } from '../../utils/image-processor.js';
2
2
  import axios from 'axios';
3
+ import * as fs from 'fs';
3
4
  /**
4
5
  * Commerce Media API (v1_beta) - Upload and manage images via eBay Picture Services
5
6
  * Based on: https://developer.ebay.com/api-docs/commerce/media/resources/image/from_url/methods
6
7
  */
7
8
  export class MediaApi {
8
9
  client;
9
- basePath = '/commerce/media/v1';
10
+ basePath = '/commerce/media/v1_beta';
10
11
  constructor(client) {
11
12
  this.client = client;
12
13
  }
@@ -15,16 +16,18 @@ export class MediaApi {
15
16
  }
16
17
  getMediaBaseUrl() {
17
18
  const env = this.client.getConfig().environment;
18
- return getBaseUrl(env);
19
+ return env === 'production' ? 'https://apim.ebay.com' : 'https://apim.sandbox.ebay.com';
19
20
  }
20
21
  /**
21
22
  * Upload an image from a public URL to eBay Picture Services.
22
23
  *
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
24
+ * Primary flow: Pass URL directly to eBay's createImageFromUrl endpoint
25
+ * (eBay downloads and processes server-side no local processing needed).
26
26
  *
27
- * Supported formats: JPG, GIF, PNG, BMP, TIFF, AVIF, HEIC, WEBP
27
+ * Fallback: If eBay rejects the image (e.g., too small for 500px minimum),
28
+ * download → enlarge with Sharp → upload via createImageFromFile.
29
+ *
30
+ * Supported source formats: JPG, GIF, PNG, BMP, TIFF, AVIF, HEIC, WEBP
28
31
  * Max file size: 10MB per image
29
32
  *
30
33
  * @param imageUrl - Public URL of the image to upload
@@ -38,20 +41,20 @@ export class MediaApi {
38
41
  const token = await this.getAccessToken();
39
42
  const baseUrl = this.getMediaBaseUrl();
40
43
  try {
41
- // Step 1: Create image from URL
44
+ // Primary: Pass URL directly to eBay — they handle server-side download
42
45
  const body = { imageUrl };
43
46
  if (description) {
44
- Object.assign(body, { description });
47
+ body.description = description;
45
48
  }
46
- const createResponse = await axios.post(`${baseUrl}${this.basePath}/image/from_url`, body, {
49
+ const createResponse = await axios.post(`${baseUrl}${this.basePath}/image/create_image_from_url`, body, {
47
50
  headers: {
48
51
  Authorization: `Bearer ${token}`,
49
52
  'Content-Type': 'application/json',
53
+ Accept: 'application/json',
50
54
  Prefer: 'return=representation',
51
55
  },
52
56
  timeout: 30000,
53
57
  });
54
- // Extract image ID from response body or Location header
55
58
  const responseData = createResponse.data;
56
59
  const imageId = typeof responseData.id === 'string'
57
60
  ? responseData.id
@@ -59,9 +62,79 @@ export class MediaApi {
59
62
  if (!imageId) {
60
63
  throw new Error('No image ID returned from create endpoint');
61
64
  }
62
- // Step 2: Fetch image details to get the eBay-hosted URL
63
65
  return await this.getImage(imageId);
64
66
  }
67
+ catch (primaryError) {
68
+ // Fallback: if eBay rejects (e.g., image too small), download → Sharp enlarge → upload via file
69
+ if (axios.isAxiosError(primaryError)) {
70
+ const status = primaryError.response?.status;
71
+ // Only fallback on errors that suggest image quality/size issues
72
+ if (status && (status === 400 || status === 500)) {
73
+ try {
74
+ console.log(`[MediaApi] Direct URL upload failed (status ${status}), falling back to Sharp processing for: ${imageUrl}`);
75
+ // Download the image
76
+ const downloadResponse = await axios.get(imageUrl, {
77
+ responseType: 'arraybuffer',
78
+ timeout: 30000,
79
+ maxContentLength: 10 * 1024 * 1024, // 10MB max
80
+ });
81
+ const imageBuffer = Buffer.from(downloadResponse.data);
82
+ // Process with Sharp (enlarge if too small, convert to JPEG)
83
+ const processed = await processImageForUpload(imageBuffer);
84
+ // Upload via file endpoint
85
+ return await this.uploadProcessedImage(processed.buffer, processed.metadata, token, baseUrl, description);
86
+ }
87
+ catch {
88
+ // If fallback also fails, throw the original error (more actionable)
89
+ throw primaryError;
90
+ }
91
+ }
92
+ }
93
+ // Re-throw non-retryable errors as-is
94
+ if (axios.isAxiosError(primaryError)) {
95
+ const status = primaryError.response?.status;
96
+ const data = primaryError.response?.data;
97
+ const message = typeof data === 'object' && data !== null && 'errors' in data
98
+ ? data.errors?.[0]?.longMessage ||
99
+ data.errors?.[0]?.message ||
100
+ primaryError.message
101
+ : primaryError.message;
102
+ throw new Error(`Failed to upload image from URL (status ${status}): ${message}`, {
103
+ cause: primaryError,
104
+ });
105
+ }
106
+ throw new Error(`Failed to upload image from URL: ${primaryError instanceof Error ? primaryError.message : 'Unknown error'}`, { cause: primaryError });
107
+ }
108
+ }
109
+ /**
110
+ * Upload an image from a local file to eBay Picture Services.
111
+ *
112
+ * Endpoint: POST /commerce/media/v1/image/create_image_from_file
113
+ * Content-Type: multipart/form-data
114
+ *
115
+ * Supported formats: JPG, GIF, PNG, BMP, TIFF, AVIF, HEIC, WEBP
116
+ * Max file size: 10MB per image
117
+ *
118
+ * @param filePath - Local file path of the image to upload
119
+ * @param description - Optional description for the image
120
+ * @returns Object with image ID and eBay-hosted image URL
121
+ */
122
+ async createImageFromFile(filePath, description) {
123
+ if (!filePath || typeof filePath !== 'string') {
124
+ throw new Error('filePath is required and must be a string');
125
+ }
126
+ if (!fs.existsSync(filePath)) {
127
+ throw new Error(`File not found: ${filePath}`);
128
+ }
129
+ const token = await this.getAccessToken();
130
+ const baseUrl = this.getMediaBaseUrl();
131
+ try {
132
+ const fileBuffer = fs.readFileSync(filePath);
133
+ // Process image — validate dimensions, enlarge to min 500px if too small,
134
+ // convert to JPEG, and optimize. Uses sharp library.
135
+ const processed = await processImageForUpload(fileBuffer);
136
+ return await this.uploadProcessedImage(processed.buffer, processed.metadata, token, baseUrl, description);
137
+ }
65
138
  catch (error) {
66
139
  if (axios.isAxiosError(error)) {
67
140
  const status = error.response?.status;
@@ -71,13 +144,69 @@ export class MediaApi {
71
144
  data.errors?.[0]?.message ||
72
145
  error.message
73
146
  : error.message;
74
- throw new Error(`Failed to upload image from URL (status ${status}): ${message}`, {
147
+ throw new Error(`Failed to upload image from file (status ${status}): ${message}`, {
75
148
  cause: error,
76
149
  });
77
150
  }
78
- throw new Error(`Failed to upload image from URL: ${error instanceof Error ? error.message : 'Unknown error'}`, { cause: error });
151
+ throw new Error(`Failed to upload image from file: ${error instanceof Error ? error.message : 'Unknown error'}`, { cause: error });
79
152
  }
80
153
  }
154
+ /**
155
+ * Upload a processed image buffer to eBay Picture Services.
156
+ *
157
+ * @param buffer - Processed image buffer
158
+ * @param metadata - Image metadata
159
+ * @param token - OAuth access token
160
+ * @param baseUrl - Media API base URL
161
+ * @param description - Optional description
162
+ * @returns Object with image ID and eBay-hosted image URL
163
+ */
164
+ async uploadProcessedImage(buffer, metadata, token, baseUrl, description) {
165
+ // Build multipart/form-data body correctly:
166
+ // --boundary\r\n
167
+ // Content-Disposition: imageFile\r\n
168
+ // Content-Type: image/jpeg\r\n\r\n
169
+ // [IMAGE BINARY DATA]
170
+ // --boundary\r\n
171
+ // Content-Disposition: description\r\n\r\n
172
+ // [description text]
173
+ // --boundary--\r\n
174
+ const boundary = `----FormBoundary${Date.now()}`;
175
+ const fileName = `image_${Date.now()}.jpg`;
176
+ const parts = [];
177
+ // Image file part — headers + binary data
178
+ const imageHeaders = `--${boundary}\r\n` +
179
+ `Content-Disposition: form-data; name="imageFile"; filename="${fileName}"\r\n` +
180
+ `Content-Type: image/jpeg\r\n\r\n`;
181
+ parts.push(Buffer.from(imageHeaders, 'utf-8'));
182
+ parts.push(buffer);
183
+ // Description part (optional)
184
+ if (description) {
185
+ const descPart = `--${boundary}\r\n` +
186
+ `Content-Disposition: form-data; name="description"\r\n\r\n` +
187
+ `${description}\r\n`;
188
+ parts.push(Buffer.from(descPart, 'utf-8'));
189
+ }
190
+ // Closing boundary
191
+ parts.push(Buffer.from(`--${boundary}--\r\n`, 'utf-8'));
192
+ const multipartBody = Buffer.concat(parts);
193
+ const createResponse = await axios.post(`${baseUrl}${this.basePath}/image/create_image_from_file`, multipartBody, {
194
+ headers: {
195
+ Authorization: `Bearer ${token}`,
196
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
197
+ Prefer: 'return=representation',
198
+ },
199
+ timeout: 30000,
200
+ });
201
+ const responseData = createResponse.data;
202
+ const imageId = typeof responseData.id === 'string'
203
+ ? responseData.id
204
+ : createResponse.headers.location?.split('/').pop();
205
+ if (!imageId) {
206
+ throw new Error('No image ID returned from create endpoint');
207
+ }
208
+ return await this.getImage(imageId);
209
+ }
81
210
  /**
82
211
  * Get image details including the eBay-hosted URL.
83
212
  *
@@ -99,9 +228,15 @@ export class MediaApi {
99
228
  timeout: 30000,
100
229
  });
101
230
  const data = response.data;
231
+ let imageUrl = data.imageUrl;
232
+ // eBay Media API returns $_1.JPG thumbnail URL. Convert to full-size (s-l1600.jpg)
233
+ // which is required for listing images (500px minimum).
234
+ if (imageUrl?.includes('$_1.JPG')) {
235
+ imageUrl = imageUrl.replace('$_1.JPG', 's-l1600.jpg');
236
+ }
102
237
  return {
103
238
  id: data.id,
104
- imageUrl: data.imageUrl,
239
+ imageUrl: imageUrl || '',
105
240
  description: typeof data.description === 'string' ? data.description : undefined,
106
241
  };
107
242
  }
@@ -3,6 +3,14 @@ export class TradingApi {
3
3
  constructor(client) {
4
4
  this.client = client;
5
5
  }
6
+ /**
7
+ * Transform an item for Trading API XML serialization.
8
+ * Ensures required fields like Currency and Country are always present.
9
+ * Used by publish_offer flow to pre-transform inventory items before publishing.
10
+ */
11
+ transformItemForXML(item) {
12
+ return this.client.transformItemForXML(item);
13
+ }
6
14
  async getActiveListings(page = 1, entriesPerPage = 50) {
7
15
  const result = await this.client.execute('GetMyeBaySelling', {
8
16
  ActiveList: {
@@ -46,6 +46,7 @@ export class EbayOAuthClient {
46
46
  clientId: this.config.clientId,
47
47
  clientSecret: this.config.clientSecret,
48
48
  redirectUri: this.config.redirectUri,
49
+ ruName: this.config.ruName,
49
50
  userAccessToken: tokenData.access_token,
50
51
  userRefreshToken: tokenData.refresh_token || envRefreshToken,
51
52
  tokenType: tokenData.token_type,
@@ -99,6 +100,7 @@ export class EbayOAuthClient {
99
100
  clientId: this.config.clientId,
100
101
  clientSecret: this.config.clientSecret,
101
102
  redirectUri: this.config.redirectUri,
103
+ ruName: this.config.ruName,
102
104
  userAccessToken: accessToken,
103
105
  userRefreshToken: refreshToken,
104
106
  tokenType: 'Bearer',
@@ -128,8 +130,9 @@ export class EbayOAuthClient {
128
130
  return this.appAccessToken;
129
131
  }
130
132
  async exchangeCodeForToken(code) {
131
- if (!this.config.redirectUri) {
132
- throw new Error('Redirect URI is required for authorization code exchange');
133
+ const redirectUriForExchange = this.config.ruName || this.config.redirectUri;
134
+ if (!redirectUriForExchange) {
135
+ throw new Error('RuName (EBAY_RUNAME) or redirectUri is required for authorization code exchange');
133
136
  }
134
137
  const tokenUrl = this.getTokenEndpoint();
135
138
  const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
@@ -137,7 +140,7 @@ export class EbayOAuthClient {
137
140
  const response = await axios.post(tokenUrl, new URLSearchParams({
138
141
  grant_type: 'authorization_code',
139
142
  code,
140
- redirect_uri: this.config.redirectUri,
143
+ redirect_uri: redirectUriForExchange,
141
144
  }).toString(), {
142
145
  headers: {
143
146
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -150,6 +153,7 @@ export class EbayOAuthClient {
150
153
  clientId: this.config.clientId,
151
154
  clientSecret: this.config.clientSecret,
152
155
  redirectUri: this.config.redirectUri,
156
+ ruName: this.config.ruName,
153
157
  userAccessToken: tokenData.access_token,
154
158
  userRefreshToken: tokenData.refresh_token,
155
159
  tokenType: tokenData.token_type,
@@ -195,6 +199,7 @@ export class EbayOAuthClient {
195
199
  clientId: this.config.clientId,
196
200
  clientSecret: this.config.clientSecret,
197
201
  redirectUri: this.config.redirectUri,
202
+ ruName: this.config.ruName,
198
203
  userAccessToken: tokenData.access_token,
199
204
  userRefreshToken: tokenData.refresh_token || this.userTokens.userRefreshToken,
200
205
  tokenType: tokenData.token_type,
@@ -215,6 +220,7 @@ export class EbayOAuthClient {
215
220
  hasRefreshToken: !!this.userTokens?.userRefreshToken,
216
221
  hasAccessToken: !!this.userTokens?.userAccessToken,
217
222
  hasRedirectUri: !!this.config.redirectUri,
223
+ hasRuName: !!this.config.ruName,
218
224
  ...(this.userTokens?.userRefreshTokenExpiry
219
225
  ? { refreshTokenExpiry: this.userTokens.userRefreshTokenExpiry }
220
226
  : {}),
@@ -268,19 +268,18 @@ export function getEbayConfig(environmentOverride) {
268
268
  : process.env.EBAY_SANDBOX_CLIENT_SECRET || process.env.EBAY_CLIENT_SECRET || '';
269
269
  // Preferred var is EBAY_RUNAME (clearer naming — it IS the RuName, not a URL).
270
270
  // EBAY_REDIRECT_URI is kept for backward compatibility.
271
+ // RuName is for token exchange; redirectUri is for browser OAuth flow. Separate purposes.
272
+ const fallbackRuName = environment === 'production'
273
+ ? process.env.EBAY_PRODUCTION_RUNAME || process.env.EBAY_RUNAME || ''
274
+ : process.env.EBAY_SANDBOX_RUNAME || process.env.EBAY_RUNAME || '';
271
275
  const fallbackRedirectUri = environment === 'production'
272
- ? process.env.EBAY_PRODUCTION_RUNAME ||
273
- process.env.EBAY_PRODUCTION_REDIRECT_URI ||
274
- process.env.EBAY_RUNAME ||
275
- process.env.EBAY_REDIRECT_URI
276
- : process.env.EBAY_SANDBOX_RUNAME ||
277
- process.env.EBAY_SANDBOX_REDIRECT_URI ||
278
- process.env.EBAY_RUNAME ||
279
- process.env.EBAY_REDIRECT_URI;
276
+ ? process.env.EBAY_PRODUCTION_REDIRECT_URI || process.env.EBAY_REDIRECT_URI || ''
277
+ : process.env.EBAY_SANDBOX_REDIRECT_URI || process.env.EBAY_REDIRECT_URI || '';
280
278
  return {
281
279
  clientId: secretConfig?.clientId || fallbackClientId,
282
280
  clientSecret: secretConfig?.clientSecret || fallbackClientSecret,
283
- redirectUri: secretConfig?.redirectUri || fallbackRedirectUri,
281
+ redirectUri: secretConfig?.redirectUri || fallbackRedirectUri || undefined,
282
+ ruName: secretConfig?.ruName || fallbackRuName || undefined,
284
283
  marketplaceId: (process.env.EBAY_MARKETPLACE_ID ?? '').trim() || 'EBAY_US',
285
284
  contentLanguage: (process.env.EBAY_CONTENT_LANGUAGE ?? '').trim() || 'en-US',
286
285
  environment,
package/build/index.js CHANGED
File without changes
@@ -32,7 +32,25 @@ function buildMarkdown(items) {
32
32
  async function main() {
33
33
  const { items, error } = await getApiStatusFeed({ limit: DEFAULT_LIMIT });
34
34
  if (error && items.length === 0) {
35
- throw new Error(`Failed to fetch API status feed: ${error}`);
35
+ // Feed is temporarily unavailable write a fallback doc instead of failing
36
+ const fallback = [
37
+ '# eBay API Status (cached)',
38
+ '',
39
+ 'The eBay API Status RSS feed is currently unavailable. Last attempt:',
40
+ `*${new Date().toISOString()}*`,
41
+ '',
42
+ `**Error:** ${error}`,
43
+ '',
44
+ 'Full list: [developer.ebay.com/support/api-status](https://developer.ebay.com/support/api-status).',
45
+ '',
46
+ '---',
47
+ '',
48
+ '*Generated by [ebay-mcp-remote-edition](https://github.com/mrnajiboy/ebay-mcp-remote-edition) API status sync.*',
49
+ ].join('\n');
50
+ writeFileSync(OUT_PATH, fallback, 'utf8');
51
+ console.warn(`Feed unavailable (${error}), wrote fallback to ${OUT_PATH}`);
52
+ process.exitCode = 0;
53
+ return;
36
54
  }
37
55
  const markdown = buildMarkdown(items);
38
56
  writeFileSync(OUT_PATH, markdown, 'utf8');
@@ -745,7 +745,7 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
745
745
  serverLogger.info(`[${prefix || 'root'}/authorize] Credential check`, {
746
746
  environment,
747
747
  envSource,
748
- ruName: ebayConfig.redirectUri,
748
+ ruName: ebayConfig.ruName || ebayConfig.redirectUri,
749
749
  ruNameDetectedEnv: credCheck.detectedEnv,
750
750
  credentialValid: credCheck.valid,
751
751
  });
@@ -770,7 +770,7 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
770
770
  serverLogger.info(`[${prefix || 'root'}/authorize] Redirecting to eBay OAuth`, {
771
771
  state: stateRecord.state,
772
772
  environment,
773
- ruName: ebayConfig.redirectUri,
773
+ ruName: ebayConfig.ruName || ebayConfig.redirectUri,
774
774
  expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
775
775
  });
776
776
  res.redirect(oauthUrl);
@@ -874,7 +874,7 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
874
874
  serverLogger.info(`[${prefix || 'root'}/oauth/start] Redirecting to eBay OAuth`, {
875
875
  state: stateRecord.state,
876
876
  environment,
877
- ruName: ebayConfig.redirectUri,
877
+ ruName: ebayConfig.ruName || ebayConfig.redirectUri,
878
878
  expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
879
879
  returnTo,
880
880
  });
@@ -966,17 +966,42 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
966
966
  { src: `${iconBaseUrl}/48x48.png`, mimeType: 'image/png', sizes: ['48x48'] },
967
967
  ],
968
968
  });
969
+ // Configurable tool execution timeout (60s default — most tools complete in <10s)
970
+ // Longer than individual API timeouts (30s) to allow for token refresh + API chaining
971
+ const TOOL_TIMEOUT_MS = Number(process.env.MCP_TOOL_TIMEOUT_MS ?? 60_000);
969
972
  const tools = getToolDefinitions();
970
973
  for (const toolDef of tools) {
971
974
  try {
972
975
  server.registerTool(toolDef.name, { description: toolDef.description, inputSchema: toolDef.inputSchema }, async (args) => {
976
+ const startTime = Date.now();
973
977
  try {
974
- const result = await executeTool(api, toolDef.name, args);
978
+ // Wrap tool execution with timeout to prevent indefinite hangs
979
+ const result = await Promise.race([
980
+ executeTool(api, toolDef.name, args),
981
+ new Promise((_, reject) => {
982
+ setTimeout(() => reject(new Error(`Tool ${toolDef.name} timed out after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS);
983
+ }),
984
+ ]);
985
+ const duration = Date.now() - startTime;
986
+ serverLogger.info(`[tool-exec] ${toolDef.name}`, {
987
+ userId,
988
+ environment,
989
+ durationMs: duration,
990
+ status: 'success',
991
+ });
975
992
  return {
976
993
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
977
994
  };
978
995
  }
979
996
  catch (error) {
997
+ const duration = Date.now() - startTime;
998
+ serverLogger.warn(`[tool-exec] ${toolDef.name}`, {
999
+ userId,
1000
+ environment,
1001
+ durationMs: duration,
1002
+ status: 'error',
1003
+ error: error instanceof Error ? error.message : String(error),
1004
+ });
980
1005
  return {
981
1006
  content: [
982
1007
  {
@@ -1132,7 +1157,7 @@ async function handleOAuthCallback(req, res, serverUrl) {
1132
1157
  environment,
1133
1158
  tokenBaseUrl: getBaseUrl(environment),
1134
1159
  clientIdPrefix: ebayConfig.clientId ? `${ebayConfig.clientId.slice(0, 12)}...` : '(missing)',
1135
- ruName: ebayConfig.redirectUri ?? '(missing)',
1160
+ ruName: (ebayConfig.ruName || ebayConfig.redirectUri) ?? '(missing)',
1136
1161
  });
1137
1162
  const userId = randomUUID();
1138
1163
  const api = await createUserScopedApi(userId, environment);