reflect-mcp 1.0.15 → 1.0.17

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.
@@ -28,6 +28,7 @@ export declare class PKCEOAuthProxy {
28
28
  private tokens;
29
29
  private recentlyExchangedCodes;
30
30
  private cleanupInterval;
31
+ private pendingAuthTransaction;
31
32
  private activeConnections;
32
33
  constructor(options: PKCEOAuthProxyConfig);
33
34
  private loadTokensFromDisk;
@@ -66,7 +67,6 @@ export declare class PKCEOAuthProxy {
66
67
  }): Promise<{
67
68
  access_token: string;
68
69
  token_type: string;
69
- expires_in: number;
70
70
  refresh_token?: string;
71
71
  scope?: string;
72
72
  }>;
@@ -78,7 +78,6 @@ export declare class PKCEOAuthProxy {
78
78
  }): Promise<{
79
79
  access_token: string;
80
80
  token_type: string;
81
- expires_in: number;
82
81
  refresh_token?: string;
83
82
  }>;
84
83
  registerClient(request: {
@@ -89,6 +88,8 @@ export declare class PKCEOAuthProxy {
89
88
  client_name?: string;
90
89
  redirect_uris?: string[];
91
90
  }>;
91
+ private validateUpstreamToken;
92
+ invalidateUpstreamToken(accessToken: string): Promise<void>;
92
93
  loadUpstreamTokens(proxyToken: string): Promise<TokenData | null>;
93
94
  getFirstValidToken(): TokenData | null;
94
95
  private startCleanup;
@@ -74,6 +74,8 @@ export class PKCEOAuthProxy {
74
74
  // Track tokens that have been exchanged but allow brief retry window
75
75
  recentlyExchangedCodes = new Map();
76
76
  cleanupInterval = null;
77
+ // Debounce: track pending auth so concurrent requests don't each open a browser
78
+ pendingAuthTransaction = null;
77
79
  // Active connections counter for debugging
78
80
  activeConnections = 0;
79
81
  constructor(options) {
@@ -98,15 +100,11 @@ export class PKCEOAuthProxy {
98
100
  const data = fs.readFileSync(this.config.tokenStoragePath, "utf-8");
99
101
  const stored = JSON.parse(data);
100
102
  for (const [key, value] of Object.entries(stored)) {
101
- const expiresAt = new Date(value.expiresAt);
102
- // Only load non-expired tokens
103
- if (expiresAt > new Date()) {
104
- this.tokens.set(key, {
105
- accessToken: value.accessToken,
106
- refreshToken: value.refreshToken,
107
- expiresAt,
108
- });
109
- }
103
+ this.tokens.set(key, {
104
+ accessToken: value.accessToken,
105
+ refreshToken: value.refreshToken,
106
+ expiresAt: new Date(value.expiresAt),
107
+ });
110
108
  }
111
109
  console.log(`[PKCEProxy] Loaded ${this.tokens.size} tokens from disk`);
112
110
  }
@@ -245,6 +243,15 @@ export class PKCEOAuthProxy {
245
243
  headers: { Location: clientRedirect.toString() },
246
244
  });
247
245
  }
246
+ // Debounce: if another request already started auth, reuse its redirect
247
+ // instead of opening yet another browser tab
248
+ if (this.pendingAuthTransaction && this.pendingAuthTransaction.expiresAt > new Date()) {
249
+ console.log("[PKCEProxy] Auth already in progress — reusing pending transaction:", this.pendingAuthTransaction.transactionId.slice(0, 8) + "...");
250
+ return new Response(null, {
251
+ status: 302,
252
+ headers: { Location: this.pendingAuthTransaction.authUrl },
253
+ });
254
+ }
248
255
  // Generate our own PKCE for upstream
249
256
  const pkce = this.generatePKCE();
250
257
  const transactionId = this.generateId();
@@ -271,6 +278,12 @@ export class PKCEOAuthProxy {
271
278
  authUrl.searchParams.set("state", transactionId); // Use transaction ID as state
272
279
  authUrl.searchParams.set("code_challenge", pkce.challenge);
273
280
  authUrl.searchParams.set("code_challenge_method", "S256");
281
+ // Store as pending so concurrent requests reuse this instead of opening more browsers
282
+ this.pendingAuthTransaction = {
283
+ transactionId,
284
+ authUrl: authUrl.toString(),
285
+ expiresAt: new Date(Date.now() + 60 * 1000), // 60 second debounce window
286
+ };
274
287
  console.log("[PKCEProxy] Redirecting to:", authUrl.toString());
275
288
  return new Response(null, {
276
289
  status: 302,
@@ -338,21 +351,22 @@ export class PKCEOAuthProxy {
338
351
  });
339
352
  }
340
353
  const tokens = await tokenResponse.json();
341
- console.log("[PKCEProxy] Got tokens, expires_in:", tokens.expires_in);
354
+ console.log("[PKCEProxy] Reflect token response:", JSON.stringify(tokens, null, 2));
342
355
  // Generate a proxy token to give to the client
343
356
  const proxyToken = this.generateId();
344
357
  this.tokens.set(proxyToken, {
345
358
  accessToken: tokens.access_token,
346
359
  refreshToken: tokens.refresh_token,
347
- expiresAt: new Date(Date.now() + (tokens.expires_in || 3600) * 1000),
360
+ expiresAt: new Date(Date.now() + (tokens.expires_in || 365 * 24 * 3600) * 1000),
348
361
  });
349
362
  await this.saveTokensToDisk(); // Persist to disk (async now)
350
363
  // Redirect back to client with our proxy token
351
364
  const clientRedirect = new URL(transaction.clientCallbackUrl);
352
365
  clientRedirect.searchParams.set("code", proxyToken);
353
366
  clientRedirect.searchParams.set("state", transaction.clientState);
354
- // Clean up transaction
367
+ // Clean up transaction and pending auth debounce
355
368
  this.transactions.delete(state);
369
+ this.pendingAuthTransaction = null;
356
370
  await this.saveTransactionsToDisk();
357
371
  console.log("[PKCEProxy] Redirecting to client:", clientRedirect.toString());
358
372
  return new Response(null, {
@@ -378,7 +392,6 @@ export class PKCEOAuthProxy {
378
392
  return {
379
393
  access_token: recentExchange.accessToken,
380
394
  token_type: "Bearer",
381
- expires_in: expiresIn > 0 ? expiresIn : 3600,
382
395
  };
383
396
  }
384
397
  }
@@ -402,25 +415,53 @@ export class PKCEOAuthProxy {
402
415
  });
403
416
  // Now save to disk
404
417
  await this.saveTokensToDisk();
405
- const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
406
- console.log(`[PKCEProxy] Issued access token (expires in ${expiresIn}s) for code: ${params.code.slice(0, 8)}...`);
418
+ console.log(`[PKCEProxy] Issued access token for code: ${params.code.slice(0, 8)}...`);
419
+ const refreshToken = `refresh_${this.generateId()}`;
420
+ this.tokens.set(refreshToken, { ...tokenData });
421
+ await this.saveTokensToDisk();
407
422
  return {
408
423
  access_token: accessToken,
409
424
  token_type: "Bearer",
410
- expires_in: expiresIn > 0 ? expiresIn : 3600,
411
- // Note: Not returning refresh_token since Reflect doesn't support refresh_token grant
412
- // This tells the MCP client to re-authenticate via OAuth when the token expires
425
+ refresh_token: refreshToken,
413
426
  };
414
427
  }
415
- // Handle refresh token exchange
416
- // Note: Reflect's API doesn't support standard refresh_token grant
417
- // We throw an OAuthProxyError to trigger re-authentication via OAuth flow
418
428
  async exchangeRefreshToken(params) {
419
- console.log("[PKCEProxy] exchangeRefreshToken called - Reflect doesn't support refresh_token grant");
420
- console.log("[PKCEProxy] Triggering re-authentication via OAuth flow...");
421
- // Reflect's token endpoint only accepts authorization_code grant, not refresh_token
422
- // Throw OAuthProxyError so FastMCP handles it properly and triggers re-auth
423
- throw new OAuthProxyError("invalid_grant", "Refresh tokens are not supported. Please re-authenticate.", 400);
429
+ console.log(`[PKCEProxy] exchangeRefreshToken called with: ${params.refresh_token.slice(0, 12)}...`);
430
+ const tokenData = this.tokens.get(params.refresh_token);
431
+ if (tokenData) {
432
+ const newAccessToken = this.generateId();
433
+ this.tokens.set(newAccessToken, { ...tokenData });
434
+ const newRefreshToken = `refresh_${this.generateId()}`;
435
+ this.tokens.set(newRefreshToken, { ...tokenData });
436
+ this.tokens.delete(params.refresh_token);
437
+ await this.saveTokensToDisk();
438
+ console.log(`[PKCEProxy] Refreshed silently`);
439
+ return {
440
+ access_token: newAccessToken,
441
+ token_type: "Bearer",
442
+ refresh_token: newRefreshToken,
443
+ };
444
+ }
445
+ // Refresh token not found — fall back to any available token
446
+ const validToken = this.getFirstValidToken();
447
+ if (validToken) {
448
+ console.log("[PKCEProxy] Refresh token not found, using fallback token");
449
+ const newAccessToken = this.generateId();
450
+ this.tokens.set(newAccessToken, { ...validToken });
451
+ const newRefreshToken = `refresh_${this.generateId()}`;
452
+ this.tokens.set(newRefreshToken, { ...validToken });
453
+ this.tokens.delete(params.refresh_token);
454
+ await this.saveTokensToDisk();
455
+ console.log(`[PKCEProxy] Issued token from fallback`);
456
+ return {
457
+ access_token: newAccessToken,
458
+ token_type: "Bearer",
459
+ refresh_token: newRefreshToken,
460
+ };
461
+ }
462
+ // No tokens at all — force browser re-auth
463
+ console.log("[PKCEProxy] No tokens available — forcing re-authentication");
464
+ throw new OAuthProxyError("invalid_grant", "All tokens expired. Please re-authenticate.", 400);
424
465
  }
425
466
  // Handle /oauth/register (Dynamic Client Registration)
426
467
  async registerClient(request) {
@@ -432,13 +473,40 @@ export class PKCEOAuthProxy {
432
473
  redirect_uris: request.redirect_uris,
433
474
  };
434
475
  }
476
+ // Validate an upstream Reflect token by calling the API
477
+ async validateUpstreamToken(tokenData) {
478
+ try {
479
+ const response = await fetch("https://reflect.app/api/users/me", {
480
+ headers: { Authorization: `Bearer ${tokenData.accessToken}` },
481
+ });
482
+ if (response.ok)
483
+ return true;
484
+ console.warn("[PKCEProxy] Upstream token rejected by Reflect API:", response.status);
485
+ return false;
486
+ }
487
+ catch (error) {
488
+ // Network error — don't invalidate, assume token is still good
489
+ console.warn("[PKCEProxy] Network error validating token, keeping it:", error);
490
+ return true;
491
+ }
492
+ }
493
+ // Invalidate all tokens that share a given upstream access token
494
+ async invalidateUpstreamToken(accessToken) {
495
+ let changed = false;
496
+ for (const [id, token] of this.tokens) {
497
+ if (token.accessToken === accessToken) {
498
+ console.log("[PKCEProxy] Invalidating token with revoked upstream:", id.slice(0, 8) + "...");
499
+ this.tokens.delete(id);
500
+ changed = true;
501
+ }
502
+ }
503
+ if (changed)
504
+ await this.saveTokensToDisk();
505
+ }
435
506
  // Load upstream tokens for a given proxy token
436
507
  async loadUpstreamTokens(proxyToken) {
437
508
  const data = this.tokens.get(proxyToken);
438
509
  if (!data) {
439
- // Token not found — check if this is a stale/rotated token from a previous session.
440
- // For a local server, all clients share the same Reflect credentials, so we can
441
- // fall back to any currently-valid token rather than forcing a re-auth loop.
442
510
  const validToken = this.getFirstValidToken();
443
511
  if (validToken) {
444
512
  console.warn("[PKCEProxy] Stale token presented, mapping to current valid token:", proxyToken.slice(0, 8) + "...");
@@ -448,26 +516,12 @@ export class PKCEOAuthProxy {
448
516
  console.warn("[PKCEProxy] Total tokens in store:", this.tokens.size);
449
517
  return null;
450
518
  }
451
- const now = new Date();
452
- if (data.expiresAt < now) {
453
- console.warn("[PKCEProxy] Token expired:", proxyToken.slice(0, 8) + "...", "expired at:", data.expiresAt, "now:", now);
454
- this.tokens.delete(proxyToken);
455
- await this.saveTokensToDisk();
456
- return null;
457
- }
458
- const timeRemaining = Math.floor((data.expiresAt.getTime() - now.getTime()) / 1000);
459
- if (timeRemaining < 300) { // Less than 5 minutes remaining
460
- console.warn("[PKCEProxy] Token expiring soon:", proxyToken.slice(0, 8) + "...", "remaining:", timeRemaining, "seconds");
461
- }
462
519
  return data;
463
520
  }
464
- // Get first valid token (for stdio mode where we don't have specific token ID)
521
+ // Get first available token (any token in the store)
465
522
  getFirstValidToken() {
466
- const now = new Date();
467
523
  for (const [id, token] of this.tokens) {
468
- if (token.expiresAt > now) {
469
- return token;
470
- }
524
+ return token;
471
525
  }
472
526
  return null;
473
527
  }
@@ -475,7 +529,6 @@ export class PKCEOAuthProxy {
475
529
  async startCleanup() {
476
530
  const cleanup = async () => {
477
531
  const now = new Date();
478
- let tokensChanged = false;
479
532
  let transactionsChanged = false;
480
533
  for (const [id, tx] of this.transactions) {
481
534
  if (tx.expiresAt < now) {
@@ -483,22 +536,11 @@ export class PKCEOAuthProxy {
483
536
  transactionsChanged = true;
484
537
  }
485
538
  }
486
- for (const [id, token] of this.tokens) {
487
- if (token.expiresAt < now) {
488
- console.log("[PKCEProxy] Cleaning up expired token:", id.slice(0, 8) + "...");
489
- this.tokens.delete(id);
490
- tokensChanged = true;
491
- }
492
- }
493
- // Clean up expired retry cache entries
494
539
  for (const [code, data] of this.recentlyExchangedCodes) {
495
540
  if (data.expiresAt < now) {
496
541
  this.recentlyExchangedCodes.delete(code);
497
542
  }
498
543
  }
499
- if (tokensChanged) {
500
- await this.saveTokensToDisk();
501
- }
502
544
  if (transactionsChanged) {
503
545
  await this.saveTransactionsToDisk();
504
546
  }
package/dist/server.js CHANGED
@@ -56,12 +56,10 @@ export async function startReflectMCPServer(config) {
56
56
  statusText: "Unauthorized - Invalid or expired token",
57
57
  });
58
58
  }
59
- const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
60
- console.log("[Auth] Token validated, expires in:", expiresIn, "seconds");
59
+ console.log("[Auth] Token validated");
61
60
  return {
62
61
  accessToken: tokenData.accessToken,
63
62
  refreshToken: tokenData.refreshToken,
64
- expiresIn,
65
63
  };
66
64
  }
67
65
  catch (error) {
@@ -78,12 +76,13 @@ export async function startReflectMCPServer(config) {
78
76
  },
79
77
  version: "1.0.0",
80
78
  });
81
- // Register all tools
82
- registerTools(server, config.dbPath);
79
+ // Register all tools (pass proxy so tools can invalidate tokens on upstream 401)
80
+ registerTools(server, config.dbPath, pkceProxy);
83
81
  // Start server
84
82
  await server.start({
85
83
  httpStream: {
86
84
  port,
85
+ stateless: false,
87
86
  },
88
87
  transportType: "httpStream",
89
88
  });
@@ -113,11 +112,9 @@ export async function startReflectMCPServerStdio(config) {
113
112
  console.error("[Auth] No valid token on disk. Connect via HTTP mode first to complete OAuth.");
114
113
  throw new Error("No valid token. Please authenticate via HTTP mode first.");
115
114
  }
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");
115
+ console.error("[Auth] Stdio mode: token loaded from disk");
118
116
  return {
119
117
  accessToken: tokenData.accessToken,
120
- expiresIn,
121
118
  };
122
119
  },
123
120
  version: "1.0.0",
@@ -4,4 +4,5 @@
4
4
  * All tools for interacting with Reflect notes
5
5
  */
6
6
  import { FastMCP } from "fastmcp";
7
- export declare function registerTools(server: FastMCP, dbPath?: string): void;
7
+ import type { PKCEOAuthProxy } from "../pkcehandler.js";
8
+ export declare function registerTools(server: FastMCP, dbPath?: string, pkceProxy?: PKCEOAuthProxy): void;
@@ -6,7 +6,7 @@
6
6
  import { z } from "zod";
7
7
  import Database from "better-sqlite3";
8
8
  import { DEFAULT_DB_PATH, expandPath, stripHtml, formatDate, getDateForTimezone } from "../utils.js";
9
- export function registerTools(server, dbPath) {
9
+ export function registerTools(server, dbPath, pkceProxy) {
10
10
  const resolvedDbPath = expandPath(dbPath || DEFAULT_DB_PATH);
11
11
  // Tool: Get all Reflect graphs
12
12
  server.addTool({
@@ -32,6 +32,9 @@ export function registerTools(server, dbPath) {
32
32
  },
33
33
  });
34
34
  if (!response.ok) {
35
+ if (response.status === 401 && pkceProxy) {
36
+ await pkceProxy.invalidateUpstreamToken(accessToken);
37
+ }
35
38
  throw new Error(`HTTP error! status: ${response.status}`);
36
39
  }
37
40
  const data = await response.json();
@@ -672,6 +675,9 @@ export function registerTools(server, dbPath) {
672
675
  }),
673
676
  });
674
677
  if (!response.ok) {
678
+ if (response.status === 401 && pkceProxy) {
679
+ await pkceProxy.invalidateUpstreamToken(accessToken);
680
+ }
675
681
  const errorText = await response.text();
676
682
  throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
677
683
  }
@@ -690,6 +696,9 @@ export function registerTools(server, dbPath) {
690
696
  }),
691
697
  });
