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 +45 -0
- package/build/auth/multi-user-store.js +32 -6
- package/build/config/environment.js +47 -14
- package/build/server-http.js +254 -9
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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);
|
package/build/server-http.js
CHANGED
|
@@ -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
|
-
|
|
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: `${
|
|
226
|
-
token_endpoint: `${
|
|
227
|
-
registration_endpoint: `${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|