ebay-mcp-remote-edition 3.1.1 → 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
@@ -101,6 +101,51 @@ EBAY_LOCAL_TLS_CERT_PATH=/path/to/ebay-local.test.pem
101
101
  EBAY_LOCAL_TLS_KEY_PATH=/path/to/ebay-local.test-key.pem
102
102
  ```
103
103
 
104
+ #### ⚠️ Trust the mkcert CA in Node.js (required for MCP clients like Cline)
105
+
106
+ VS Code's extension host (where Cline runs) uses Node.js for outbound HTTPS requests. Node.js does **not** automatically read macOS's system keychain, so the `ebay-local.test` certificate is not trusted by default. This causes the OAuth token exchange (`POST /sandbox/token`) to fail silently — the browser flow completes, the "Open in VS Code" page appears, but Cline never receives a session token.
107
+
108
+ **Fix — run these two commands once, then fully quit and reopen VS Code:**
109
+
110
+ ```bash
111
+ # 1. Set for the current macOS session (affects all Dock/Spotlight-launched apps):
112
+ launchctl setenv NODE_EXTRA_CA_CERTS "$(mkcert -CAROOT)/rootCA.pem"
113
+
114
+ # 2. Create a LaunchAgent so it persists across reboots:
115
+ cat > ~/Library/LaunchAgents/com.local.mkcert-node-trust.plist <<'EOF'
116
+ <?xml version="1.0" encoding="UTF-8"?>
117
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
118
+ <plist version="1.0">
119
+ <dict>
120
+ <key>Label</key><string>com.local.mkcert-node-trust</string>
121
+ <key>ProgramArguments</key>
122
+ <array>
123
+ <string>launchctl</string><string>setenv</string>
124
+ <string>NODE_EXTRA_CA_CERTS</string>
125
+ <string>/Users/YOUR_USERNAME/Library/Application Support/mkcert/rootCA.pem</string>
126
+ </array>
127
+ <key>RunAtLoad</key><true/>
128
+ </dict>
129
+ </plist>
130
+ EOF
131
+ launchctl load ~/Library/LaunchAgents/com.local.mkcert-node-trust.plist
132
+
133
+ # 3. For terminal-launched VS Code — add to ~/.zshrc:
134
+ echo 'export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"' >> ~/.zshrc
135
+ ```
136
+
137
+ > Replace `YOUR_USERNAME` with your actual macOS username in the plist, or use the full path printed by `mkcert -CAROOT`.
138
+
139
+ After running these commands and **fully quitting VS Code (Cmd+Q on macOS)** and reopening it, Cline's extension host will trust the `ebay-local.test` certificate and the MCP OAuth flow will complete successfully.
140
+
141
+ **Verify the fix works (without restarting VS Code):**
142
+ ```bash
143
+ NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" node -e "
144
+ require('https').get('https://ebay-local.test:3000/health', r => console.log('TLS OK — status:', r.statusCode)).on('error', e => console.error('TLS FAIL:', e.message));
145
+ "
146
+ # Expected: TLS OK — status: 200
147
+ ```
148
+
104
149
  For hosted deployments, register your server's public HTTPS URL instead (e.g. `https://your-server.com/oauth/callback`).
105
150
 
106
151
  ---
@@ -109,10 +154,10 @@ For hosted deployments, register your server's public HTTPS URL instead (e.g. `h
109
154
 
110
155
  ### Install
111
156
 
112
- **Option A — npm global install (no build step):**
157
+ **Option A — pnpm global install (no build step):**
113
158
 
114
159
  ```bash
115
- npm install -g ebay-mcp-remote-edition
160
+ pnpm install -g ebay-mcp-remote-edition
116
161
  ```
117
162
 
118
163
  **Option B — clone and build (for contributors or self-hosting):**
@@ -5,8 +5,20 @@ import { createKVStore } from '../auth/kv-store.js';
5
5
  const OAUTH_STATE_TTL_S = 15 * 60;
6
6
  /** 10 minutes — short-lived MCP authorization code. */
7
7
  const AUTH_CODE_TTL_S = 10 * 60;
8
- /** 30 days — configurable via SESSION_TTL_SECONDS env var. */
9
- const SESSION_TTL_S = Number(process.env.SESSION_TTL_SECONDS ?? 30 * 24 * 60 * 60);
8
+ /** 30 days — default; configurable via SESSION_TTL_SECONDS env var. */
9
+ const SESSION_TTL_FALLBACK_S = 30 * 24 * 60 * 60;
10
+ const _rawSessionTtl = process.env.SESSION_TTL_SECONDS;
11
+ const SESSION_TTL_S = (() => {
12
+ if (_rawSessionTtl === undefined || _rawSessionTtl.trim() === '') {
13
+ return SESSION_TTL_FALLBACK_S;
14
+ }
15
+ const parsed = Number(_rawSessionTtl);
16
+ if (!Number.isFinite(parsed) || parsed <= 0) {
17
+ console.warn(`[multi-user-store] SESSION_TTL_SECONDS="${_rawSessionTtl}" is invalid; falling back to ${SESSION_TTL_FALLBACK_S}s (30 days)`);
18
+ return SESSION_TTL_FALLBACK_S;
19
+ }
20
+ return Math.floor(parsed);
21
+ })();
10
22
  /** 18 months — default fallback when no refresh token expiry is available. */
