reflect-mcp 1.0.12 → 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 CHANGED
@@ -34,54 +34,52 @@ function isPortInUse(port) {
34
34
  });
35
35
  }
36
36
  /**
37
- * Kill any process using the specified port
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 killProcessOnPort(port) {
40
- try {
41
- // Get PID(s) using the port
42
- const pids = execSync(`lsof -ti:${port}`, { encoding: "utf-8" }).trim();
43
- if (pids) {
44
- console.log(`⚠️ Port ${port} in use by PID(s): ${pids.replace(/\n/g, ", ")}`);
45
- execSync(`lsof -ti:${port} | xargs kill -9`, { stdio: "ignore" });
46
- console.log(`✅ Killed existing process(es) on port ${port}`);
47
- return true;
48
- }
49
- }
50
- catch {
51
- // No process found on port, or kill failed - that's fine
52
- }
53
- return false;
54
- }
55
- /**
56
- * Ensure the port is free, killing any existing process if needed
57
- */
58
- async function ensurePortFree(port) {
59
- if (await isPortInUse(port)) {
60
- killProcessOnPort(port);
61
- // Wait a moment for the port to be released
62
- await new Promise((resolve) => setTimeout(resolve, 500));
63
- // Check again
64
- if (await isPortInUse(port)) {
65
- 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;
66
46
  }
67
47
  }
48
+ throw new Error(`No free port found in range ${startPort}–${startPort + maxAttempts - 1}`);
68
49
  }
69
50
  const REFLECT_CLIENT_ID = "55798f25d5a24efb95e4174fff3d219e";
51
+ const platform = os.platform();
52
+ // macOS LaunchAgent paths
70
53
  const LAUNCH_AGENT_LABEL = "com.reflect-mcp";
71
54
  const LAUNCH_AGENT_DIR = path.join(os.homedir(), "Library/LaunchAgents");
72
55
  const LAUNCH_AGENT_PATH = path.join(LAUNCH_AGENT_DIR, `${LAUNCH_AGENT_LABEL}.plist`);
56
+ // Linux systemd user service paths
57
+ const SYSTEMD_SERVICE_NAME = "reflect-mcp";
58
+ const SYSTEMD_USER_DIR = path.join(os.homedir(), ".config/systemd/user");
59
+ const SYSTEMD_SERVICE_PATH = path.join(SYSTEMD_USER_DIR, `${SYSTEMD_SERVICE_NAME}.service`);
60
+ function requireSupportedPlatform() {
61
+ if (platform !== "darwin" && platform !== "linux") {
62
+ console.error(`❌ Service management is not supported on ${platform}.`);
63
+ console.error(" Supported platforms: macOS (darwin), Linux");
64
+ console.error(" You can still run the server directly: reflect-mcp [db-path]");
65
+ process.exit(1);
66
+ }
67
+ }
73
68
  // Get the command and arguments
74
69
  const args = process.argv.slice(2);
75
70
  const command = args[0];
76
71
  // Handle commands
77
72
  (async () => {
78
73
  if (command === "install") {
74
+ requireSupportedPlatform();
79
75
  await install(args.slice(1));
80
76
  }
81
77
  else if (command === "uninstall") {
78
+ requireSupportedPlatform();
82
79
  uninstall();
83
80
  }
84
81
  else if (command === "status") {
82
+ requireSupportedPlatform();
85
83
  status();
86
84
  }
87
85
  else if (command === "--help" || command === "-h") {
@@ -93,6 +91,9 @@ const command = args[0];
93
91
  }
94
92
  })();