692
698
  if (!dailyNoteResponse.ok) {
699
+ if (dailyNoteResponse.status === 401 && pkceProxy) {
700
+ await pkceProxy.invalidateUpstreamToken(accessToken);
701
+ }
693
702
  const errorText = await dailyNoteResponse.text();
694
703
  console.error(`Failed to append to daily notes: ${dailyNoteResponse.status}, ${errorText}`);
695
704
  }
package/dist/utils.js CHANGED
@@ -32,31 +32,35 @@ export function findLocalDatabase() {
32
32
  }
33
33
  // Search for database files in the File System directory
34
34
  // Structure is typically: File System/XXX/t/XX/XXXXXXXX
35
+ // Reflect may have multiple database partitions (000, 001, etc.)
36
+ // so we find all SQLite files and return the most recently modified one.
35
37
  try {
36
38
  const entries = fs.readdirSync(basePath);
39
+ let bestPath = null;
40
+ let bestMtime = 0;
37
41
  for (const entry of entries) {
38
42
  const entryPath = path.join(basePath, entry);
39
43
  const tPath = path.join(entryPath, "t");
40
44
  if (fs.existsSync(tPath) && fs.statSync(tPath).isDirectory()) {
41
- // Look for subdirectories in t/
42
45
  const tEntries = fs.readdirSync(tPath);
43
46
  for (const tEntry of tEntries) {
44
47
  const subPath = path.join(tPath, tEntry);
45
48
  if (fs.statSync(subPath).isDirectory()) {
46
- // Look for database files (8-char hex names)
47
49
  const dbFiles = fs.readdirSync(subPath);
48
50
  for (const dbFile of dbFiles) {
49
51
  const dbPath = path.join(subPath, dbFile);
50
- // Check if it's a valid SQLite database (starts with SQLite header)
51
52
  if (fs.statSync(dbPath).isFile()) {
52
53
  try {
53
54
  const header = Buffer.alloc(16);
54
55
  const fd = fs.openSync(dbPath, 'r');
55
56
  fs.readSync(fd, header, 0, 16, 0);
56
57
  fs.closeSync(fd);
57
- // SQLite files start with "SQLite format 3"
58
58
  if (header.toString('utf8', 0, 15) === 'SQLite format 3') {
59
- return dbPath;
59
+ const mtime = fs.statSync(dbPath).mtimeMs;
60
+ if (mtime > bestMtime) {
61
+ bestMtime = mtime;
62
+ bestPath = dbPath;
63
+ }
60
64
  }
61
65
  }
62
66
  catch {
@@ -68,11 +72,11 @@ export function findLocalDatabase() {
68
72
  }
69
73
  }
70
74
  }
75
+ return bestPath;
71
76
  }
72
77
  catch {
73
78
  return null;
74
79
  }
75
- return null;
76
80
  }
77
81
  /**
78
82
  * Gets the default database path, searching for it if not provided.
@@ -84,7 +88,7 @@ export function getDefaultDbPath() {
84
88
  return found;
85
89
  }
86
90
  if (os.platform() === "darwin") {
87
- return expandPath("~/Library/Application Support/Reflect/File System/000/t/00/00000000");
91
+ return expandPath("~/Library/Application Support/Reflect/File System/001/t/00/00000000");
88
92
  }
89
93
  return "";
90
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reflect-mcp",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "MCP server for Reflect Notes - connect your notes to Claude Desktop. Just run: npx reflect-mcp",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
@@ -39,7 +39,7 @@
39
39
  "@modelcontextprotocol/sdk": "^1.25.1",
40
40
  "better-sqlite3": "^11.10.0",
41
41
  "fastmcp": "^3.25.4",
42
- "reflect-mcp": "^1.0.12",
42
+ "reflect-mcp": "^1.0.16",
43
43
  "zod": "^4.1.13"
44
44
  },
45
45
  "devDependencies": {