11
23
  const DEFAULT_REFRESH_TOKEN_TTL_S = 18 * 30 * 24 * 60 * 60;
12
24
  function secondsFromNow(ttlSeconds) {
@@ -32,7 +44,7 @@ export class MultiUserAuthStore {
32
44
  * `lastUsedAt` once per TOUCH_THROTTLE_MS (default: 1 hour).
33
45
  */
34
46
  sessionTouchCache = new Map();
35
- static TOUCH_THROTTLE_MS = 60 * 60 * 1_000; // 1 hour
47
+ static touchThrottleMs = 60 * 60 * 1_000; // 1 hour
36
48
  stateKey(state) {
37
49
  return `oauth_state:${state}`;
38
50
  }
@@ -55,6 +67,12 @@ export class MultiUserAuthStore {
55
67
  await this.kv.put(this.stateKey(state), record, OAUTH_STATE_TTL_S);
56
68
  return record;
57
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
+ }
58
76
  async consumeOAuthState(state) {
59
77
  const key = this.stateKey(state);
60
78
  const record = await this.kv.get(key);
@@ -110,7 +128,7 @@ export class MultiUserAuthStore {
110
128
  // Skip the KV write entirely if we touched this session recently.
111
129
  // The in-memory cache in CloudflareKVStore already keeps reads free,
112
130
  // so the only cost we're avoiding here is the unnecessary KV PUT.
113
- if (lastTouched !== undefined && now - lastTouched < MultiUserAuthStore.TOUCH_THROTTLE_MS) {
131
+ if (lastTouched !== undefined && now - lastTouched < MultiUserAuthStore.touchThrottleMs) {
114
132
  return;
115
133
  }
116
134
  const record = await this.getSession(sessionToken);
@@ -139,13 +157,14 @@ export class MultiUserAuthStore {
139
157
  await this.kv.delete(this.sessionKey(sessionToken));
140
158
  }
141
159
  // ── RFC 7591 Dynamic Client Registration ──────────────────────────────────
142
- async registerClient(redirectUris, clientName) {
160
+ async registerClient(redirectUris, clientName, environment) {
143
161
  const clientId = randomUUID();
144
162
  const record = {
145
163
  clientId,
146
164
  redirectUris,
147
165
  clientName,
148
166
  createdAt: new Date().toISOString(),
167
+ ...(environment ? { environment } : {}),
149
168
  };
150
169
  await this.kv.put(`client:${clientId}`, record);
151
170
  return record;
@@ -162,13 +181,25 @@ export class MultiUserAuthStore {
162
181
  * An existing record for `clientId` is overwritten only if the supplied
163
182
  * `redirectUri` is not already listed (additive merge otherwise).
164
183
  */
165
- async registerClientWithId(clientId, redirectUris, clientName) {
184
+ /**
185
+ * @param environment Optional env to tag the client with. When provided,
186
+ * the environment is persisted on the record so that the root /authorize
187
+ * endpoint can use it as a fallback even when no ?env= query param is
188
+ * present. If the existing record already has a different environment
189
+ * the new value wins (e.g. re-registering via /sandbox/authorize should
190
+ * override a stale "production" tag).
191
+ */
192
+ async registerClientWithId(clientId, redirectUris, clientName, environment) {
166
193
  const existing = await this.kv.get(`client:${clientId}`);
167
194
  const now = new Date().toISOString();
168
195
  if (existing) {
169
- // Merge any new redirect URIs into the existing record
196
+ // Merge any new redirect URIs and update the env tag when provided.
170
197
  const merged = Array.from(new Set([...existing.redirectUris, ...redirectUris]));
171
- const updated = { ...existing, redirectUris: merged };
198
+ const updated = {
199
+ ...existing,
200
+ redirectUris: merged,
201
+ ...(environment ? { environment } : {}),
202
+ };
172
203
  await this.kv.put(`client:${clientId}`, updated);
173
204
  return updated;
174
205
  }
@@ -177,6 +208,7 @@ export class MultiUserAuthStore {
177
208
  redirectUris,
178
209
  clientName,
179
210
  createdAt: now,
211
+ ...(environment ? { environment } : {}),
180
212
  };
181
213
  await this.kv.put(`client:${clientId}`, record);
182
214
  return record;
@@ -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;
@@ -130,26 +130,59 @@ export function getHostedOauthScopes(environment) {
130
130
  'https://api.ebay.com/oauth/api_scope/commerce.message',
131
131
  'https://api.ebay.com/oauth/api_scope/commerce.feedback',
132
132
  'https://api.ebay.com/oauth/api_scope/commerce.shipping',
133
- 'https://api.ebay.com/oauth/api_scope/sell.order.read',
134
- 'https://api.ebay.com/oauth/api_scope/sell.order',
135
- 'https://api.ebay.com/oauth/api_scope/sell.auction.read',
136
- 'https://api.ebay.com/oauth/api_scope/sell.offer.read',
137
- 'https://api.ebay.com/oauth/api_scope/sell.offer',
138
- 'https://api.ebay.com/oauth/api_scope/sell.return.read',
139
- 'https://api.ebay.com/oauth/api_scope/sell.return',
140
- 'https://api.ebay.com/oauth/api_scope/sell.refund.read',
141
- 'https://api.ebay.com/oauth/api_scope/sell.resolution.read',
142
- 'https://api.ebay.com/oauth/api_scope/sell.inquiry.read',
143
- 'https://api.ebay.com/oauth/api_scope/sell.inquiry',
144
- 'https://api.ebay.com/oauth/api_scope/sell.cancellation.read',
145
- 'https://api.ebay.com/oauth/api_scope/sell.cancellation',
146
- 'https://api.ebay.com/oauth/api_scope/commerce.usernote',
147
133
  ];
148
134
  }
149
135
  export function getConfiguredEnvironment() {
150
136
  const env = process.env.EBAY_ENVIRONMENT || process.env.EBAY_DEFAULT_ENVIRONMENT || 'production';
151
137
  return env === 'sandbox' ? 'sandbox' : 'production';
152
138
  }
139
+ /**
140
+ * Parse an eBay RuName string to detect whether it belongs to production or
141
+ * sandbox. eBay encodes the environment in a dedicated segment of the RuName:
142
+ *
143
+ * CompanyName-AppNickname-AppName-**PR**-ID → production
144
+ * CompanyName-AppNickname-AppName-**SB**-ID → sandbox
145
+ *
146
+ * @returns `'production'` | `'sandbox'` | `null` (unknown / not detectable)
147
+ */
148
+ export function ruNameToEnvironment(ruName) {
149
+ if (!ruName)
150
+ return null;
151
+ // Look for a word boundary -PR- or -SB- anywhere in the string.
152
+ // Use exact dash-delimited segment matching to avoid false positives (e.g.
153
+ // "PROMO" or "SUBSCRIBE" being mis-detected).
154
+ if (/-PR-/i.test(ruName))
155
+ return 'production';
156
+ if (/-SB-/i.test(ruName))
157
+ return 'sandbox';
158
+ return null;
159
+ }
160
+ /**
161
+ * Validate that the credentials configured for `environment` actually match
162
+ * the requested environment by inspecting the RuName segment.
163
+ *
164
+ * Returns an object describing whether the credentials look correct and any
165
+ * human-readable warning or error message.
166
+ */
167
+ export function validateCredentialsForEnvironment(environment) {
168
+ const config = getEbayConfig(environment);
169
+ const ruName = config.redirectUri;
170
+ const detectedEnv = ruNameToEnvironment(ruName);
171
+ if (detectedEnv === null) {
172
+ // Can't tell from the RuName — treat as valid (no info to contradict it).
173
+ return { valid: true, detectedEnv: null };
174
+ }
175
+ if (detectedEnv !== environment) {
176
+ return {
177
+ valid: false,
178
+ detectedEnv,
179
+ error: `Credential mismatch: the RuName "${ruName}" belongs to ${detectedEnv} ` +
180
+ `but the request targets ${environment}. ` +
181
+ `Check EBAY_${environment.toUpperCase()}_RUNAME (or EBAY_RUNAME).`,
182
+ };
183
+ }
184
+ return { valid: true, detectedEnv };
185
+ }
153
186
  export function validateScopes(scopes, environment) {
154
187
  const validScopes = getDefaultScopes(environment);
155
188
  const validScopeSet = new Set(validScopes);
@@ -256,9 +289,19 @@ export function getEbayConfig(environmentOverride) {
256
289
  appAccessToken: process.env.EBAY_APP_ACCESS_TOKEN ?? '',
257
290
  };
258
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
+ }
259
299
  export function getBaseUrl(environment) {
260
300
  return environment === 'production' ? 'https://api.ebay.com' : 'https://api.sandbox.ebay.com';
261
301
  }
302
+ export function getOAuthTokenBaseUrl(environment) {
303
+ return environment === 'production' ? 'https://api.ebay.com' : 'https://api.sandbox.ebay.com';
304
+ }
262
305
  export function getIdentityBaseUrl(environment) {
263
306
  return environment === 'production' ? 'https://apiz.ebay.com' : 'https://apiz.sandbox.ebay.com';
264
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;