reflect-mcp 1.0.13 → 1.0.14
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/dist/cli.js +22 -57
- package/dist/pkcehandler.d.ts +7 -1
- package/dist/pkcehandler.js +166 -44
- package/dist/server.d.ts +9 -0
- package/dist/server.js +41 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -34,55 +34,18 @@ function isPortInUse(port) {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* Find a free port starting from startPort.
|
|
38
|
+
* Returns the first available port within maxAttempts tries,
|
|
39
|
+
* or throws if none is found.
|
|
38
40
|
*/
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
// Linux: try lsof first, fall back to fuser
|
|
47
|
-
try {
|
|
48
|
-
pids = execSync(`lsof -ti:${port}`, { encoding: "utf-8" }).trim();
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
pids = execSync(`fuser ${port}/tcp 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
if (pids) {
|
|
55
|
-
console.log(`⚠️ Port ${port} in use by PID(s): ${pids.replace(/\n/g, ", ")}`);
|
|
56
|
-
for (const pid of pids.split(/\s+/)) {
|
|
57
|
-
if (pid) {
|
|
58
|
-
try {
|
|
59
|
-
process.kill(parseInt(pid), "SIGKILL");
|
|
60
|
-
}
|
|
61
|
-
catch { /* already dead */ }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
console.log(`✅ Killed existing process(es) on port ${port}`);
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
// No process found on port, or kill failed - that's fine
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Ensure the port is free, killing any existing process if needed
|
|
75
|
-
*/
|
|
76
|
-
async function ensurePortFree(port) {
|
|
77
|
-
if (await isPortInUse(port)) {
|
|
78
|
-
killProcessOnPort(port);
|
|
79
|
-
// Wait a moment for the port to be released
|
|
80
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
81
|
-
// Check again
|
|
82
|
-
if (await isPortInUse(port)) {
|
|
83
|
-
throw new Error(`Port ${port} is still in use after attempting to free it`);
|
|
41
|
+
async function findFreePort(startPort, maxAttempts = 10) {
|
|
42
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
43
|
+
const candidate = startPort + i;
|
|
44
|
+
if (!(await isPortInUse(candidate))) {
|
|
45
|
+
return candidate;
|
|
84
46
|
}
|
|
85
47
|
}
|
|
48
|
+
throw new Error(`No free port found in range ${startPort}–${startPort + maxAttempts - 1}`);
|
|
86
49
|
}
|
|
87
50
|
const REFLECT_CLIENT_ID = "55798f25d5a24efb95e4174fff3d219e";
|
|
88
51
|
const platform = os.platform();
|
|
@@ -180,9 +143,6 @@ async function install(installArgs) {
|
|
|
180
143
|
const nodePath = process.execPath;
|
|
181
144
|
const cliPath = process.argv[1];
|
|
182
145
|
console.log("📦 Installing Reflect MCP Server as auto-start service...\n");
|
|
183
|
-
// Kill any stale processes on the port
|
|
184
|
-
killProcessOnPort(port);
|
|
185
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
186
146
|
if (platform === "darwin") {
|
|
187
147
|
installDarwin(nodePath, cliPath, expandedDbPath, port);
|
|
188
148
|
}
|
|
@@ -203,7 +163,7 @@ async function install(installArgs) {
|
|
|
203
163
|
"mcpServers": {
|
|
204
164
|
"reflect": {
|
|
205
165
|
"command": "npx",
|
|
206
|
-
"args": ["-y", "mcp-remote", "
|
|
166
|
+
"args": ["-y", "mcp-remote", "http://localhost:${port}/mcp", "--port", "4209"]
|
|
207
167
|
}
|
|
208
168
|
}
|
|
209
169
|
}`);
|
|
@@ -401,10 +361,10 @@ function statusLinux() {
|
|
|
401
361
|
}
|
|
402
362
|
async function runServer(serverArgs) {
|
|
403
363
|
let dbPath;
|
|
404
|
-
let
|
|
364
|
+
let requestedPort = 3000;
|
|
405
365
|
for (let i = 0; i < serverArgs.length; i++) {
|
|
406
366
|
if (serverArgs[i] === "--port" && serverArgs[i + 1]) {
|
|
407
|
-
|
|
367
|
+
requestedPort = parseInt(serverArgs[++i]);
|
|
408
368
|
}
|
|
409
369
|
else if (!serverArgs[i].startsWith("--")) {
|
|
410
370
|
dbPath = serverArgs[i];
|
|
@@ -418,22 +378,27 @@ async function runServer(serverArgs) {
|
|
|
418
378
|
}
|
|
419
379
|
dbPath = DEFAULT_DB_PATH;
|
|
420
380
|
}
|
|
421
|
-
//
|
|
381
|
+
// Find the first free port at or above the requested port.
|
|
382
|
+
// This allows multiple MCP clients to each get their own HTTP server
|
|
383
|
+
// without killing other running instances.
|
|
384
|
+
let port;
|
|
422
385
|
try {
|
|
423
|
-
await
|
|
386
|
+
port = await findFreePort(requestedPort);
|
|
424
387
|
}
|
|
425
388
|
catch (err) {
|
|
426
|
-
console.error(
|
|
389
|
+
console.error(`[reflect-mcp] ${err}`);
|
|
427
390
|
process.exit(1);
|
|
428
391
|
}
|
|
392
|
+
if (port !== requestedPort) {
|
|
393
|
+
console.error(`[reflect-mcp] Requested port ${requestedPort} is in use — using port ${port} instead`);
|
|
394
|
+
}
|
|
429
395
|
try {
|
|
430
396
|
await startReflectMCPServer({
|
|
431
397
|
clientId: REFLECT_CLIENT_ID,
|
|
432
398
|
port,
|
|
433
399
|
dbPath,
|
|
434
400
|
});
|
|
435
|
-
console.
|
|
436
|
-
console.log(`Database: ${dbPath}`);
|
|
401
|
+
console.error(`[reflect-mcp] HTTP server running on http://localhost:${port}`);
|
|
437
402
|
}
|
|
438
403
|
catch (err) {
|
|
439
404
|
console.error("Failed to start server:", err);
|
package/dist/pkcehandler.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PKCE OAuth Proxy (No Client Secret Required)
|
|
3
3
|
*
|
|
4
|
+
* Updated by Twice 🦸♂️
|
|
5
|
+
* Cloning capabilities enabled for multiple MCP clients
|
|
6
|
+
*
|
|
4
7
|
* This module provides a custom OAuth proxy that uses PKCE for authentication
|
|
5
8
|
* without requiring a client secret, suitable for public clients.
|
|
6
9
|
*/
|
|
@@ -25,6 +28,9 @@ export declare class PKCEOAuthProxy {
|
|
|
25
28
|
private tokens;
|
|
26
29
|
private recentlyExchangedCodes;
|
|
27
30
|
private cleanupInterval;
|
|
31
|
+
private tokenMutex;
|
|
32
|
+
private tokenMutexBusy;
|
|
33
|
+
private activeConnections;
|
|
28
34
|
constructor(options: PKCEOAuthProxyConfig);
|
|
29
35
|
private loadTokensFromDisk;
|
|
30
36
|
private saveTokensToDisk;
|
|
@@ -85,7 +91,7 @@ export declare class PKCEOAuthProxy {
|
|
|
85
91
|
client_name?: string;
|
|
86
92
|
redirect_uris?: string[];
|
|
87
93
|
}>;
|
|
88
|
-
loadUpstreamTokens(proxyToken: string): TokenData | null
|
|
94
|
+
loadUpstreamTokens(proxyToken: string): Promise<TokenData | null>;
|
|
89
95
|
getFirstValidToken(): TokenData | null;
|
|
90
96
|
private startCleanup;
|
|
91
97
|
destroy(): void;
|
package/dist/pkcehandler.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PKCE OAuth Proxy (No Client Secret Required)
|
|
3
3
|
*
|
|
4
|
+
* Updated by Twice 🦸♂️
|
|
5
|
+
* Cloning capabilities enabled for multiple MCP clients
|
|
6
|
+
*
|
|
4
7
|
* This module provides a custom OAuth proxy that uses PKCE for authentication
|
|
5
8
|
* without requiring a client secret, suitable for public clients.
|
|
6
9
|
*/
|
|
@@ -10,6 +13,56 @@ import * as path from "path";
|
|
|
10
13
|
import * as os from "os";
|
|
11
14
|
import { OAuthProxyError } from "fastmcp/auth";
|
|
12
15
|
// ============================================================================
|
|
16
|
+
// Write Queue - Prevents concurrent file I/O operations
|
|
17
|
+
// ============================================================================
|
|
18
|
+
/**
|
|
19
|
+
* Simple in-memory write queue that batches and serializes file writes.
|
|
20
|
+
* This prevents race conditions when multiple clients trigger disk writes simultaneously.
|
|
21
|
+
*/
|
|
22
|
+
class WriteQueue {
|
|
23
|
+
queue = [];
|
|
24
|
+
isProcessing = false;
|
|
25
|
+
/**
|
|
26
|
+
* Add a write operation to the queue and wait for it to complete
|
|
27
|
+
*/
|
|
28
|
+
async add(writeOperation) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const operation = async () => {
|
|
31
|
+
try {
|
|
32
|
+
await writeOperation();
|
|
33
|
+
resolve();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
reject(error);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
this.queue.push(operation);
|
|
40
|
+
this.processQueue();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Process the queue one operation at a time
|
|
45
|
+
*/
|
|
46
|
+
async processQueue() {
|
|
47
|
+
if (this.isProcessing || this.queue.length === 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.isProcessing = true;
|
|
51
|
+
const operation = this.queue.shift();
|
|
52
|
+
try {
|
|
53
|
+
await operation();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
this.isProcessing = false;
|
|
57
|
+
// Small delay to batch rapid writes together
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
59
|
+
this.processQueue();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Global write queue instance shared across all PKCEProxy instances
|
|
64
|
+
const globalWriteQueue = new WriteQueue();
|
|
65
|
+
// ============================================================================
|
|
13
66
|
// PKCEOAuthProxy Class
|
|
14
67
|
// ============================================================================
|
|
15
68
|
export class PKCEOAuthProxy {
|
|
@@ -21,6 +74,12 @@ export class PKCEOAuthProxy {
|
|
|
21
74
|
// Track tokens that have been exchanged but allow brief retry window
|
|
22
75
|
recentlyExchangedCodes = new Map();
|
|
23
76
|
cleanupInterval = null;
|
|
77
|
+
// In-memory mutex to prevent race conditions during token operations
|
|
78
|
+
// This ensures only one client can exchange a code at a time
|
|
79
|
+
tokenMutex = new Map();
|
|
80
|
+
tokenMutexBusy = false;
|
|
81
|
+
// Active connections counter for debugging
|
|
82
|
+
activeConnections = 0;
|
|
24
83
|
constructor(options) {
|
|
25
84
|
this.config = {
|
|
26
85
|
baseUrl: options.baseUrl,
|
|
@@ -60,8 +119,9 @@ export class PKCEOAuthProxy {
|
|
|
60
119
|
console.warn("[PKCEProxy] Failed to load tokens from disk:", error);
|
|
61
120
|
}
|
|
62
121
|
}
|
|
63
|
-
// Save tokens to disk
|
|
64
|
-
|
|
122
|
+
// Save tokens to disk - ASYNC with write queue
|
|
123
|
+
// This prevents blocking the event loop and prevents race conditions
|
|
124
|
+
async saveTokensToDisk() {
|
|
65
125
|
try {
|
|
66
126
|
const toStore = {};
|
|
67
127
|
for (const [key, value] of this.tokens) {
|
|
@@ -71,7 +131,11 @@ export class PKCEOAuthProxy {
|
|
|
71
131
|
expiresAt: value.expiresAt.toISOString(),
|
|
72
132
|
};
|
|
73
133
|
}
|
|
74
|
-
|
|
134
|
+
// Use the global write queue to serialize this write operation
|
|
135
|
+
await globalWriteQueue.add(async () => {
|
|
136
|
+
await fs.promises.writeFile(this.config.tokenStoragePath, JSON.stringify(toStore, null, 2), "utf-8");
|
|
137
|
+
});
|
|
138
|
+
console.log(`[PKCEProxy] Saved ${toStore.length} tokens to disk`);
|
|
75
139
|
}
|
|
76
140
|
catch (error) {
|
|
77
141
|
console.error("[PKCEProxy] Failed to save tokens to disk:", error);
|
|
@@ -106,8 +170,8 @@ export class PKCEOAuthProxy {
|
|
|
106
170
|
console.warn("[PKCEProxy] Failed to load transactions from disk:", error);
|
|
107
171
|
}
|
|
108
172
|
}
|
|
109
|
-
// Save transactions to disk (survives server restarts)
|
|
110
|
-
saveTransactionsToDisk() {
|
|
173
|
+
// Save transactions to disk (survives server restarts) - ASYNC with write queue
|
|
174
|
+
async saveTransactionsToDisk() {
|
|
111
175
|
try {
|
|
112
176
|
const toStore = {};
|
|
113
177
|
for (const [key, value] of this.transactions) {
|
|
@@ -122,7 +186,11 @@ export class PKCEOAuthProxy {
|
|
|
122
186
|
expiresAt: value.expiresAt.toISOString(),
|
|
123
187
|
};
|
|
124
188
|
}
|
|
125
|
-
|
|
189
|
+
// Use the global write queue to serialize this write operation
|
|
190
|
+
await globalWriteQueue.add(async () => {
|
|
191
|
+
await fs.promises.writeFile(this.config.transactionStoragePath, JSON.stringify(toStore, null, 2), "utf-8");
|
|
192
|
+
});
|
|
193
|
+
console.log(`[PKCEProxy] Saved ${toStore.length} transactions to disk`);
|
|
126
194
|
}
|
|
127
195
|
catch (error) {
|
|
128
196
|
console.error("[PKCEProxy] Failed to save transactions to disk:", error);
|
|
@@ -179,7 +247,7 @@ export class PKCEOAuthProxy {
|
|
|
179
247
|
expiresAt: new Date(Date.now() + 600 * 1000), // 10 minutes
|
|
180
248
|
};
|
|
181
249
|
this.transactions.set(transactionId, transaction);
|
|
182
|
-
this.saveTransactionsToDisk(); // Persist to survive restarts
|
|
250
|
+
await this.saveTransactionsToDisk(); // Persist to survive restarts (async now)
|
|
183
251
|
console.log("[PKCEProxy] Created transaction:", transactionId);
|
|
184
252
|
// Build upstream authorization URL
|
|
185
253
|
const authUrl = new URL(this.config.authorizationEndpoint);
|
|
@@ -228,7 +296,7 @@ export class PKCEOAuthProxy {
|
|
|
228
296
|
}
|
|
229
297
|
if (transaction.expiresAt < new Date()) {
|
|
230
298
|
this.transactions.delete(state);
|
|
231
|
-
this.saveTransactionsToDisk();
|
|
299
|
+
await this.saveTransactionsToDisk();
|
|
232
300
|
console.error("[PKCEProxy] Transaction expired, created:", transaction.createdAt, "expired:", transaction.expiresAt);
|
|
233
301
|
return new Response(JSON.stringify({ error: "transaction_expired" }), {
|
|
234
302
|
status: 400,
|
|
@@ -265,14 +333,14 @@ export class PKCEOAuthProxy {
|
|
|
265
333
|
refreshToken: tokens.refresh_token,
|
|
266
334
|
expiresAt: new Date(Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
267
335
|
});
|
|
268
|
-
this.saveTokensToDisk(); // Persist to disk
|
|
336
|
+
await this.saveTokensToDisk(); // Persist to disk (async now)
|
|
269
337
|
// Redirect back to client with our proxy token
|
|
270
338
|
const clientRedirect = new URL(transaction.clientCallbackUrl);
|
|
271
339
|
clientRedirect.searchParams.set("code", proxyToken);
|
|
272
340
|
clientRedirect.searchParams.set("state", transaction.clientState);
|
|
273
341
|
// Clean up transaction
|
|
274
342
|
this.transactions.delete(state);
|
|
275
|
-
this.saveTransactionsToDisk();
|
|
343
|
+
await this.saveTransactionsToDisk();
|
|
276
344
|
console.log("[PKCEProxy] Redirecting to client:", clientRedirect.toString());
|
|
277
345
|
return new Response(null, {
|
|
278
346
|
status: 302,
|
|
@@ -285,11 +353,12 @@ export class PKCEOAuthProxy {
|
|
|
285
353
|
if (!params.code) {
|
|
286
354
|
throw new OAuthProxyError("invalid_request", "Missing authorization code", 400);
|
|
287
355
|
}
|
|
356
|
+
console.log(`[PKCEProxy] Exchange requested for code: ${params.code.slice(0, 8)}... (connections: ${this.activeConnections})`);
|
|
288
357
|
// Check if this code was recently exchanged (retry tolerance)
|
|
289
358
|
// This allows mcp-remote to retry if the first request timed out but actually succeeded
|
|
290
359
|
const recentExchange = this.recentlyExchangedCodes.get(params.code);
|
|
291
360
|
if (recentExchange && recentExchange.expiresAt > new Date()) {
|
|
292
|
-
console.log(
|
|
361
|
+
console.log(`[PKCEProxy] Returning cached token for retry of code: ${params.code.slice(0, 8)}...`);
|
|
293
362
|
const tokenData = this.tokens.get(recentExchange.accessToken);
|
|
294
363
|
if (tokenData) {
|
|
295
364
|
const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
|
|
@@ -300,33 +369,83 @@ export class PKCEOAuthProxy {
|
|
|
300
369
|
};
|
|
301
370
|
}
|
|
302
371
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
// Note: Not returning refresh_token since Reflect doesn't support refresh_token grant
|
|
328
|
-
// This tells the MCP client to re-authenticate via OAuth when the token expires
|
|
372
|
+
// Acquire mutex for this specific code to prevent race conditions
|
|
373
|
+
// This ensures only ONE client can exchange a given code at a time
|
|
374
|
+
let releaseMutex;
|
|
375
|
+
const acquireMutex = async () => {
|
|
376
|
+
const codeKey = `code_${params.code}`;
|
|
377
|
+
while (this.tokenMutex.has(codeKey)) {
|
|
378
|
+
// Wait for the current exchange to complete
|
|
379
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
380
|
+
}
|
|
381
|
+
// Create a pending promise to indicate we're acquiring the lock
|
|
382
|
+
const pending = new Promise(resolve => {
|
|
383
|
+
releaseMutex = resolve;
|
|
384
|
+
this.tokenMutex.set(codeKey, Promise.resolve());
|
|
385
|
+
});
|
|
386
|
+
// Check if we got the lock before another concurrent request took it
|
|
387
|
+
if (this.tokenMutex.get(codeKey) !== pending) {
|
|
388
|
+
this.tokenMutex.delete(codeKey);
|
|
389
|
+
return acquireMutex(); // Try again
|
|
390
|
+
}
|
|
391
|
+
await pending;
|
|
392
|
+
};
|
|
393
|
+
const releaseMutexForCode = (code) => {
|
|
394
|
+
const codeKey = `code_${code}`;
|
|
395
|
+
this.tokenMutex.delete(codeKey);
|
|
329
396
|
};
|
|
397
|
+
try {
|
|
398
|
+
await acquireMutex();
|
|
399
|
+
// Check again after acquiring mutex - another request might have already processed this code
|
|
400
|
+
const cachedExchange = this.recentlyExchangedCodes.get(params.code);
|
|
401
|
+
if (cachedExchange && cachedExchange.expiresAt > new Date()) {
|
|
402
|
+
const cachedTokenData = this.tokens.get(cachedExchange.accessToken);
|
|
403
|
+
if (cachedTokenData) {
|
|
404
|
+
const expiresIn = Math.floor((cachedTokenData.expiresAt.getTime() - Date.now()) / 1000);
|
|
405
|
+
return {
|
|
406
|
+
access_token: cachedExchange.accessToken,
|
|
407
|
+
token_type: "Bearer",
|
|
408
|
+
expires_in: expiresIn > 0 ? expiresIn : 3600,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const tokenData = this.tokens.get(params.code);
|
|
413
|
+
if (!tokenData) {
|
|
414
|
+
console.error(`[PKCEProxy] Token not found for code: ${params.code}`);
|
|
415
|
+
console.error(`[PKCEProxy] Available tokens:`, Array.from(this.tokens.keys()).map(k => k.slice(0, 8) + "..."));
|
|
416
|
+
console.error(`[PKCEProxy] Recently exchanged codes:`, Array.from(this.recentlyExchangedCodes.keys()).map(k => k.slice(0, 8) + "..."));
|
|
417
|
+
throw new OAuthProxyError("invalid_grant", "Invalid or expired authorization code", 400);
|
|
418
|
+
}
|
|
419
|
+
// Remove the code but keep track of it for retry tolerance (30 second window)
|
|
420
|
+
// Mark as exchanged BEFORE saving to disk to prevent race conditions
|
|
421
|
+
this.tokens.delete(params.code);
|
|
422
|
+
// Generate a new access token for the client
|
|
423
|
+
const accessToken = this.generateId();
|
|
424
|
+
this.tokens.set(accessToken, tokenData);
|
|
425
|
+
// Store the exchange for retry tolerance (30 seconds) - mark as exchanged
|
|
426
|
+
this.recentlyExchangedCodes.set(params.code, {
|
|
427
|
+
accessToken,
|
|
428
|
+
expiresAt: new Date(Date.now() + 30 * 1000),
|
|
429
|
+
});
|
|
430
|
+
// Now save to disk - this is the only write operation during the critical section
|
|
431
|
+
await this.saveTokensToDisk();
|
|
432
|
+
const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
|
|
433
|
+
console.log(`[PKCEProxy] Issued access token (expires in ${expiresIn}s) for code: ${params.code.slice(0, 8)}...`);
|
|
434
|
+
return {
|
|
435
|
+
access_token: accessToken,
|
|
436
|
+
token_type: "Bearer",
|
|
437
|
+
expires_in: expiresIn > 0 ? expiresIn : 3600,
|
|
438
|
+
// Note: Not returning refresh_token since Reflect doesn't support refresh_token grant
|
|
439
|
+
// This tells the MCP client to re-authenticate via OAuth when the token expires
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
// Release the mutex
|
|
447
|
+
releaseMutexForCode(params.code);
|
|
448
|
+
}
|
|
330
449
|
}
|
|
331
450
|
// Handle refresh token exchange
|
|
332
451
|
// Note: Reflect's API doesn't support standard refresh_token grant
|
|
@@ -349,7 +468,7 @@ export class PKCEOAuthProxy {
|
|
|
349
468
|
};
|
|
350
469
|
}
|
|
351
470
|
// Load upstream tokens for a given proxy token
|
|
352
|
-
loadUpstreamTokens(proxyToken) {
|
|
471
|
+
async loadUpstreamTokens(proxyToken) {
|
|
353
472
|
const data = this.tokens.get(proxyToken);
|
|
354
473
|
if (!data) {
|
|
355
474
|
console.warn("[PKCEProxy] Token not found:", proxyToken.slice(0, 8) + "...");
|
|
@@ -360,7 +479,7 @@ export class PKCEOAuthProxy {
|
|
|
360
479
|
if (data.expiresAt < now) {
|
|
361
480
|
console.warn("[PKCEProxy] Token expired:", proxyToken.slice(0, 8) + "...", "expired at:", data.expiresAt, "now:", now);
|
|
362
481
|
this.tokens.delete(proxyToken);
|
|
363
|
-
this.saveTokensToDisk();
|
|
482
|
+
await this.saveTokensToDisk();
|
|
364
483
|
return null;
|
|
365
484
|
}
|
|
366
485
|
const timeRemaining = Math.floor((data.expiresAt.getTime() - now.getTime()) / 1000);
|
|
@@ -380,8 +499,8 @@ export class PKCEOAuthProxy {
|
|
|
380
499
|
return null;
|
|
381
500
|
}
|
|
382
501
|
// Cleanup expired transactions, tokens, and retry cache
|
|
383
|
-
startCleanup() {
|
|
384
|
-
|
|
502
|
+
async startCleanup() {
|
|
503
|
+
const cleanup = async () => {
|
|
385
504
|
const now = new Date();
|
|
386
505
|
let tokensChanged = false;
|
|
387
506
|
let transactionsChanged = false;
|
|
@@ -405,12 +524,15 @@ export class PKCEOAuthProxy {
|
|
|
405
524
|
}
|
|
406
525
|
}
|
|
407
526
|
if (tokensChanged) {
|
|
408
|
-
this.saveTokensToDisk();
|
|
527
|
+
await this.saveTokensToDisk();
|
|
409
528
|
}
|
|
410
529
|
if (transactionsChanged) {
|
|
411
|
-
this.saveTransactionsToDisk();
|
|
530
|
+
await this.saveTransactionsToDisk();
|
|
412
531
|
}
|
|
413
|
-
}
|
|
532
|
+
};
|
|
533
|
+
this.cleanupInterval = setInterval(cleanup, 60000); // Every minute
|
|
534
|
+
// Run cleanup immediately on startup
|
|
535
|
+
await cleanup();
|
|
414
536
|
}
|
|
415
537
|
destroy() {
|
|
416
538
|
if (this.cleanupInterval) {
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reflect MCP Server Factory
|
|
3
3
|
*
|
|
4
|
+
* Updated by Twice 🦸♂️
|
|
5
|
+
* Now handling multiple concurrent clients!
|
|
6
|
+
*
|
|
4
7
|
* Creates and configures the FastMCP server with PKCE OAuth
|
|
5
8
|
*/
|
|
6
9
|
export interface ServerConfig {
|
|
@@ -9,5 +12,11 @@ export interface ServerConfig {
|
|
|
9
12
|
dbPath?: string;
|
|
10
13
|
}
|
|
11
14
|
export declare function startReflectMCPServer(config: ServerConfig): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Start the Reflect MCP server in stdio mode.
|
|
17
|
+
* Used when an HTTP server is already running on the port (e.g. a second MCP client).
|
|
18
|
+
* Reads the cached OAuth token from disk instead of running the full OAuth flow.
|
|
19
|
+
*/
|
|
20
|
+
export declare function startReflectMCPServerStdio(config: ServerConfig): Promise<void>;
|
|
12
21
|
export { PKCEOAuthProxy } from "./pkcehandler.js";
|
|
13
22
|
export * from "./utils.js";
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reflect MCP Server Factory
|
|
3
3
|
*
|
|
4
|
+
* Updated by Twice 🦸♂️
|
|
5
|
+
* Now handling multiple concurrent clients!
|
|
6
|
+
*
|
|
4
7
|
* Creates and configures the FastMCP server with PKCE OAuth
|
|
5
8
|
*/
|
|
6
9
|
import { FastMCP } from "fastmcp";
|
|
@@ -44,7 +47,7 @@ export async function startReflectMCPServer(config) {
|
|
|
44
47
|
}
|
|
45
48
|
const token = authHeader.slice(7);
|
|
46
49
|
try {
|
|
47
|
-
const tokenData = pkceProxy.loadUpstreamTokens(token);
|
|
50
|
+
const tokenData = await pkceProxy.loadUpstreamTokens(token);
|
|
48
51
|
if (!tokenData) {
|
|
49
52
|
console.warn("[Auth] Token validation failed for:", token.slice(0, 8) + "... - triggering 401");
|
|
50
53
|
// Throw Response to trigger re-authentication (per FastMCP docs)
|
|
@@ -85,6 +88,43 @@ export async function startReflectMCPServer(config) {
|
|
|
85
88
|
transportType: "httpStream",
|
|
86
89
|
});
|
|
87
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Start the Reflect MCP server in stdio mode.
|
|
93
|
+
* Used when an HTTP server is already running on the port (e.g. a second MCP client).
|
|
94
|
+
* Reads the cached OAuth token from disk instead of running the full OAuth flow.
|
|
95
|
+
*/
|
|
96
|
+
export async function startReflectMCPServerStdio(config) {
|
|
97
|
+
const port = config.port || 3000;
|
|
98
|
+
const baseUrl = `http://localhost:${port}`;
|
|
99
|
+
// Instantiate proxy only to read tokens from disk — no HTTP server needed
|
|
100
|
+
const pkceProxy = new PKCEOAuthProxy({
|
|
101
|
+
baseUrl,
|
|
102
|
+
clientId: config.clientId,
|
|
103
|
+
authorizationEndpoint: "https://reflect.app/oauth",
|
|
104
|
+
tokenEndpoint: "https://reflect.app/api/oauth/token",
|
|
105
|
+
scopes: ["read:graph", "write:graph"],
|
|
106
|
+
});
|
|
107
|
+
const server = new FastMCP({
|
|
108
|
+
name: "Reflect MCP Server",
|
|
109
|
+
// For stdio, FastMCP calls authenticate(undefined). We load the token from disk.
|
|
110
|
+
authenticate: async (_request) => {
|
|
111
|
+
const tokenData = pkceProxy.getFirstValidToken();
|
|
112
|
+
if (!tokenData) {
|
|
113
|
+
console.error("[Auth] No valid token on disk. Connect via HTTP mode first to complete OAuth.");
|
|
114
|
+
throw new Error("No valid token. Please authenticate via HTTP mode first.");
|
|
115
|
+
}
|
|
116
|
+
const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
|
|
117
|
+
console.error("[Auth] Stdio mode: token loaded from disk, expires in:", expiresIn, "seconds");
|
|
118
|
+
return {
|
|
119
|
+
accessToken: tokenData.accessToken,
|
|
120
|
+
expiresIn,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
version: "1.0.0",
|
|
124
|
+
});
|
|
125
|
+
registerTools(server, config.dbPath);
|
|
126
|
+
await server.start({ transportType: "stdio" });
|
|
127
|
+
}
|
|
88
128
|
// Also export for programmatic use
|
|
89
129
|
export { PKCEOAuthProxy } from "./pkcehandler.js";
|
|
90
130
|
export * from "./utils.js";
|