95
93
  function showHelp() {
94
+ const dbDefault = DEFAULT_DB_PATH
95
+ ? `(default: ${DEFAULT_DB_PATH})`
96
+ : "(required on Linux — no default path)";
96
97
  console.log(`
97
98
  Reflect MCP Server - Connect your Reflect notes to Claude
98
99
 
@@ -104,13 +105,13 @@ Usage:
104
105
 
105
106
  Arguments:
106
107
  db-path Path to Reflect SQLite database
107
- (default: ${DEFAULT_DB_PATH})
108
+ ${dbDefault}
108
109
 
109
110
  Options:
110
111
  --port <port> Port to run server on (default: 3000)
111
112
 
112
113
  Examples:
113
- reflect-mcp install # Install with default db path
114
+ reflect-mcp install # Install with default db path (macOS)
114
115
  reflect-mcp install ~/my/reflect/db # Install with custom db path
115
116
  reflect-mcp uninstall # Remove auto-start
116
117
  reflect-mcp # Run server manually
@@ -118,7 +119,7 @@ Examples:
118
119
  process.exit(0);
119
120
  }
120
121
  async function install(installArgs) {
121
- let dbPath = DEFAULT_DB_PATH;
122
+ let dbPath;
122
123
  let port = 3000;
123
124
  // Parse install arguments
124
125
  for (let i = 0; i < installArgs.length; i++) {
@@ -129,11 +130,47 @@ async function install(installArgs) {
129
130
  dbPath = installArgs[i];
130
131
  }
131
132
  }
133
+ // On Linux there's no known default path -- require explicit db-path
134
+ if (!dbPath) {
135
+ if (platform === "linux") {
136
+ console.error("❌ On Linux, you must specify the database path:");
137
+ console.error(" reflect-mcp install /path/to/reflect.db");
138
+ process.exit(1);
139
+ }
140
+ dbPath = DEFAULT_DB_PATH;
141
+ }
132
142
  const expandedDbPath = expandPath(dbPath);
133
143
  const nodePath = process.execPath;
134
144
  const cliPath = process.argv[1];
135
145
  console.log("📦 Installing Reflect MCP Server as auto-start service...\n");
136
- // Create Launch Agent directory if needed
146
+ if (platform === "darwin") {
147
+ installDarwin(nodePath, cliPath, expandedDbPath, port);
148
+ }
149
+ else {
150
+ installLinux(nodePath, cliPath, expandedDbPath, port);
151
+ }
152
+ console.log(`🚀 Reflect MCP Server will now auto-start on login`);
153
+ console.log(` Server: http://localhost:${port}`);
154
+ console.log(` Database: ${expandedDbPath}`);
155
+ if (platform === "darwin") {
156
+ console.log(` Logs: tail -f /tmp/reflect-mcp.log\n`);
157
+ }
158
+ else {
159
+ console.log(` Logs: journalctl --user -u ${SYSTEMD_SERVICE_NAME} -f\n`);
160
+ }
161
+ console.log(`📋 Add to Claude Desktop config (~/.config/claude/claude_desktop_config.json):`);
162
+ console.log(`{
163
+ "mcpServers": {
164
+ "reflect": {
165
+ "command": "npx",
166
+ "args": ["-y", "mcp-remote", "http://localhost:${port}/mcp", "--port", "4209"]
167
+ }
168
+ }
169
+ }`);
170
+ console.log(` Note: Make sure the port (${port}) matches the port you used when installing the server.`);
171
+ console.log(` Important: Add "--port", "4209" (or a different port like "4210", "4211", etc.) to avoid conflicts if you have multiple MCP clients running.`);
172
+ }
173
+ function installDarwin(nodePath, cliPath, expandedDbPath, port) {
137
174
  if (!fs.existsSync(LAUNCH_AGENT_DIR)) {
138
175
  fs.mkdirSync(LAUNCH_AGENT_DIR, { recursive: true });
139
176
  }
@@ -145,10 +182,6 @@ async function install(installArgs) {
145
182
  catch {
146
183
  // Ignore errors - service might not exist yet
147
184
  }
148
- // Kill any stale processes on the port
149
- killProcessOnPort(port);
150
- await new Promise((resolve) => setTimeout(resolve, 300));
151
- // Create plist content
152
185
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
153
186
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
154
187
  <plist version="1.0">
@@ -173,10 +206,8 @@ async function install(installArgs) {
173
206
  <string>/tmp/reflect-mcp.log</string>
174
207
  </dict>
175
208
  </plist>`;
176
- // Write plist file
177
209
  fs.writeFileSync(LAUNCH_AGENT_PATH, plist);
178
210
  console.log(`✅ Created: ${LAUNCH_AGENT_PATH}`);
179
- // Load and start the service
180
211
  try {
181
212
  execSync(`launchctl load ${LAUNCH_AGENT_PATH}`);
182
213
  execSync(`launchctl start ${LAUNCH_AGENT_LABEL}`);
@@ -186,40 +217,90 @@ async function install(installArgs) {
186
217
  console.error("❌ Failed to start service:", error);
187
218
  process.exit(1);
188
219
  }
189
- console.log(`🚀 Reflect MCP Server will now auto-start on login`);
190
- console.log(` Server: http://localhost:${port}`);
191
- console.log(` Database: ${expandedDbPath}`);
192
- console.log(` Logs: tail -f /tmp/reflect-mcp.log\n`);
193
- console.log(`📋 Add to Claude Desktop config (~/.config/claude/claude_desktop_config.json):`);
194
- console.log(`{
195
- "mcpServers": {
196
- "reflect": {
197
- "command": "npx",
198
- "args": ["-y", "mcp-remote", "--port", "4209", "http://localhost:${port}/mcp"]
199
- }
200
- }
201
- }`);
202
- console.log(` Note: Make sure the port (${port}) matches the port you used when installing the server.`);
203
- console.log(` Important: Add "--port", "4209" (or a different port like "4210", "4211", etc.) to avoid conflicts if you have multiple MCP clients running.`);
204
220
  }
205
- function uninstall() {
206
- console.log("🗑️ Removing Reflect MCP Server auto-start service...\n");
221
+ function installLinux(nodePath, cliPath, expandedDbPath, port) {
222
+ if (!fs.existsSync(SYSTEMD_USER_DIR)) {
223
+ fs.mkdirSync(SYSTEMD_USER_DIR, { recursive: true });
224
+ }
225
+ // Stop existing service if running
207
226
  try {
208
- execSync(`launchctl stop ${LAUNCH_AGENT_LABEL} 2>/dev/null`, { stdio: "ignore" });
209
- execSync(`launchctl unload ${LAUNCH_AGENT_PATH} 2>/dev/null`, { stdio: "ignore" });
227
+ execSync(`systemctl --user stop ${SYSTEMD_SERVICE_NAME} 2>/dev/null`, { stdio: "ignore" });
228
+ execSync(`systemctl --user disable ${SYSTEMD_SERVICE_NAME} 2>/dev/null`, { stdio: "ignore" });
210
229
  }
211
230
  catch {
212
- // Ignore errors
231
+ // Ignore errors - service might not exist yet
213
232
  }
214
- if (fs.existsSync(LAUNCH_AGENT_PATH)) {
215
- fs.unlinkSync(LAUNCH_AGENT_PATH);
216
- console.log(`✅ Removed: ${LAUNCH_AGENT_PATH}`);
233
+ const unit = `[Unit]
234
+ Description=Reflect MCP Server
235
+ After=network.target
236
+
237
+ [Service]
238
+ ExecStart=${nodePath} ${cliPath} ${expandedDbPath} --port ${port}
239
+ Restart=always
240
+ StandardOutput=journal
241
+ StandardError=journal
242
+
243
+ [Install]
244
+ WantedBy=default.target
245
+ `;
246
+ fs.writeFileSync(SYSTEMD_SERVICE_PATH, unit);
247
+ console.log(`✅ Created: ${SYSTEMD_SERVICE_PATH}`);
248
+ try {
249
+ execSync("systemctl --user daemon-reload");
250
+ execSync(`systemctl --user enable --now ${SYSTEMD_SERVICE_NAME}`);
251
+ console.log("✅ Service installed and started!\n");
252
+ }
253
+ catch (error) {
254
+ console.error("❌ Failed to start service:", error);
255
+ console.error(" Make sure systemd user services are available (systemctl --user).");
256
+ process.exit(1);
257
+ }
258
+ }
259
+ function uninstall() {
260
+ console.log("🗑️ Removing Reflect MCP Server auto-start service...\n");
261
+ if (platform === "darwin") {
262
+ try {
263
+ execSync(`launchctl stop ${LAUNCH_AGENT_LABEL} 2>/dev/null`, { stdio: "ignore" });
264
+ execSync(`launchctl unload ${LAUNCH_AGENT_PATH} 2>/dev/null`, { stdio: "ignore" });
265
+ }
266
+ catch {
267
+ // Ignore errors
268
+ }
269
+ if (fs.existsSync(LAUNCH_AGENT_PATH)) {
270
+ fs.unlinkSync(LAUNCH_AGENT_PATH);
271
+ console.log(`✅ Removed: ${LAUNCH_AGENT_PATH}`);
272
+ }
273
+ }
274
+ else {
275
+ try {
276
+ execSync(`systemctl --user disable --now ${SYSTEMD_SERVICE_NAME} 2>/dev/null`, { stdio: "ignore" });
277
+ }
278
+ catch {
279
+ // Ignore errors
280
+ }
281
+ if (fs.existsSync(SYSTEMD_SERVICE_PATH)) {
282
+ fs.unlinkSync(SYSTEMD_SERVICE_PATH);
283
+ console.log(`✅ Removed: ${SYSTEMD_SERVICE_PATH}`);
284
+ }
285
+ try {
286
+ execSync("systemctl --user daemon-reload", { stdio: "ignore" });
287
+ }
288
+ catch {
289
+ // Ignore errors
290
+ }
217
291
  }
218
292
  console.log("✅ Service uninstalled. Server will no longer auto-start.");
219
293
  }
220
294
  function status() {
221
295
  console.log("📊 Reflect MCP Server Status\n");
222
- // Check if plist exists
296
+ if (platform === "darwin") {
297
+ statusDarwin();
298
+ }
299
+ else {
300
+ statusLinux();
301
+ }
302
+ }
303
+ function statusDarwin() {
223
304
  if (fs.existsSync(LAUNCH_AGENT_PATH)) {
224
305
  console.log(`✅ Launch Agent installed: ${LAUNCH_AGENT_PATH}`);
225
306
  }
@@ -228,7 +309,6 @@ function status() {
228
309
  console.log(" Run: reflect-mcp install");
229
310
  return;
230
311
  }
231
- // Check if service is running
232
312
  try {
233
313
  const result = execSync(`launchctl list | grep ${LAUNCH_AGENT_LABEL}`, { encoding: "utf-8" });
234
314
  if (result.includes(LAUNCH_AGENT_LABEL)) {
@@ -251,33 +331,74 @@ function status() {
251
331
  }
252
332
  console.log(`\n📝 Logs: tail -f /tmp/reflect-mcp.log`);
253
333
  }
334
+ function statusLinux() {
335
+ if (!fs.existsSync(SYSTEMD_SERVICE_PATH)) {
336
+ console.log("❌ Systemd service not installed");
337
+ console.log(" Run: reflect-mcp install /path/to/reflect.db");
338
+ return;
339
+ }
340
+ console.log(`✅ Systemd service installed: ${SYSTEMD_SERVICE_PATH}`);
341
+ try {
342
+ const result = execSync(`systemctl --user is-active ${SYSTEMD_SERVICE_NAME} 2>/dev/null`, { encoding: "utf-8" }).trim();
343
+ if (result === "active") {
344
+ console.log("✅ Service running");
345
+ }
346
+ else {
347
+ console.log(`⚠️ Service not active (state: ${result})`);
348
+ }
349
+ }
350
+ catch {
351
+ // is-active exits non-zero when inactive/failed
352
+ try {
353
+ const result = execSync(`systemctl --user show ${SYSTEMD_SERVICE_NAME} --property=ActiveState --value 2>/dev/null`, { encoding: "utf-8" }).trim();
354
+ console.log(`❌ Service ${result || "not loaded"}`);
355
+ }
356
+ catch {
357
+ console.log("❌ Service not loaded");
358
+ }
359
+ }
360
+ console.log(`\n📝 Logs: journalctl --user -u ${SYSTEMD_SERVICE_NAME} -f`);
361
+ }
254
362
  async function runServer(serverArgs) {
255
- let dbPath = DEFAULT_DB_PATH;
256
- let port = 3000;
363
+ let dbPath;
364
+ let requestedPort = 3000;
257
365
  for (let i = 0; i < serverArgs.length; i++) {
258
366
  if (serverArgs[i] === "--port" && serverArgs[i + 1]) {
259
- port = parseInt(serverArgs[++i]);
367
+ requestedPort = parseInt(serverArgs[++i]);
260
368
  }
261
369
  else if (!serverArgs[i].startsWith("--")) {
262
370
  dbPath = serverArgs[i];
263
371
  }
264
372
  }
265
- // Ensure port is free before starting
373
+ if (!dbPath) {
374
+ if (platform === "linux") {
375
+ console.error("❌ On Linux, you must specify the database path:");
376
+ console.error(" reflect-mcp /path/to/reflect.db");
377
+ process.exit(1);
378
+ }
379
+ dbPath = DEFAULT_DB_PATH;
380
+ }
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;
266
385
  try {
267
- await ensurePortFree(port);
386
+ port = await findFreePort(requestedPort);
268
387
  }
269
388
  catch (err) {
270
- console.error(`❌ ${err}`);
389
+ console.error(`[reflect-mcp] ${err}`);
271
390
  process.exit(1);
272
391
  }
392
+ if (port !== requestedPort) {
393
+ console.error(`[reflect-mcp] Requested port ${requestedPort} is in use — using port ${port} instead`);
394
+ }
273
395
  try {
274
396
  await startReflectMCPServer({
275
397
  clientId: REFLECT_CLIENT_ID,
276
398
  port,
277
399
  dbPath,
278
400
  });
279
- console.log(`Reflect MCP Server running on http://localhost:${port}`);
280
- console.log(`Database: ${dbPath}`);
401
+ console.error(`[reflect-mcp] HTTP server running on http://localhost:${port}`);
281
402
  }
282
403
  catch (err) {
283
404
  console.error("Failed to start server:", err);
@@ -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;
@@ -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
- saveTokensToDisk() {
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
- fs.writeFileSync(this.config.tokenStoragePath, JSON.stringify(toStore, null, 2));
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
- fs.writeFileSync(this.config.transactionStoragePath, JSON.stringify(toStore, null, 2));
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("[PKCEProxy] Returning cached token for retry of code:", params.code.slice(0, 8) + "...");
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
- const tokenData = this.tokens.get(params.code);
304
- if (!tokenData) {
305
- console.error("[PKCEProxy] Token not found for code:", params.code);
306
- console.error("[PKCEProxy] Available tokens:", Array.from(this.tokens.keys()).map(k => k.slice(0, 8) + "..."));
307
- console.error("[PKCEProxy] Recently exchanged codes:", Array.from(this.recentlyExchangedCodes.keys()).map(k => k.slice(0, 8) + "..."));
308
- throw new OAuthProxyError("invalid_grant", "Invalid or expired authorization code", 400);
309
- }
310
- // Remove the code but keep track of it for retry tolerance (30 second window)
311
- this.tokens.delete(params.code);
312
- // Generate a new access token for the client
313
- const accessToken = this.generateId();
314
- this.tokens.set(accessToken, tokenData);
315
- this.saveTokensToDisk(); // Persist to disk
316
- // Store the exchange for retry tolerance (30 seconds)
317
- this.recentlyExchangedCodes.set(params.code, {
318
- accessToken,
319
- expiresAt: new Date(Date.now() + 30 * 1000),
320
- });
321
- const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
322
- console.log("[PKCEProxy] Issuing access token, expires in:", expiresIn, "seconds");
323
- return {
324
- access_token: accessToken,
325
- token_type: "Bearer",
326
- expires_in: expiresIn > 0 ? expiresIn : 3600,
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
- this.cleanupInterval = setInterval(() => {
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
- }, 60000); // Every minute
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";
package/dist/utils.d.ts CHANGED
@@ -6,12 +6,14 @@
6
6
  */
7
7
  export declare function expandPath(filePath: string): string;
8
8
  /**
9
- * Searches for the Reflect local database file
10
- * Returns the first valid database path found, or null if not found
9
+ * Searches for the Reflect local database file.
10
+ * Only works on macOS where the Reflect app path is known.
11
+ * Returns the first valid database path found, or null if not found.
11
12
  */
12
13
  export declare function findLocalDatabase(): string | null;
13
14
  /**
14
- * Gets the default database path, searching for it if not provided
15
+ * Gets the default database path, searching for it if not provided.
16
+ * Returns empty string on non-macOS platforms where no default is known.
15
17
  */
16
18
  export declare function getDefaultDbPath(): string;
17
19
  export declare const DEFAULT_DB_PATH: string;
package/dist/utils.js CHANGED
@@ -4,8 +4,10 @@
4
4
  import * as path from "path";
5
5
  import * as os from "os";
6
6
  import * as fs from "fs";
7
- // Base path for Reflect local database
8
- const REFLECT_BASE_PATH = "~/Library/Application Support/Reflect/File System";
7
+ // Base path for Reflect local database (macOS only; no known Linux path)
8
+ const REFLECT_BASE_PATH = os.platform() === "darwin"
9
+ ? "~/Library/Application Support/Reflect/File System"
10
+ : null;
9
11
  /**
10
12
  * Expands ~ to the user's home directory
11
13
  */
@@ -16,10 +18,14 @@ export function expandPath(filePath) {
16
18
  return filePath;
17
19
  }
18
20
  /**
19
- * Searches for the Reflect local database file
20
- * Returns the first valid database path found, or null if not found
21
+ * Searches for the Reflect local database file.
22
+ * Only works on macOS where the Reflect app path is known.
23
+ * Returns the first valid database path found, or null if not found.
21
24
  */
22
25
  export function findLocalDatabase() {
26
+ if (!REFLECT_BASE_PATH) {
27
+ return null;
28
+ }
23
29
  const basePath = expandPath(REFLECT_BASE_PATH);
24
30
  if (!fs.existsSync(basePath)) {
25
31
  return null;
@@ -69,15 +75,18 @@ export function findLocalDatabase() {
69
75
  return null;
70
76
  }
71
77
  /**
72
- * Gets the default database path, searching for it if not provided
78
+ * Gets the default database path, searching for it if not provided.
79
+ * Returns empty string on non-macOS platforms where no default is known.
73
80
  */
74
81
  export function getDefaultDbPath() {
75
82
  const found = findLocalDatabase();
76
83
  if (found) {
77
84
  return found;
78
85
  }
79
- // Fallback to a common path pattern
80
- return expandPath("~/Library/Application Support/Reflect/File System/000/t/00/00000000");
86
+ if (os.platform() === "darwin") {
87
+ return expandPath("~/Library/Application Support/Reflect/File System/000/t/00/00000000");
88
+ }
89
+ return "";
81
90
  }
82
91
  // For backwards compatibility
83
92
  export const DEFAULT_DB_PATH = getDefaultDbPath();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reflect-mcp",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
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.11",
42
+ "reflect-mcp": "^1.0.12",
43
43
  "zod": "^4.1.13"
44
44
  },
45
45
  "devDependencies": {