ebay-mcp-remote-edition 3.1.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Kenyatta Naji Johnson-Adams
3
+ Copyright (c) 2026 Kenyatta Naji Johnson-Adams
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -154,10 +154,10 @@ For hosted deployments, register your server's public HTTPS URL instead (e.g. `h
154
154
 
155
155
  ### Install
156
156
 
157
- **Option A — npm global install (no build step):**
157
+ **Option A — pnpm global install (no build step):**
158
158
 
159
159
  ```bash
160
- npm install -g ebay-mcp-remote-edition
160
+ pnpm install -g ebay-mcp-remote-edition
161
161
  ```
162
162
 
163
163
  **Option B — clone and build (for contributors or self-hosting):**
@@ -44,7 +44,7 @@ export class MultiUserAuthStore {
44
44
  * `lastUsedAt` once per TOUCH_THROTTLE_MS (default: 1 hour).
45
45
  */
46
46
  sessionTouchCache = new Map();
47
- static TOUCH_THROTTLE_MS = 60 * 60 * 1_000; // 1 hour
47
+ static touchThrottleMs = 60 * 60 * 1_000; // 1 hour
48
48
  stateKey(state) {
49
49
  return `oauth_state:${state}`;
50
50
  }
@@ -67,6 +67,12 @@ export class MultiUserAuthStore {
67
67
  await this.kv.put(this.stateKey(state), record, OAUTH_STATE_TTL_S);
68
68
  return record;
69
69
  }
70
+ async getOAuthState(state) {
71
+ return await this.kv.get(this.stateKey(state));
72
+ }
73
+ async deleteOAuthState(state) {
74
+ await this.kv.delete(this.stateKey(state));
75
+ }
70
76
  async consumeOAuthState(state) {
71
77
  const key = this.stateKey(state);
72
78
  const record = await this.kv.get(key);
@@ -122,7 +128,7 @@ export class MultiUserAuthStore {
122
128
  // Skip the KV write entirely if we touched this session recently.
123
129
  // The in-memory cache in CloudflareKVStore already keeps reads free,
124
130
  // so the only cost we're avoiding here is the unnecessary KV PUT.
125
- if (lastTouched !== undefined && now - lastTouched < MultiUserAuthStore.TOUCH_THROTTLE_MS) {
131
+ if (lastTouched !== undefined && now - lastTouched < MultiUserAuthStore.touchThrottleMs) {
126
132
  return;
127
133
  }
128
134
  const record = await this.getSession(sessionToken);
@@ -1,5 +1,5 @@
1
1
  import axios from 'axios';
2
- import { getBaseUrl } from '../config/environment.js';
2
+ import { getOAuthTokenBaseUrl } from '../config/environment.js';
3
3
  import { MultiUserAuthStore } from '../auth/multi-user-store.js';
4
4
  export class EbayOAuthClient {
5
5
  config;
@@ -8,15 +8,20 @@ export class EbayOAuthClient {
8
8
  appAccessTokenExpiry = 0;
9
9
  userTokens = null;
10
10
  authStore = new MultiUserAuthStore();
11
+ authSource = null;
11
12
  constructor(config, context) {
12
13
  this.config = config;
13
14
  this.context = context;
14
15
  }
16
+ getTokenEndpoint() {
17
+ return `${getOAuthTokenBaseUrl(this.config.environment)}/identity/v1/oauth2/token`;
18
+ }
15
19
  async initialize() {
16
20
  if (this.context?.userId && this.context.environment) {
17
21
  const stored = await this.authStore.getUserTokens(this.context.userId, this.context.environment);
18
22
  if (stored?.tokenData) {
19
23
  this.userTokens = stored.tokenData;
24
+ this.authSource = 'stored_user_tokens';
20
25
  return;
21
26
  }
22
27
  }
@@ -24,7 +29,7 @@ export class EbayOAuthClient {
24
29
  const envRefreshToken = process.env.EBAY_USER_REFRESH_TOKEN;
25
30
  if (envRefreshToken) {
26
31
  try {
27
- const authUrl = `${getBaseUrl(this.config.environment)}/identity/v1/oauth2/token`;
32
+ const authUrl = this.getTokenEndpoint();
28
33
  const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
29
34
  const response = await axios.post(authUrl, new URLSearchParams({
30
35
  grant_type: 'refresh_token',
@@ -50,6 +55,7 @@ export class EbayOAuthClient {
50
55
  : now + 18 * 30 * 24 * 60 * 60 * 1000,
51
56
  scope: tokenData.scope,
52
57
  };
58
+ this.authSource = 'env_refresh_token_fallback';
53
59
  }
54
60
  catch {
55
61
  // If refresh fails, leave userTokens as null
@@ -99,13 +105,14 @@ export class EbayOAuthClient {
99
105
  userAccessTokenExpiry: accessTokenExpiry ?? now + 7200 * 1000,
100
106
  userRefreshTokenExpiry: refreshTokenExpiry ?? now + 18 * 30 * 24 * 60 * 60 * 1000,
101
107
  };
108
+ this.authSource = 'manual_set_user_tokens';
102
109
  await this.persistUserTokens();
103
110
  }
104
111
  async getOrRefreshAppAccessToken() {
105
112
  if (this.appAccessToken && Date.now() < this.appAccessTokenExpiry) {
106
113
  return this.appAccessToken;
107
114
  }
108
- const authUrl = `${getBaseUrl(this.config.environment)}/identity/v1/oauth2/token`;
115
+ const authUrl = this.getTokenEndpoint();
109
116
  const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
110
117
  const response = await axios.post(authUrl, new URLSearchParams({
111
118
  grant_type: 'client_credentials',
@@ -124,7 +131,7 @@ export class EbayOAuthClient {
124
131
  if (!this.config.redirectUri) {
125
132
  throw new Error('Redirect URI is required for authorization code exchange');
126
133
  }
127
- const tokenUrl = `${getBaseUrl(this.config.environment)}/identity/v1/oauth2/token`;
134
+ const tokenUrl = this.getTokenEndpoint();
128
135
  const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
129
136
  try {
130
137
  const response = await axios.post(tokenUrl, new URLSearchParams({
@@ -150,6 +157,7 @@ export class EbayOAuthClient {
150
157
  userRefreshTokenExpiry: now + tokenData.refresh_token_expires_in * 1000,
151
158
  scope: tokenData.scope,
152
159
  };
160
+ this.authSource = 'authorization_code_exchange';
153
161
  await this.persistUserTokens();
154
162
  return tokenData;
155
163
  }
@@ -170,7 +178,7 @@ export class EbayOAuthClient {
170
178
  if (!this.userTokens) {
171
179
  throw new Error('No user tokens available to refresh');
172
180
  }
173
- const authUrl = `${getBaseUrl(this.config.environment)}/identity/v1/oauth2/token`;
181
+ const authUrl = this.getTokenEndpoint();
174
182
  const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
175
183
  const response = await axios.post(authUrl, new URLSearchParams({
176
184
  grant_type: 'refresh_token',
@@ -198,6 +206,24 @@ export class EbayOAuthClient {
198
206
  };
199
207
  await this.persistUserTokens();
200
208
  }
209
+ getAuthDebugInfo() {
210
+ return {
211
+ tokenEndpoint: this.getTokenEndpoint(),
212
+ environment: this.config.environment,
213
+ hasClientId: this.config.clientId.trim().length > 0,
214
+ hasClientSecret: this.config.clientSecret.trim().length > 0,
215
+ hasRefreshToken: !!this.userTokens?.userRefreshToken,
216
+ hasAccessToken: !!this.userTokens?.userAccessToken,
217
+ hasRedirectUri: !!this.config.redirectUri,
218
+ ...(this.userTokens?.userRefreshTokenExpiry
219
+ ? { refreshTokenExpiry: this.userTokens.userRefreshTokenExpiry }
220
+ : {}),
221
+ ...(this.userTokens?.userAccessTokenExpiry
222
+ ? { accessTokenExpiry: this.userTokens.userAccessTokenExpiry }
223
+ : {}),
224
+ ...(this.authSource ? { source: this.authSource } : {}),
225
+ };
226
+ }
201
227
  isAuthenticated() {
202
228
  if (this.userTokens && !this.isUserAccessTokenExpired(this.userTokens)) {
203
229
  return true;
@@ -289,9 +289,19 @@ export function getEbayConfig(environmentOverride) {
289
289
  appAccessToken: process.env.EBAY_APP_ACCESS_TOKEN ?? '',
290
290
  };
291
291
  }
292
+ export function getValidationRunnerUserId(environment) {
293
+ const envSpecific = environment === 'production'
294
+ ? process.env.VALIDATION_RUNNER_USER_ID_PRODUCTION
295
+ : process.env.VALIDATION_RUNNER_USER_ID_SANDBOX;
296
+ const resolved = (envSpecific ?? process.env.VALIDATION_RUNNER_USER_ID ?? '').trim();
297
+ return resolved || null;
298
+ }
292
299
  export function getBaseUrl(environment) {
293
300
  return environment === 'production' ? 'https://api.ebay.com' : 'https://api.sandbox.ebay.com';
294
301
  }
302
+ export function getOAuthTokenBaseUrl(environment) {
303
+ return environment === 'production' ? 'https://api.ebay.com' : 'https://api.sandbox.ebay.com';
304
+ }
295
305
  export function getIdentityBaseUrl(environment) {
296
306
  return environment === 'production' ? 'https://apiz.ebay.com' : 'https://apiz.sandbox.ebay.com';
297
307
  }
@@ -11,7 +11,7 @@ function isHostedEnvironment() {
11
11
  function main() {
12
12
  const args = process.argv.slice(2);
13
13
  if (args.length === 0) {
14
- throw new Error('Usage: tsx src/scripts/run-with-local-env.ts <command> [args...]');
14
+ throw new Error('Usage: tsx src/scripts/env-check.ts <command> [args...]');
15
15
  }
16
16
  const hosted = isHostedEnvironment();
17
17
  const [command, ...commandArgs] = args;
@@ -1,6 +1,7 @@
1
1
  import express from 'express';
2
2
  import helmet from 'helmet';
3
3
  import cors from 'cors';
4
+ import axios from 'axios';
4
5
  import { createServer as createHttpsServer } from 'https';
5
6
  import { readFileSync } from 'fs';
6
7
  import { dirname, join, resolve } from 'path';
@@ -10,8 +11,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
11
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
12
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
12
13
  import { EbaySellerApi } from './api/index.js';
13
- import { getConfiguredEnvironment, getHostedOauthScopes, getEbayConfig, getOAuthAuthorizationUrl, validateCredentialsForEnvironment, ruNameToEnvironment, } from './config/environment.js';
14
- import { getToolDefinitions, executeTool } from './tools/index.js';
14
+ import { getBaseUrl, getConfiguredEnvironment, getHostedOauthScopes, getEbayConfig, getOAuthAuthorizationUrl, getValidationRunnerUserId, validateCredentialsForEnvironment, ruNameToEnvironment, } from './config/environment.js';
15
15
  import { getVersion } from './utils/version.js';
16
16
  import { serverLogger } from './utils/logger.js';
17
17
  import { MultiUserAuthStore } from './auth/multi-user-store.js';
@@ -38,6 +38,48 @@ function htmlEscape(value) {
38
38
  .replace(/"/g, '"')
39
39
  .replace(/'/g, '&#39;');
40
40
  }
41
+ function getValidationIdFromBody(body) {
42
+ if (typeof body === 'object' &&
43
+ body !== null &&
44
+ 'validationId' in body &&
45
+ typeof body.validationId === 'string') {
46
+ return body.validationId;
47
+ }
48
+ return '';
49
+ }
50
+ function getRetryTimestampFromBody(body) {
51
+ if (typeof body === 'object' &&
52
+ body !== null &&
53
+ 'timestamp' in body &&
54
+ typeof body.timestamp === 'string') {
55
+ const parsed = new Date(body.timestamp);
56
+ if (Number.isFinite(parsed.getTime())) {
57
+ return new Date(parsed.getTime() + 30 * 60 * 1000).toISOString();
58
+ }
59
+ }
60
+ return new Date(Date.now() + 30 * 60 * 1000).toISOString();
61
+ }
62
+ function getAxiosFailureDebug(error) {
63
+ if (!axios.isAxiosError(error)) {
64
+ return {
65
+ responseStatus: null,
66
+ responseBodyExcerpt: null,
67
+ };
68
+ }
69
+ const responseStatus = error.response?.status ?? null;
70
+ const rawBody = error.response?.data;
71
+ if (rawBody === undefined) {
72
+ return {
73
+ responseStatus,
74
+ responseBodyExcerpt: null,
75
+ };
76
+ }
77
+ const bodyText = typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody, null, 2);
78
+ return {
79
+ responseStatus,
80
+ responseBodyExcerpt: bodyText.slice(0, 500),
81
+ };
82
+ }
41
83
  function requireAdmin(req, res, next) {
42
84
  if (!CONFIG.adminApiKey) {
43
85
  res.status(500).json({ error: 'ADMIN_API_KEY is not configured' });
@@ -107,6 +149,7 @@ function createApp() {
107
149
  app.use(express.urlencoded({ extended: false }));
108
150
  app.use(helmet({ xPoweredBy: false }));
109
151
  app.use('/icons', express.static(join(projectRoot, 'public', 'icons')));
152
+ app.use('/callback-copy.js', express.static(join(projectRoot, 'public', 'callback-copy.js')));
110
153
  app.use((req, res, next) => {
111
154
  const start = Date.now();
112
155
  res.on('finish', () => {
@@ -317,9 +360,9 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
317
360
  }
318
361
  }
319
362
  // Step 2: per-env RuName detection.
320
- const sandboxRuName = process.env.EBAY_SANDBOX_RUNAME || process.env.EBAY_SANDBOX_REDIRECT_URI;
321
- const productionRuName = process.env.EBAY_PRODUCTION_RUNAME || process.env.EBAY_PRODUCTION_REDIRECT_URI;
322
- const genericRuName = process.env.EBAY_RUNAME || process.env.EBAY_REDIRECT_URI;
363
+ const sandboxRuName = process.env.EBAY_SANDBOX_RUNAME ?? process.env.EBAY_SANDBOX_REDIRECT_URI;
364
+ const productionRuName = process.env.EBAY_PRODUCTION_RUNAME ?? process.env.EBAY_PRODUCTION_REDIRECT_URI;
365
+ const genericRuName = process.env.EBAY_RUNAME ?? process.env.EBAY_REDIRECT_URI;
323
366
  const sandboxDetected = ruNameToEnvironment(sandboxRuName);
324
367
  const productionDetected = ruNameToEnvironment(productionRuName);
325
368
  if (sandboxDetected && !productionDetected)
@@ -333,6 +376,113 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
333
376
  // Step 4: final fallback.
334
377
  return getConfiguredEnvironment();
335
378
  }
379
+ router.post('/validation/run', requireAdmin, async (req, res) => {
380
+ const environment = resolveEnv(req);
381
+ const validationRunnerUserId = getValidationRunnerUserId(environment);
382
+ if (!validationRunnerUserId) {
383
+ res.status(500).json({
384
+ status: 'error',
385
+ validationId: getValidationIdFromBody(req.body),
386
+ errorCode: 'VALIDATION_USER_NOT_CONFIGURED',
387
+ message: `No validation runner user is configured for ${environment}`,
388
+ retryable: false,
389
+ nextCheckAt: null,
390
+ });
391
+ return;
392
+ }
393
+ const storedTokens = await authStore.getUserTokens(validationRunnerUserId, environment);
394
+ if (!storedTokens?.tokenData) {
395
+ res.status(500).json({
396
+ status: 'error',
397
+ validationId: getValidationIdFromBody(req.body),
398
+ errorCode: 'VALIDATION_USER_TOKENS_MISSING',
399
+ message: `Stored refresh-token-backed credentials were not found for validation user ${validationRunnerUserId} in ${environment}`,
400
+ retryable: false,
401
+ nextCheckAt: null,
402
+ });
403
+ return;
404
+ }
405
+ try {
406
+ const api = await createUserScopedApi(validationRunnerUserId, environment);
407
+ const { runValidation } = await import('./validation/run-validation.js');
408
+ const result = await runValidation(api, req.body);
409
+ if (result.status === 'error') {
410
+ res.status(500).json(result);
411
+ return;
412
+ }
413
+ res.json(result);
414
+ }
415
+ catch (error) {
416
+ res.status(500).json({
417
+ status: 'error',
418
+ validationId: getValidationIdFromBody(req.body),
419
+ errorCode: 'VALIDATION_ROUTE_ERROR',
420
+ message: error instanceof Error ? error.message : String(error),
421
+ retryable: true,
422
+ nextCheckAt: getRetryTimestampFromBody(req.body),
423
+ });
424
+ }
425
+ });
426
+ router.get('/validation/health', requireAdmin, async (req, res) => {
427
+ const environment = resolveEnv(req);
428
+ const validationRunnerUserId = getValidationRunnerUserId(environment);
429
+ const storedTokens = validationRunnerUserId
430
+ ? await authStore.getUserTokens(validationRunnerUserId, environment)
431
+ : null;
432
+ let authenticated = false;
433
+ let authError = null;
434
+ let tokenStatus = null;
435
+ let authDebug = null;
436
+ if (validationRunnerUserId && storedTokens?.tokenData) {
437
+ try {
438
+ const api = await createUserScopedApi(validationRunnerUserId, environment);
439
+ const oauthClient = api.getAuthClient().getOAuthClient();
440
+ const config = api.getAuthClient().getConfig();
441
+ authDebug = {
442
+ ...oauthClient.getAuthDebugInfo(),
443
+ configuredMarketplaceId: config.marketplaceId ?? '',
444
+ configuredContentLanguage: config.contentLanguage ?? '',
445
+ };
446
+ await api.getAuthClient().getOAuthClient().getAccessToken();
447
+ authenticated = true;
448
+ tokenStatus = api.getTokenInfo();
449
+ }
450
+ catch (error) {
451
+ authError = error instanceof Error ? error.message : String(error);
452
+ const failureDebug = getAxiosFailureDebug(error);
453
+ authDebug = authDebug
454
+ ? {
455
+ ...authDebug,
456
+ responseStatus: failureDebug.responseStatus,
457
+ responseBodyExcerpt: failureDebug.responseBodyExcerpt,
458
+ }
459
+ : null;
460
+ serverLogger.error('Validation health auth check failed', {
461
+ environment,
462
+ validationRunnerUserId,
463
+ tokenEndpoint: authDebug?.tokenEndpoint ?? null,
464
+ responseStatus: failureDebug.responseStatus,
465
+ responseBodyExcerpt: failureDebug.responseBodyExcerpt,
466
+ authError,
467
+ });
468
+ }
469
+ }
470
+ res.json({
471
+ status: authenticated ? 'ok' : 'degraded',
472
+ environment,
473
+ validationRunnerUserId,
474
+ hasStoredTokens: !!storedTokens?.tokenData,
475
+ authenticated,
476
+ tokenStatus,
477
+ authDebug,
478
+ providers: {
479
+ ebay: { available: true, implemented: true, confidence: 'medium' },
480
+ social: { available: false, implemented: false, confidence: 'low' },
481
+ chart: { available: false, implemented: false, confidence: 'low' },
482
+ },
483
+ ...(authError ? { authError } : {}),
484
+ });
485
+ });
336
486
  // ── RFC 8414 – Authorization Server Metadata ──────────────────────────
337
487
  // For env-scoped routers: endpoints are relative to the env base URL.
338
488
  // For the ROOT router: endpoints are relative to the DEFAULT environment's
@@ -671,6 +821,19 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
671
821
  next();
672
822
  };
673
823
  async function createMcpServer(userId, environment) {
824
+ let getToolDefinitions;
825
+ let executeTool;
826
+ try {
827
+ ({ getToolDefinitions, executeTool } = await import('./tools/index.js'));
828
+ }
829
+ catch (error) {
830
+ serverLogger.error('Failed to import MCP tool registry', {
831
+ userId,
832
+ environment,
833
+ error: error instanceof Error ? error.message : String(error),
834
+ });
835
+ throw error;
836
+ }
674
837
  const api = await createUserScopedApi(userId, environment);
675
838
  const server = new McpServer({
676
839
  name: 'ebay-mcp-remote-edition',
@@ -685,23 +848,36 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
685
848
  });
686
849
  const tools = getToolDefinitions();
687
850
  for (const toolDef of tools) {
688
- server.registerTool(toolDef.name, { description: toolDef.description, inputSchema: toolDef.inputSchema }, async (args) => {
689
- try {
690
- const result = await executeTool(api, toolDef.name, args);
691
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
692
- }
693
- catch (error) {
694
- return {
695
- content: [
696
- {
697
- type: 'text',
698
- text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2),
699
- },
700
- ],
701
- isError: true,
702
- };
703
- }
704
- });
851
+ try {
852
+ server.registerTool(toolDef.name, { description: toolDef.description, inputSchema: toolDef.inputSchema }, async (args) => {
853
+ try {
854
+ const result = await executeTool(api, toolDef.name, args);
855
+ return {
856
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
857
+ };
858
+ }
859
+ catch (error) {
860
+ return {
861
+ content: [
862
+ {
863
+ type: 'text',
864
+ text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2),
865
+ },
866
+ ],
867
+ isError: true,
868
+ };
869
+ }
870
+ });
871
+ }
872
+ catch (error) {
873
+ serverLogger.error('Failed to register MCP tool', {
874
+ toolName: toolDef.name,
875
+ userId,
876
+ environment,
877
+ error: error instanceof Error ? error.message : String(error),
878
+ });
879
+ throw error;
880
+ }
705
881
  }
706
882
  return server;
707
883
  }
@@ -717,26 +893,51 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
717
893
  transport = transports.get(sessionId);
718
894
  }
719
895
  else if (!sessionId && isInitializeRequest(req.body)) {
720
- transport = new StreamableHTTPServerTransport({
721
- sessionIdGenerator: () => randomUUID(),
722
- onsessioninitialized: (newSessionId) => {
723
- transports.set(newSessionId, transport);
724
- serverLogger.info('New MCP session initialized', {
725
- sessionId: newSessionId,
726
- userId: userContext.userId,
727
- environment: userContext.environment,
728
- });
729
- },
730
- });
731
- transport.onclose = () => {
732
- if (transport.sessionId) {
733
- transports.delete(transport.sessionId);
734
- }
735
- };
736
- const server = await createMcpServer(userContext.userId, userContext.environment);
737
- await server.connect(transport);
896
+ try {
897
+ transport = new StreamableHTTPServerTransport({
898
+ sessionIdGenerator: () => randomUUID(),
899
+ onsessioninitialized: (newSessionId) => {
900
+ transports.set(newSessionId, transport);
901
+ serverLogger.info('New MCP session initialized', {
902
+ sessionId: newSessionId,
903
+ userId: userContext.userId,
904
+ environment: userContext.environment,
905
+ });
906
+ },
907
+ });
908
+ transport.onclose = () => {
909
+ if (transport.sessionId) {
910
+ transports.delete(transport.sessionId);
911
+ }
912
+ };
913
+ const server = await createMcpServer(userContext.userId, userContext.environment);
914
+ await server.connect(transport);
915
+ }
916
+ catch (error) {
917
+ serverLogger.error('Failed to initialize MCP session', {
918
+ userId: userContext.userId,
919
+ environment: userContext.environment,
920
+ error: error instanceof Error ? error.message : String(error),
921
+ });
922
+ res.status(500).json({
923
+ jsonrpc: '2.0',
924
+ error: {
925
+ code: -32603,
926
+ message: error instanceof Error ? error.message : 'Failed to initialize MCP session',
927
+ },
928
+ id: null,
929
+ });
930
+ return;
931
+ }
738
932
  }
739
933
  else {
934
+ serverLogger.warn('Rejected MCP request without valid transport session', {
935
+ hasSessionId: !!sessionId,
936
+ sessionId,
937
+ isInitialize: isInitializeRequest(req.body),
938
+ userId: userContext.userId,
939
+ environment: userContext.environment,
940
+ });
740
941
  res.status(400).json({
741
942
  jsonrpc: '2.0',
742
943
  error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
@@ -769,7 +970,6 @@ async function handleOAuthCallback(req, res, serverUrl) {
769
970
  try {
770
971
  const code = typeof req.query.code === 'string' ? req.query.code : undefined;
771
972
  const state = typeof req.query.state === 'string' ? req.query.state : undefined;
772
- const envFromQuery = typeof req.query.env === 'string' ? req.query.env : undefined;
773
973
  const oauthError = typeof req.query.error === 'string' ? req.query.error : undefined;
774
974
  const errorDescription = typeof req.query.error_description === 'string' ? req.query.error_description : undefined;
775
975
  serverLogger.info('[oauth/callback] Received', {
@@ -787,33 +987,37 @@ async function handleOAuthCallback(req, res, serverUrl) {
787
987
  res.status(400).send('<h1>Missing authorization code</h1>');
788
988
  return;
789
989
  }
790
- let environment;
791
- let stateRecord = null;
792
- if (state) {
793
- stateRecord = await authStore.consumeOAuthState(state);
794
- if (!stateRecord) {
795
- serverLogger.warn('[oauth/callback] OAuth state not found or expired', { state });
796
- res.status(400).send('<h1>Invalid or expired OAuth state</h1>');
797
- return;
798
- }
799
- environment = stateRecord.environment;
800
- serverLogger.info('[oauth/callback] State resolved', {
801
- environment,
802
- isMcpFlow: !!(stateRecord.mcpClientId && stateRecord.mcpRedirectUri),
803
- });
990
+ if (!state) {
991
+ serverLogger.warn('[oauth/callback] Missing OAuth state in callback');
992
+ res
993
+ .status(400)
994
+ .send("<h1>Missing OAuth state</h1><p>Restart the OAuth flow from this server's /oauth/start or /authorize endpoint.</p>");
995
+ return;
804
996
  }
805
- else {
806
- environment =
807
- envFromQuery === 'sandbox' || envFromQuery === 'production'
808
- ? envFromQuery
809
- : getConfiguredEnvironment();
810
- serverLogger.warn('OAuth callback received without state; falling back to configured/query environment', { environment });
997
+ const stateRecord = await authStore.getOAuthState(state);
998
+ if (!stateRecord) {
999
+ serverLogger.warn('[oauth/callback] OAuth state not found or expired', { state });
1000
+ res.status(400).send('<h1>Invalid or expired OAuth state</h1>');
1001
+ return;
811
1002
  }
1003
+ const environment = stateRecord.environment;
1004
+ serverLogger.info('[oauth/callback] State resolved', {
1005
+ environment,
1006
+ isMcpFlow: !!(stateRecord.mcpClientId && stateRecord.mcpRedirectUri),
1007
+ });
1008
+ const ebayConfig = getEbayConfig(environment);
1009
+ serverLogger.info('[oauth/callback] Prepared eBay token exchange', {
1010
+ environment,
1011
+ tokenBaseUrl: getBaseUrl(environment),
1012
+ clientIdPrefix: ebayConfig.clientId ? `${ebayConfig.clientId.slice(0, 12)}...` : '(missing)',
1013
+ ruName: ebayConfig.redirectUri ?? '(missing)',
1014
+ });
812
1015
  const userId = randomUUID();
813
1016
  const api = await createUserScopedApi(userId, environment);
814
1017
  const oauthClient = api.getAuthClient().getOAuthClient();
815
1018
  serverLogger.info('[oauth/callback] Exchanging code for eBay tokens', { userId });
816
1019
  const tokenData = await oauthClient.exchangeCodeForToken(code);
1020
+ await authStore.deleteOAuthState(state);
817
1021
  serverLogger.info('[oauth/callback] eBay token exchange successful', {
818
1022
  userId,
819
1023
  hasScope: !!tokenData.scope,
@@ -901,32 +1105,7 @@ async function handleOAuthCallback(req, res, serverUrl) {
901
1105
  .muted { color: #6b7280; }
902
1106
  a { color: #2563eb; }
903
1107
  </style>
904
- <script>
905
- async function copyText(elementId, statusId) {
906
- const token = document.getElementById(elementId).innerText;
907
- const status = document.getElementById(statusId);
908
- try {
909
- if (navigator.clipboard && window.isSecureContext) {
910
- await navigator.clipboard.writeText(token);
911
- } else {
912
- const temp = document.createElement('textarea');
913
- temp.value = token;
914
- temp.setAttribute('readonly', '');
915
- temp.style.position = 'absolute';
916
- temp.style.left = '-9999px';
917
- document.body.appendChild(temp);
918
- temp.select();
919
- document.execCommand('copy');
920
- document.body.removeChild(temp);
921
- }
922
- status.textContent = 'Copied!';
923
- setTimeout(() => { status.textContent = ''; }, 1800);
924
- } catch (err) {
925
- status.textContent = 'Copy failed — select manually';
926
- setTimeout(() => { status.textContent = ''; }, 2500);
927
- }
928
- }
929
- </script>
1108
+ <script src="/callback-copy.js" defer></script>
930
1109
  </head>
931
1110
  <body>
932
1111
  <h1>eBay account connected ✓</h1>
@@ -936,21 +1115,21 @@ async function handleOAuthCallback(req, res, serverUrl) {
936
1115
  <h2>① Session token — paste as Bearer token in your MCP client</h2>
937
1116
  <div class="card">
938
1117
  <pre id="session-token">${htmlEscape(session.sessionToken)}</pre>
939
- <button class="copy-btn" onclick="copyText('session-token','st-status')">Copy</button><span id="st-status" class="copy-status"></span>
1118
+ <button class="copy-btn" data-copy-source="session-token" data-copy-status="st-status">Copy</button><span id="st-status" class="copy-status"></span>
940
1119
  <p class="muted">Set as <code>EBAY_SESSION_TOKEN</code> in your server env to survive restarts.</p>
941
1120
  </div>
942
1121
 
943
1122
  <h2>② eBay user access token</h2>
944
1123
  <div class="card">
945
1124
  <pre id="access-token">${htmlEscape(tokenData.access_token)}</pre>
946
- <button class="copy-btn" onclick="copyText('access-token','at-status')">Copy</button><span id="at-status" class="copy-status"></span>
1125
+ <button class="copy-btn" data-copy-source="access-token" data-copy-status="at-status">Copy</button><span id="at-status" class="copy-status"></span>
947
1126
  <p class="muted">Set as <code>EBAY_USER_ACCESS_TOKEN</code> in your server env (optional — auto-refreshed from refresh token).</p>
948
1127
  </div>
949
1128
 
950
1129
  <h2>③ eBay user refresh token</h2>
951
1130
  <div class="card">
952
1131
  <pre id="refresh-token">${htmlEscape(tokenData.refresh_token ?? '')}</pre>
953
- <button class="copy-btn" onclick="copyText('refresh-token','rt-status')">Copy</button><span id="rt-status" class="copy-status"></span>
1132
+ <button class="copy-btn" data-copy-source="refresh-token" data-copy-status="rt-status">Copy</button><span id="rt-status" class="copy-status"></span>
954
1133
  <p class="muted">Set as <code>EBAY_USER_REFRESH_TOKEN</code> in your server env. The server uses this to keep the access token fresh automatically.</p>
955
1134
  </div>
956
1135