ebay-mcp-remote-edition 3.1.1 → 3.1.2

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
@@ -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
  ---
@@ -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) {
@@ -139,13 +151,14 @@ export class MultiUserAuthStore {
139
151
  await this.kv.delete(this.sessionKey(sessionToken));
140
152
  }
141
153
  // ── RFC 7591 Dynamic Client Registration ──────────────────────────────────
142
- async registerClient(redirectUris, clientName) {
154
+ async registerClient(redirectUris, clientName, environment) {
143
155
  const clientId = randomUUID();
144
156
  const record = {
145
157
  clientId,
146
158
  redirectUris,
147
159
  clientName,
148
160
  createdAt: new Date().toISOString(),
161
+ ...(environment ? { environment } : {}),
149
162
  };
150
163
  await this.kv.put(`client:${clientId}`, record);
151
164
  return record;
@@ -162,13 +175,25 @@ export class MultiUserAuthStore {
162
175
  * An existing record for `clientId` is overwritten only if the supplied
163
176
  * `redirectUri` is not already listed (additive merge otherwise).
164
177
  */
165
- async registerClientWithId(clientId, redirectUris, clientName) {
178
+ /**
179
+ * @param environment Optional env to tag the client with. When provided,
180
+ * the environment is persisted on the record so that the root /authorize
181
+ * endpoint can use it as a fallback even when no ?env= query param is
182
+ * present. If the existing record already has a different environment
183
+ * the new value wins (e.g. re-registering via /sandbox/authorize should
184
+ * override a stale "production" tag).
185
+ */
186
+ async registerClientWithId(clientId, redirectUris, clientName, environment) {
166
187
  const existing = await this.kv.get(`client:${clientId}`);
167
188
  const now = new Date().toISOString();
168
189
  if (existing) {
169
- // Merge any new redirect URIs into the existing record
190
+ // Merge any new redirect URIs and update the env tag when provided.
170
191
  const merged = Array.from(new Set([...existing.redirectUris, ...redirectUris]));
171
- const updated = { ...existing, redirectUris: merged };
192
+ const updated = {
193
+ ...existing,
194
+ redirectUris: merged,
195
+ ...(environment ? { environment } : {}),
196
+ };
172
197
  await this.kv.put(`client:${clientId}`, updated);
173
198
  return updated;
174
199
  }
@@ -177,6 +202,7 @@ export class MultiUserAuthStore {
177
202
  redirectUris,
178
203
  clientName,
179
204
  createdAt: now,
205
+ ...(environment ? { environment } : {}),
180
206
  };
181
207
  await this.kv.put(`client:${clientId}`, record);
182
208
  return record;
@@ -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);
@@ -10,7 +10,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
11
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
12
12
  import { EbaySellerApi } from './api/index.js';
13
- import { getConfiguredEnvironment, getHostedOauthScopes, getEbayConfig, getOAuthAuthorizationUrl, } from './config/environment.js';
13
+ import { getConfiguredEnvironment, getHostedOauthScopes, getEbayConfig, getOAuthAuthorizationUrl, validateCredentialsForEnvironment, ruNameToEnvironment, } from './config/environment.js';
14
14
  import { getToolDefinitions, executeTool } from './tools/index.js';
15
15
  import { getVersion } from './utils/version.js';
16
16
  import { serverLogger } from './utils/logger.js';
@@ -118,6 +118,80 @@ function createApp() {
118
118
  });
119
119
  const serverUrl = getServerBaseUrl();
120
120
  const iconBaseUrl = `${serverUrl}/icons`;
121
+ // ── RFC 9728 – Path-based Protected Resource Metadata ────────────────────
122
+ // Cline probes these URLs for MCP resources at /sandbox/mcp and /production/mcp.
123
+ // RFC 9728 §3 defines the well-known URI as:
124
+ // /.well-known/oauth-protected-resource{path-to-resource}
125
+ // We must serve these before the env routers so they are not caught by their
126
+ // own /.well-known/... handler (which is relative to the router base path).
127
+ app.get('/.well-known/oauth-protected-resource/sandbox/mcp', (_req, res) => {
128
+ res.json({
129
+ resource: `${serverUrl}/sandbox/mcp`,
130
+ authorization_servers: [`${serverUrl}/sandbox`],
131
+ scopes_supported: ['mcp'],
132
+ resource_documentation: 'https://github.com/mrnajiboy/ebay-mcp-remote-edition',
133
+ });
134
+ });
135
+ app.get('/.well-known/oauth-protected-resource/production/mcp', (_req, res) => {
136
+ res.json({
137
+ resource: `${serverUrl}/production/mcp`,
138
+ authorization_servers: [`${serverUrl}/production`],
139
+ scopes_supported: ['mcp'],
140
+ resource_documentation: 'https://github.com/mrnajiboy/ebay-mcp-remote-edition',
141
+ });
142
+ });
143
+ // Generic fallback: serves the default-env resource metadata.
144
+ // Also satisfies clients that probe /.well-known/oauth-protected-resource
145
+ // without a path suffix.
146
+ app.get('/.well-known/oauth-protected-resource', (_req, res) => {
147
+ const defaultEnv = getConfiguredEnvironment();
148
+ res.json({
149
+ resource: `${serverUrl}/${defaultEnv}/mcp`,
150
+ authorization_servers: [`${serverUrl}/${defaultEnv}`],
151
+ scopes_supported: ['mcp'],
152
+ resource_documentation: 'https://github.com/mrnajiboy/ebay-mcp-remote-edition',
153
+ });
154
+ });
155
+ // ── RFC 8414 §3 – Path-based Authorization Server Metadata ───────────────
156
+ // When Protected Resource Metadata says authorization_servers: ["…/sandbox"],
157
+ // RFC 8414 §3 requires the auth server metadata to be fetchable at:
158
+ // /.well-known/oauth-authorization-server/sandbox (NOT /sandbox/.well-known/…)
159
+ //
160
+ // Cline probes exactly this form. Without these routes it falls back to root
161
+ // /authorize which silently defaults to production.
162
+ app.get('/.well-known/oauth-authorization-server/sandbox', (_req, res) => {
163
+ const base = `${serverUrl}/sandbox`;
164
+ // authorization_endpoint uses the ROOT /authorize with ?env=sandbox so that
165
+ // MCP clients (like Cline) that strip the path prefix from the issuer URL
166
+ // still land on the correct environment — the ?env= query param is
167
+ // preserved through URL construction and picked up by resolveEnv().
168
+ // token/registration still use the env-scoped path.
169
+ res.json({
170
+ issuer: base,
171
+ authorization_endpoint: `${serverUrl}/authorize?env=sandbox`,
172
+ token_endpoint: `${base}/token`,
173
+ registration_endpoint: `${base}/register`,
174
+ response_types_supported: ['code'],
175
+ grant_types_supported: ['authorization_code'],
176
+ code_challenge_methods_supported: ['S256'],
177
+ token_endpoint_auth_methods_supported: ['none'],
178
+ scopes_supported: ['mcp'],
179
+ });
180
+ });
181
+ app.get('/.well-known/oauth-authorization-server/production', (_req, res) => {
182
+ const base = `${serverUrl}/production`;
183
+ res.json({
184
+ issuer: base,
185
+ authorization_endpoint: `${serverUrl}/authorize?env=production`,
186
+ token_endpoint: `${base}/token`,
187
+ registration_endpoint: `${base}/register`,
188
+ response_types_supported: ['code'],
189
+ grant_types_supported: ['authorization_code'],
190
+ code_challenge_methods_supported: ['S256'],
191
+ token_endpoint_auth_methods_supported: ['none'],
192
+ scopes_supported: ['mcp'],
193
+ });
194
+ });
121
195
  // ── Root index / health ───────────────────────────────────────────────────
122
196
  app.get('/', (_req, res) => {
123
197
  res.json({
@@ -216,15 +290,63 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
216
290
  if (hardcodedEnv)
217
291
  return hardcodedEnv;
218
292
  const q = req.query;
219
- return q.env === 'sandbox' || q.env === 'production' ? q.env : getConfiguredEnvironment();
293
+ if (q.env === 'sandbox' || q.env === 'production')
294
+ return q.env;
295
+ // For root router, detect environment from available signals in priority order:
296
+ //
297
+ // 1. `resource` query param (RFC 9728) — MCP clients like Cline include the
298
+ // full target MCP URL, e.g. resource=https://host/sandbox/mcp, which
299
+ // encodes the env directly in its path.
300
+ //
301
+ // 2. Per-env RuNames (EBAY_SANDBOX_RUNAME / EBAY_PRODUCTION_RUNAME) via
302
+ // -SB- / -PR- segment — if only ONE is configured, it's definitive.
303
+ // If BOTH are configured they conflict; skip to next step.
304
+ //
305
+ // 3. Generic RuName (EBAY_RUNAME / legacy EBAY_REDIRECT_URI) to disambiguate
306
+ // when both or neither env-specific vars are set.
307
+ //
308
+ // 4. EBAY_ENVIRONMENT env var — last resort only.
309
+ // Step 1: resource param (most reliable for RFC 9728-aware clients).
310
+ const resourceParam = q.resource;
311
+ if (resourceParam) {
312
+ if (resourceParam.includes('/sandbox/') || resourceParam.endsWith('/sandbox')) {
313
+ return 'sandbox';
314
+ }
315
+ if (resourceParam.includes('/production/') || resourceParam.endsWith('/production')) {
316
+ return 'production';
317
+ }
318
+ }
319
+ // 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;
323
+ const sandboxDetected = ruNameToEnvironment(sandboxRuName);
324
+ const productionDetected = ruNameToEnvironment(productionRuName);
325
+ if (sandboxDetected && !productionDetected)
326
+ return 'sandbox';
327
+ if (productionDetected && !sandboxDetected)
328
+ return 'production';
329
+ // Step 3: generic RuName to disambiguate when both/neither env-specific are set.
330
+ const genericDetected = ruNameToEnvironment(genericRuName);
331
+ if (genericDetected)
332
+ return genericDetected;
333
+ // Step 4: final fallback.
334
+ return getConfiguredEnvironment();
220
335
  }
221
336
  // ── RFC 8414 – Authorization Server Metadata ──────────────────────────
337
+ // For env-scoped routers: endpoints are relative to the env base URL.
338
+ // For the ROOT router: endpoints are relative to the DEFAULT environment's
339
+ // base URL (not root). This ensures that MCP clients that cached root
340
+ // auth-server discovery (e.g. Cline) see env-specific authorize/token/
341
+ // register URLs and update their cached endpoints on the next request.
222
342
  router.get('/.well-known/oauth-authorization-server', (_req, res) => {
343
+ // env-scoped: use as-is; root: redirect to the configured default env sub-path
344
+ const endpointBase = hardcodedEnv ? routeBaseUrl : `${serverUrl}/${getConfiguredEnvironment()}`;
223
345
  res.json({
224
346
  issuer: routeBaseUrl,
225
- authorization_endpoint: `${routeBaseUrl}/authorize`,
226
- token_endpoint: `${routeBaseUrl}/token`,
227
- registration_endpoint: `${routeBaseUrl}/register`,
347
+ authorization_endpoint: `${endpointBase}/authorize`,
348
+ token_endpoint: `${endpointBase}/token`,
349
+ registration_endpoint: `${endpointBase}/register`,
228
350
  response_types_supported: ['code'],
229
351
  grant_types_supported: ['authorization_code'],
230
352
  code_challenge_methods_supported: ['S256'],
@@ -245,7 +367,9 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
245
367
  return;
246
368
  }
247
369
  const uris = redirectUris;
248
- const client = await authStore.registerClient(uris, clientName);
370
+ // Tag the registered client with the env so that root /authorize can use
371
+ // client.environment as a fallback when no ?env= param is present.
372
+ const client = await authStore.registerClient(uris, clientName, hardcodedEnv ?? undefined);
249
373
  serverLogger.info(`[${prefix || 'root'}/register] MCP client registered`, {
250
374
  clientId: client.clientId,
251
375
  redirectUris: client.redirectUris,
@@ -265,7 +389,19 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
265
389
  try {
266
390
  const q = req.query;
267
391
  const { client_id: clientId, redirect_uri: redirectUri, response_type: responseType, state: mcpState, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod, } = q;
268
- const environment = resolveEnv(req);
392
+ // Environment resolution (in priority order):
393
+ // 1. URL path prefix (/sandbox, /production) → authoritative [hardcodedEnv]
394
+ // 2. Explicit ?env= query param
395
+ // 3. ?resource param (RFC 9728 — full MCP URL encodes env in its path)
396
+ // 4. RuName -SB-/-PR- segment / EBAY_ENVIRONMENT fallback (see resolveEnv)
397
+ const envSource = hardcodedEnv
398
+ ? 'path'
399
+ : q.env === 'sandbox' || q.env === 'production'
400
+ ? 'query'
401
+ : q.resource && (q.resource.includes('/sandbox/') || q.resource.includes('/production/'))
402
+ ? 'resource'
403
+ : 'runame';
404
+ let environment = resolveEnv(req);
269
405
  serverLogger.info(`[${prefix || 'root'}/authorize] Request received`, {
270
406
  clientId,
271
407
  redirectUri,
@@ -273,6 +409,7 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
273
409
  hasPkce: !!codeChallenge,
274
410
  pkceMethod: codeChallengeMethod,
275
411
  environment,
412
+ envSource,
276
413
  hasMcpState: !!mcpState,
277
414
  });
278
415
  if (responseType !== 'code') {
@@ -286,10 +423,27 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
286
423
  return;
287
424
  }
288
425
  let client = await authStore.getClient(clientId);
426
+ // For root router (hardcodedEnv = null): if the client was previously
427
+ // registered through an env-scoped path (e.g. /sandbox/register), use
428
+ // that env instead of the generic fallback — even if ?env= was not sent.
429
+ // This fixes the case where Cline has cached root /authorize but the
430
+ // client record was tagged as sandbox from an earlier discovery pass.
431
+ if (!hardcodedEnv && client?.environment) {
432
+ if (environment !== client.environment) {
433
+ serverLogger.info(`[root/authorize] Overriding env from client registration`, {
434
+ clientId,
435
+ resolvedEnv: environment,
436
+ clientEnv: client.environment,
437
+ });
438
+ environment = client.environment;
439
+ }
440
+ }
289
441
  if (!client) {
290
442
  if (redirectUri && isTrustedDesktopRedirectUri(redirectUri)) {
291
443
  serverLogger.info(`[${prefix || 'root'}/authorize] Auto-registering trusted desktop MCP client`, { clientId, redirectUri });
292
- client = await authStore.registerClientWithId(clientId, [redirectUri]);
444
+ // Tag the new client with the already-resolved env so subsequent root
445
+ // /authorize calls also land on the correct env without re-discovery.
446
+ client = await authStore.registerClientWithId(clientId, [redirectUri], undefined, environment);
293
447
  }
294
448
  else {
295
449
  serverLogger.warn(`[${prefix || 'root'}/authorize] Rejected: unknown client_id`, {
@@ -322,6 +476,28 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
322
476
  });
323
477
  return;
324
478
  }
479
+ // Validate that the loaded credentials (RuName segment) actually match the
480
+ // requested environment. This is the authoritative check — if the RuName
481
+ // contains -PR- but the request is for sandbox (or vice-versa), we must
482
+ // fail fast rather than silently issuing tokens for the wrong environment.
483
+ const credCheck = validateCredentialsForEnvironment(environment);
484
+ serverLogger.info(`[${prefix || 'root'}/authorize] Credential check`, {
485
+ environment,
486
+ envSource,
487
+ ruName: ebayConfig.redirectUri,
488
+ ruNameDetectedEnv: credCheck.detectedEnv,
489
+ credentialValid: credCheck.valid,
490
+ });
491
+ if (!credCheck.valid) {
492
+ serverLogger.error(`[${prefix || 'root'}/authorize] RuName/environment mismatch`, {
493
+ error: credCheck.error,
494
+ });
495
+ res.status(500).json({
496
+ error: 'server_misconfiguration',
497
+ error_description: credCheck.error,
498
+ });
499
+ return;
500
+ }
325
501
  const stateRecord = await authStore.createOAuthState(environment, undefined, {
326
502
  mcpClientId: clientId,
327
503
  mcpRedirectUri: redirectUri,
@@ -348,6 +524,16 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
348
524
  });
349
525
  // ── RFC 6749 – Token Endpoint ─────────────────────────────────────────
350
526
  router.post('/token', async (req, res) => {
527
+ // Entry-level log fires before ANY validation so we can confirm whether
528
+ // Cline's token request reaches the server at all. If this log never
529
+ // appears after a successful vscode:// deep-link redirect, the request
530
+ // is being dropped before it reaches the server (TLS trust issue, wrong
531
+ // URL, or the deep link is silently swallowed by VS Code).
532
+ serverLogger.info(`[${prefix || 'root'}/token] Request received`, {
533
+ contentType: req.headers['content-type'],
534
+ origin: req.headers.origin,
535
+ hasBody: !!req.body,
536
+ });
351
537
  if (!req.body || typeof req.body !== 'object') {
352
538
  res.status(400).json({
353
539
  error: 'invalid_request',
@@ -442,6 +628,17 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
442
628
  if (CONFIG.oauthStartKey) {
443
629
  oauthUrl.searchParams.set('key', CONFIG.oauthStartKey);
444
630
  }
631
+ // RFC 9728 §5.1 / RFC 6750: include resource_metadata in WWW-Authenticate
632
+ // so that MCP clients (Cline, Claude Desktop, etc.) can discover the
633
+ // correct env-scoped authorization server without probing well-known URLs.
634
+ // The path-based well-known URI for a resource at /sandbox/mcp is
635
+ // /.well-known/oauth-protected-resource/sandbox/mcp
636
+ // For the root MCP path we fall back to the generic well-known endpoint.
637
+ const resourcePath = req.path; // e.g. "" (when router is at /sandbox)
638
+ const fullResourcePath = hardcodedEnv ? `/${hardcodedEnv}${resourcePath}` : resourcePath;
639
+ // Normalise: strip trailing slashes, ensure it does not double-encode
640
+ const resourceMetadataUrl = `${serverUrl}/.well-known/oauth-protected-resource${fullResourcePath.replace(/\/$/, '')}`;
641
+ res.setHeader('WWW-Authenticate', `Bearer realm="mcp", resource_metadata="${resourceMetadataUrl}"`);
445
642
  if (req.method === 'GET') {
446
643
  res.redirect(oauthUrl.toString());
447
644
  return;
@@ -451,6 +648,7 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
451
648
  authorization_required: true,
452
649
  environment: requestedEnv,
453
650
  authorization_url: oauthUrl.toString(),
651
+ resource_metadata: resourceMetadataUrl,
454
652
  message: 'No valid hosted session token was provided. Complete the browser OAuth flow using authorization_url, then retry with Authorization: Bearer <session-token>.',
455
653
  });
456
654
  };
@@ -628,13 +826,60 @@ async function handleOAuthCallback(req, res, serverUrl) {
628
826
  if (stateRecord.mcpState) {
629
827
  redirectUrl.searchParams.set('state', stateRecord.mcpState);
630
828
  }
829
+ const finalRedirectUrl = redirectUrl.toString();
631
830
  serverLogger.info('[oauth/callback] MCP OAuth flow complete, redirecting to client', {
632
831
  clientId: stateRecord.mcpClientId,
633
832
  userId,
634
833
  authCodePrefix: authCodeRecord.code.substring(0, 8),
635
834
  expiresAt: authCodeRecord.expiresAt,
835
+ // Full URL logged so we can verify vscode:// deep-link format exactly.
836
+ // NOTE: contains auth code — treat as sensitive, rotate immediately on exposure.
837
+ redirectUrl: finalRedirectUrl,
636
838
  });
637
- res.redirect(redirectUrl.toString());
839
+ // ── Custom-scheme redirect (vscode://, cursor://, etc.) ──────────────
840
+ // Browsers (Chrome 91+, Safari) block plain HTTP 302 → custom-scheme
841
+ // redirects without an explicit user gesture, causing the vscode:// URI
842
+ // to be silently swallowed before VS Code ever receives the deep link.
843
+ //
844
+ // Fix: serve an HTML page that uses window.location.href (page-level
845
+ // navigation; browsers allow this) and shows a manual button fallback.
846
+ // The page also shows a "close this tab" message after the redirect so
847
+ // the user knows the flow completed.
848
+ const isCustomScheme = redirectUrl.protocol !== 'http:' && redirectUrl.protocol !== 'https:';
849
+ if (isCustomScheme) {
850
+ const safeFinalUrl = htmlEscape(finalRedirectUrl);
851
+ res.status(200).send(`<!doctype html>
852
+ <html>
853
+ <head>
854
+ <meta charset="utf-8">
855
+ <title>Opening in VS Code…</title>
856
+ <style>
857
+ body { font-family: Inter, Arial, sans-serif; max-width: 520px; margin: 80px auto; text-align: center; line-height: 1.6; color: #111827; padding: 0 16px; }
858
+ .btn { display: inline-block; background: #111827; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 10px; font-size: 1rem; margin-top: 20px; }
859
+ .btn:hover { background: #1f2937; }
860
+ .muted { color: #6b7280; font-size: .9rem; margin-top: 24px; }
861
+ </style>
862
+ </head>
863
+ <body>
864
+ <h2>eBay authentication complete ✓</h2>
865
+ <p>Opening VS Code to finish connecting…</p>
866
+ <a class="btn" href="${safeFinalUrl}" id="open-link">Open in VS Code</a>
867
+ <p class="muted">If VS Code does not open automatically, click the button above.<br>You may close this tab once VS Code activates.</p>
868
+ <script>
869
+ // Give the page a moment to render, then navigate.
870
+ // window.location.href (user-initiated via script on page load) is
871
+ // allowed by Chrome/Safari for custom URI schemes.
872
+ setTimeout(function() {
873
+ window.location.href = ${JSON.stringify(finalRedirectUrl)};
874
+ }, 300);
875
+ </script>
876
+ </body>
877
+ </html>`);
878
+ return;
879
+ }
880
+ // For http:// / https:// redirect URIs (e.g. localhost loopback), a
881
+ // plain 302 is fine and is the standard OAuth response.
882
+ res.redirect(finalRedirectUrl);
638
883
  return;
639
884
  }
640
885
  // ── Non-MCP flow: show tokens page ────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ebay-mcp-remote-edition",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
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",