reflect-mcp 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,6 +125,8 @@ npx reflect-mcp uninstall
125
125
  - Verify database path exists at default location
126
126
  - Try specifying custom path: `npx reflect-mcp install /path/to/db`
127
127
 
128
+ ## Demo:
129
+ https://www.loom.com/share/455b1d3eb7184bdea1ae4e8d5904fc53
128
130
  ## License
129
131
 
130
132
  MIT
package/dist/cli.js CHANGED
File without changes
@@ -17,15 +17,19 @@ export interface PKCEOAuthProxyConfig {
17
17
  scopes: string[];
18
18
  redirectPath?: string;
19
19
  tokenStoragePath?: string;
20
+ transactionStoragePath?: string;
20
21
  }
21
22
  export declare class PKCEOAuthProxy {
22
23
  private config;
23
24
  private transactions;
24
25
  private tokens;
26
+ private recentlyExchangedCodes;
25
27
  private cleanupInterval;
26
28
  constructor(options: PKCEOAuthProxyConfig);
27
29
  private loadTokensFromDisk;
28
30
  private saveTokensToDisk;
31
+ private loadTransactionsFromDisk;
32
+ private saveTransactionsToDisk;
29
33
  private generatePKCE;
30
34
  private generateId;
31
35
  getAuthorizationServerMetadata(): {
@@ -14,10 +14,12 @@ import { OAuthProxyError } from "fastmcp/auth";
14
14
  // ============================================================================
15
15
  export class PKCEOAuthProxy {
16
16
  config;
17
- // In-memory storage for transactions (short-lived, don't need persistence)
17
+ // Transaction storage - now persisted to disk to survive restarts
18
18
  transactions = new Map();
19
19
  // Token storage - persisted to disk
20
20
  tokens = new Map();
21
+ // Track tokens that have been exchanged but allow brief retry window
22
+ recentlyExchangedCodes = new Map();
21
23
  cleanupInterval = null;
22
24
  constructor(options) {
23
25
  this.config = {
@@ -28,8 +30,10 @@ export class PKCEOAuthProxy {
28
30
  redirectPath: options.redirectPath || "/oauth/callback",
29
31
  scopes: options.scopes,
30
32
  tokenStoragePath: options.tokenStoragePath || path.join(os.homedir(), ".reflect-mcp-tokens.json"),
33
+ transactionStoragePath: options.transactionStoragePath || path.join(os.homedir(), ".reflect-mcp-transactions.json"),
31
34
  };
32
35
  this.loadTokensFromDisk();
36
+ this.loadTransactionsFromDisk();
33
37
  this.startCleanup();
34
38
  }
35
39
  // Load tokens from disk on startup
@@ -73,6 +77,57 @@ export class PKCEOAuthProxy {
73
77
  console.error("[PKCEProxy] Failed to save tokens to disk:", error);
74
78
  }
75
79
  }
80
+ // Load transactions from disk on startup (survives server restarts)
81
+ loadTransactionsFromDisk() {
82
+ try {
83
+ if (fs.existsSync(this.config.transactionStoragePath)) {
84
+ const data = fs.readFileSync(this.config.transactionStoragePath, "utf-8");
85
+ const stored = JSON.parse(data);
86
+ for (const [key, value] of Object.entries(stored)) {
87
+ const expiresAt = new Date(value.expiresAt);
88
+ // Only load non-expired transactions
89
+ if (expiresAt > new Date()) {
90
+ this.transactions.set(key, {
91
+ codeVerifier: value.codeVerifier,
92
+ codeChallenge: value.codeChallenge,
93
+ clientCallbackUrl: value.clientCallbackUrl,
94
+ clientId: value.clientId,
95
+ clientState: value.clientState,
96
+ scope: value.scope,
97
+ createdAt: new Date(value.createdAt),
98
+ expiresAt,
99
+ });
100
+ }
101
+ }
102
+ console.log(`[PKCEProxy] Loaded ${this.transactions.size} transactions from disk`);
103
+ }
104
+ }
105
+ catch (error) {
106
+ console.warn("[PKCEProxy] Failed to load transactions from disk:", error);
107
+ }
108
+ }
109
+ // Save transactions to disk (survives server restarts)
110
+ saveTransactionsToDisk() {
111
+ try {
112
+ const toStore = {};
113
+ for (const [key, value] of this.transactions) {
114
+ toStore[key] = {
115
+ codeVerifier: value.codeVerifier,
116
+ codeChallenge: value.codeChallenge,
117
+ clientCallbackUrl: value.clientCallbackUrl,
118
+ clientId: value.clientId,
119
+ clientState: value.clientState,
120
+ scope: value.scope,
121
+ createdAt: value.createdAt.toISOString(),
122
+ expiresAt: value.expiresAt.toISOString(),
123
+ };
124
+ }
125
+ fs.writeFileSync(this.config.transactionStoragePath, JSON.stringify(toStore, null, 2));
126
+ }
127
+ catch (error) {
128
+ console.error("[PKCEProxy] Failed to save transactions to disk:", error);
129
+ }
130
+ }
76
131
  // Generate PKCE code verifier and challenge
77
132
  generatePKCE() {
78
133
  // Generate a random code verifier (43-128 characters)
@@ -124,6 +179,7 @@ export class PKCEOAuthProxy {
124
179
  expiresAt: new Date(Date.now() + 600 * 1000), // 10 minutes
125
180
  };
126
181
  this.transactions.set(transactionId, transaction);
182
+ this.saveTransactionsToDisk(); // Persist to survive restarts
127
183
  console.log("[PKCEProxy] Created transaction:", transactionId);
128
184
  // Build upstream authorization URL
129
185
  const authUrl = new URL(this.config.authorizationEndpoint);
@@ -172,6 +228,8 @@ export class PKCEOAuthProxy {
172
228
  }
173
229
  if (transaction.expiresAt < new Date()) {
174
230
  this.transactions.delete(state);
231
+ this.saveTransactionsToDisk();
232
+ console.error("[PKCEProxy] Transaction expired, created:", transaction.createdAt, "expired:", transaction.expiresAt);
175
233
  return new Response(JSON.stringify({ error: "transaction_expired" }), {
176
234
  status: 400,
177
235
  headers: { "Content-Type": "application/json" },
@@ -214,6 +272,7 @@ export class PKCEOAuthProxy {
214
272
  clientRedirect.searchParams.set("state", transaction.clientState);
215
273
  // Clean up transaction
216
274
  this.transactions.delete(state);
275
+ this.saveTransactionsToDisk();
217
276
  console.log("[PKCEProxy] Redirecting to client:", clientRedirect.toString());
218
277
  return new Response(null, {
219
278
  status: 302,
@@ -223,22 +282,42 @@ export class PKCEOAuthProxy {
223
282
  // Handle /oauth/token - exchange proxy code for access token
224
283
  // FastMCP expects a TokenResponse object, not a Response
225
284
  async exchangeAuthorizationCode(params) {
226
- console.log("[PKCEProxy] exchangeAuthorizationCode called with code:", params.code?.slice(0, 8) + "...");
227
285
  if (!params.code) {
228
286
  throw new OAuthProxyError("invalid_request", "Missing authorization code", 400);
229
287
  }
288
+ // Check if this code was recently exchanged (retry tolerance)
289
+ // This allows mcp-remote to retry if the first request timed out but actually succeeded
290
+ const recentExchange = this.recentlyExchangedCodes.get(params.code);
291
+ if (recentExchange && recentExchange.expiresAt > new Date()) {
292
+ console.log("[PKCEProxy] Returning cached token for retry of code:", params.code.slice(0, 8) + "...");
293
+ const tokenData = this.tokens.get(recentExchange.accessToken);
294
+ if (tokenData) {
295
+ const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
296
+ return {
297
+ access_token: recentExchange.accessToken,
298
+ token_type: "Bearer",
299
+ expires_in: expiresIn > 0 ? expiresIn : 3600,
300
+ };
301
+ }
302
+ }
230
303
  const tokenData = this.tokens.get(params.code);
231
304
  if (!tokenData) {
232
305
  console.error("[PKCEProxy] Token not found for code:", params.code);
233
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) + "..."));
234
308
  throw new OAuthProxyError("invalid_grant", "Invalid or expired authorization code", 400);
235
309
  }
236
- // Remove the code (single use)
310
+ // Remove the code but keep track of it for retry tolerance (30 second window)
237
311
  this.tokens.delete(params.code);
238
312
  // Generate a new access token for the client
239
313
  const accessToken = this.generateId();
240
314
  this.tokens.set(accessToken, tokenData);
241
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
+ });
242
321
  const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
243
322
  console.log("[PKCEProxy] Issuing access token, expires in:", expiresIn, "seconds");
244
323
  return {
@@ -272,13 +351,22 @@ export class PKCEOAuthProxy {
272
351
  // Load upstream tokens for a given proxy token
273
352
  loadUpstreamTokens(proxyToken) {
274
353
  const data = this.tokens.get(proxyToken);
275
- if (!data)
354
+ if (!data) {
355
+ console.warn("[PKCEProxy] Token not found:", proxyToken.slice(0, 8) + "...");
356
+ console.warn("[PKCEProxy] Total tokens in store:", this.tokens.size);
276
357
  return null;
277
- if (data.expiresAt < new Date()) {
358
+ }
359
+ const now = new Date();
360
+ if (data.expiresAt < now) {
361
+ console.warn("[PKCEProxy] Token expired:", proxyToken.slice(0, 8) + "...", "expired at:", data.expiresAt, "now:", now);
278
362
  this.tokens.delete(proxyToken);
279
363
  this.saveTokensToDisk();
280
364
  return null;
281
365
  }
366
+ const timeRemaining = Math.floor((data.expiresAt.getTime() - now.getTime()) / 1000);
367
+ if (timeRemaining < 300) { // Less than 5 minutes remaining
368
+ console.warn("[PKCEProxy] Token expiring soon:", proxyToken.slice(0, 8) + "...", "remaining:", timeRemaining, "seconds");
369
+ }
282
370
  return data;
283
371
  }
284
372
  // Get first valid token (for stdio mode where we don't have specific token ID)
@@ -291,24 +379,37 @@ export class PKCEOAuthProxy {
291
379
  }
292
380
  return null;
293
381
  }
294
- // Cleanup expired transactions and tokens
382
+ // Cleanup expired transactions, tokens, and retry cache
295
383
  startCleanup() {
296
384
  this.cleanupInterval = setInterval(() => {
297
385
  const now = new Date();
298
386
  let tokensChanged = false;
387
+ let transactionsChanged = false;
299
388
  for (const [id, tx] of this.transactions) {
300
- if (tx.expiresAt < now)
389
+ if (tx.expiresAt < now) {
301
390
  this.transactions.delete(id);
391
+ transactionsChanged = true;
392
+ }
302
393
  }
303
394
  for (const [id, token] of this.tokens) {
304
395
  if (token.expiresAt < now) {
396
+ console.log("[PKCEProxy] Cleaning up expired token:", id.slice(0, 8) + "...");
305
397
  this.tokens.delete(id);
306
398
  tokensChanged = true;
307
399
  }
308
400
  }
401
+ // Clean up expired retry cache entries
402
+ for (const [code, data] of this.recentlyExchangedCodes) {
403
+ if (data.expiresAt < now) {
404
+ this.recentlyExchangedCodes.delete(code);
405
+ }
406
+ }
309
407
  if (tokensChanged) {
310
408
  this.saveTokensToDisk();
311
409
  }
410
+ if (transactionsChanged) {
411
+ this.saveTransactionsToDisk();
412
+ }
312
413
  }, 60000); // Every minute
313
414
  }
314
415
  destroy() {
package/dist/server.js CHANGED
@@ -35,15 +35,26 @@ export async function startReflectMCPServer(config) {
35
35
  authenticate: async (request) => {
36
36
  const authHeader = request.headers.authorization;
37
37
  if (!authHeader?.startsWith("Bearer ")) {
38
- return undefined;
38
+ console.warn("[Auth] Missing or invalid Authorization header - triggering 401");
39
+ // Throw Response to trigger re-authentication (per FastMCP docs)
40
+ throw new Response(null, {
41
+ status: 401,
42
+ statusText: "Unauthorized - Bearer token required",
43
+ });
39
44
  }
40
45
  const token = authHeader.slice(7);
41
46
  try {
42
47
  const tokenData = pkceProxy.loadUpstreamTokens(token);
43
48
  if (!tokenData) {
44
- return undefined;
49
+ console.warn("[Auth] Token validation failed for:", token.slice(0, 8) + "... - triggering 401");
50
+ // Throw Response to trigger re-authentication (per FastMCP docs)
51
+ throw new Response(null, {
52
+ status: 401,
53
+ statusText: "Unauthorized - Invalid or expired token",
54
+ });
45
55
  }
46
56
  const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
57
+ console.log("[Auth] Token validated, expires in:", expiresIn, "seconds");
47
58
  return {
48
59
  accessToken: tokenData.accessToken,
49
60
  refreshToken: tokenData.refreshToken,
@@ -51,8 +62,15 @@ export async function startReflectMCPServer(config) {
51
62
  };
52
63
  }
53
64
  catch (error) {
54
- console.error("[Auth] Error:", error);
55
- return undefined;
65
+ // Re-throw if it's already a Response (our auth failures above)
66
+ if (error instanceof Response) {
67
+ throw error;
68
+ }
69
+ console.error("[Auth] Error validating token:", error);
70
+ throw new Response(null, {
71
+ status: 401,
72
+ statusText: "Unauthorized - Token validation error",
73
+ });
56
74
  }
57
75
  },
58
76
  version: "1.0.0",
@@ -11,7 +11,7 @@ export function registerTools(server, dbPath) {
11
11
  // Tool: Get all Reflect graphs
12
12
  server.addTool({
13
13
  name: "get_graphs",
14
- description: "Get a list of all Reflect graphs accessible with the current access token",
14
+ description: "Get a list of all Reflect graphs.",
15
15
  parameters: z.object({}),
16
16
  execute: async (_args, { session }) => {
17
17
  if (!session) {
@@ -59,14 +59,14 @@ export function registerTools(server, dbPath) {
59
59
  // Tool: Get backlinks for a note from local Reflect SQLite database
60
60
  server.addTool({
61
61
  name: "get_backlinks",
62
- description: "Get backlinks for a note from the local Reflect database. Returns notes that link to the specified note.",
62
+ description: "Get backlinks for a note from Reflect. Use this tool to get more context about a note after calling the get_note tool.",
63
63
  parameters: z.object({
64
64
  subject: z.string().describe("The subject/title of the note to get backlinks for"),
65
- graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
65
+ graph_id: z.string().default("rapheal-brain").describe("The graph ID to search in"),
66
66
  limit: z.number().default(10).describe("Maximum number of backlinks to return"),
67
67
  }),
68
68
  execute: async (args) => {
69
- const { subject, graphId, limit } = args;
69
+ const { subject, graph_id, limit } = args;
70
70
  try {
71
71
  const dbFile = resolvedDbPath;
72
72
  const db = new Database(dbFile, { readonly: true });
@@ -79,7 +79,7 @@ export function registerTools(server, dbPath) {
79
79
  ORDER BY bl.updatedAt DESC
80
80
  LIMIT ?
81
81
  `);
82
- const results = stmt.all(subject, graphId, limit);
82
+ const results = stmt.all(subject, graph_id, limit);
83
83
  db.close();
84
84
  const backlinks = results.map((row) => ({
85
85
  fromSubject: row.from_subject,
@@ -91,7 +91,7 @@ export function registerTools(server, dbPath) {
91
91
  content: [
92
92
  {
93
93
  type: "text",
94
- text: JSON.stringify({ subject, graphId, backlinks }, null, 2),
94
+ text: JSON.stringify({ subject, graph_id, backlinks }, null, 2),
95
95
  },
96
96
  ],
97
97
  };
@@ -111,13 +111,13 @@ export function registerTools(server, dbPath) {
111
111
  // Tool: Get recent daily notes
112
112
  server.addTool({
113
113
  name: "get_daily_notes",
114
- description: "Get the most recent daily notes from the local Reflect database",
114
+ description: "Get the most recent daily notes from Reflect.",
115
115
  parameters: z.object({
116
116
  limit: z.number().default(5).describe("Number of recent daily notes to return"),
117
- graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
117
+ graph_id: z.string().default("rapheal-brain").describe("The graph ID to search in"),
118
118
  }),
119
119
  execute: async (args) => {
120
- const { limit, graphId } = args;
120
+ const { limit, graph_id } = args;
121
121
  try {
122
122
  const dbFile = resolvedDbPath;
123
123
  const db = new Database(dbFile, { readonly: true });
@@ -128,7 +128,7 @@ export function registerTools(server, dbPath) {
128
128
  ORDER BY dailyDate DESC
129
129
  LIMIT ?
130
130
  `);
131
- const rows = stmt.all(graphId, limit);
131
+ const rows = stmt.all(graph_id, limit);
132
132
  db.close();
133
133
  const dailyNotes = rows.map((row) => ({
134
134
  id: row.id,
@@ -137,13 +137,13 @@ export function registerTools(server, dbPath) {
137
137
  editedAt: formatDate(row.editedAt),
138
138
  tags: row.tags ? JSON.parse(row.tags) : [],
139
139
  dailyDate: formatDate(row.dailyDate),
140
- graphId: row.graphId,
140
+ graph_id: row.graphId,
141
141
  }));
142
142
  return {
143
143
  content: [
144
144
  {
145
145
  type: "text",
146
- text: JSON.stringify({ graphId, count: dailyNotes.length, dailyNotes }, null, 2),
146
+ text: JSON.stringify({ graph_id, count: dailyNotes.length, dailyNotes }, null, 2),
147
147
  },
148
148
  ],
149
149
  };
@@ -163,13 +163,13 @@ export function registerTools(server, dbPath) {
163
163
  // Tool: Get daily note by date
164
164
  server.addTool({
165
165
  name: "get_daily_note_by_date",
166
- description: "Get the daily note for a specific date from the local Reflect database",
166
+ description: "Get the daily note for a specific date.",
167
167
  parameters: z.object({
168
168
  date: z.string().describe("The date in YYYY-MM-DD format"),
169
- graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
169
+ graph_id: z.string().default("rapheal-brain").describe("The graph ID to search in"),
170
170
  }),
171
171
  execute: async (args) => {
172
- const { date, graphId } = args;
172
+ const { date, graph_id } = args;
173
173
  try {
174
174
  const dbFile = resolvedDbPath;
175
175
  const db = new Database(dbFile, { readonly: true });
@@ -180,14 +180,14 @@ export function registerTools(server, dbPath) {
180
180
  FROM notes
181
181
  WHERE isDaily = 1 AND isDeleted = 0 AND graphId = ? AND dailyDate = ?
182
182
  `);
183
- const result = stmt.get(graphId, dateMs);
183
+ const result = stmt.get(graph_id, dateMs);
184
184
  db.close();
185
185
  if (!result) {
186
186
  return {
187
187
  content: [
188
188
  {
189
189
  type: "text",
190
- text: JSON.stringify({ error: `No daily note found for ${date}`, date, graphId }),
190
+ text: JSON.stringify({ error: `No daily note found for ${date}`, date, graph_id }),
191
191
  },
192
192
  ],
193
193
  };
@@ -199,13 +199,13 @@ export function registerTools(server, dbPath) {
199
199
  editedAt: formatDate(result.editedAt),
200
200
  tags: result.tags ? JSON.parse(result.tags) : [],
201
201
  dailyDate: formatDate(result.dailyDate),
202
- graphId: result.graphId,
202
+ graph_id: result.graph_id,
203
203
  };
204
204
  return {
205
205
  content: [
206
206
  {
207
207
  type: "text",
208
- text: JSON.stringify({ date, graphId, dailyNote }, null, 2),
208
+ text: JSON.stringify({ date, graph_id, dailyNote }, null, 2),
209
209
  },
210
210
  ],
211
211
  };
@@ -225,14 +225,14 @@ export function registerTools(server, dbPath) {
225
225
  // Tool: Get notes with most backlinks
226
226
  server.addTool({
227
227
  name: "get_backlinked_notes",
228
- description: "Get notes that have at least a minimum number of backlinks from the local Reflect database",
228
+ description: "Get notes that have at least a minimum number of backlinks from Reflect.",
229
229
  parameters: z.object({
230
230
  minBacklinks: z.number().default(5).describe("Minimum number of backlinks a note must have"),
231
231
  limit: z.number().default(10).describe("Maximum number of notes to return"),
232
- graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
232
+ graph_id: z.string().default("rapheal-brain").describe("The graph ID to search in"),
233
233
  }),
234
234
  execute: async (args) => {
235
- const { minBacklinks, limit, graphId } = args;
235
+ const { minBacklinks, limit, graph_id } = args;
236
236
  try {
237
237
  const dbFile = resolvedDbPath;
238
238
  const db = new Database(dbFile, { readonly: true });
@@ -246,7 +246,7 @@ export function registerTools(server, dbPath) {
246
246
  ORDER BY backlink_count DESC
247
247
  LIMIT ?
248
248
  `);
249
- const results = stmt.all(graphId, minBacklinks, limit);
249
+ const results = stmt.all(graph_id, minBacklinks, limit);
250
250
  db.close();
251
251
  const notes = results.map((row) => ({
252
252
  id: row.id,
@@ -258,7 +258,7 @@ export function registerTools(server, dbPath) {
258
258
  content: [
259
259
  {
260
260
  type: "text",
261
- text: JSON.stringify({ graphId, minBacklinks, count: notes.length, notes }, null, 2),
261
+ text: JSON.stringify({ graph_id, minBacklinks, count: notes.length, notes }, null, 2),
262
262
  },
263
263
  ],
264
264
  };
@@ -278,13 +278,13 @@ export function registerTools(server, dbPath) {
278
278
  // Tool: Get all tags with usage counts
279
279
  server.addTool({
280
280
  name: "get_tags",
281
- description: "Get all unique tags with their usage counts from the local Reflect database",
281
+ description: "Get all unique tags with their usage counts from Reflect.",
282
282
  parameters: z.object({
283
- graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
283
+ graph_id: z.string().default("rapheal-brain").describe("The graph ID to search in"),
284
284
  limit: z.number().default(50).describe("Maximum number of tags to return"),
285
285
  }),
286
286
  execute: async (args) => {
287
- const { graphId, limit } = args;
287
+ const { graph_id, limit } = args;
288
288
  try {
289
289
  const dbFile = resolvedDbPath;
290
290
  const db = new Database(dbFile, { readonly: true });
@@ -292,7 +292,7 @@ export function registerTools(server, dbPath) {
292
292
  SELECT tags FROM notes
293
293
  WHERE isDeleted = 0 AND graphId = ? AND tags IS NOT NULL AND tags != '[]'
294
294
  `);
295
- const rows = stmt.all(graphId);
295
+ const rows = stmt.all(graph_id);
296
296
  db.close();
297
297
  const tagCounts = {};
298
298
  for (const row of rows) {
@@ -314,7 +314,7 @@ export function registerTools(server, dbPath) {
314
314
  content: [
315
315
  {
316
316
  type: "text",
317
- text: JSON.stringify({ graphId, totalTags: Object.keys(tagCounts).length, tags: sortedTags }, null, 2),
317
+ text: JSON.stringify({ graph_id, totalTags: Object.keys(tagCounts).length, tags: sortedTags }, null, 2),
318
318
  },
319
319
  ],
320
320
  };
@@ -334,14 +334,14 @@ export function registerTools(server, dbPath) {
334
334
  // Tool: Get notes with a specific tag
335
335
  server.addTool({
336
336
  name: "get_notes_with_tag",
337
- description: "Get notes that have a specific tag from the local Reflect database",
337
+ description: "Get notes that have a specific tag from Reflect.",
338
338
  parameters: z.object({
339
339
  tag: z.string().describe("The tag to search for"),
340
- graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
340
+ graph_id: z.string().default("rapheal-brain").describe("The graph ID to search in"),
341
341
  limit: z.number().default(20).describe("Maximum number of notes to return"),
342
342
  }),
343
343
  execute: async (args) => {
344
- const { tag, graphId, limit } = args;
344
+ const { tag, graph_id, limit } = args;
345
345
  try {
346
346
  const dbFile = resolvedDbPath;
347
347
  const db = new Database(dbFile, { readonly: true });
@@ -352,7 +352,7 @@ export function registerTools(server, dbPath) {
352
352
  ORDER BY editedAt DESC
353
353
  LIMIT ?
354
354
  `);
355
- const results = stmt.all(graphId, `%"${tag}"%`, limit);
355
+ const results = stmt.all(graph_id, `%"${tag}"%`, limit);
356
356
  db.close();
357
357
  const notes = results.map((row) => ({
358
358
  id: row.id,
@@ -366,7 +366,7 @@ export function registerTools(server, dbPath) {
366
366
  content: [
367
367
  {
368
368
  type: "text",
369
- text: JSON.stringify({ tag, graphId, count: notes.length, notes }, null, 2),
369
+ text: JSON.stringify({ tag, graph_id, count: notes.length, notes }, null, 2),
370
370
  },
371
371
  ],
372
372
  };
@@ -383,49 +383,116 @@ export function registerTools(server, dbPath) {
383
383
  }
384
384
  },
385
385
  });
386
- // Tool: Get a note by title
386
+ // Tool: Get a note by title (exact match first, then fuzzy fallback)
387
387
  server.addTool({
388
388
  name: "get_note",
389
- description: "Get a note by its title (subject) from the local Reflect database",
389
+ description: "Get a note by its title (subject) from Reflect.",
390
390
  parameters: z.object({
391
391
  title: z.string().describe("The title/subject of the note to retrieve"),
392
- graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
392
+ graph_id: z.string().default("rapheal-brain").describe("The graph ID to search in"),
393
393
  }),
394
394
  execute: async (args) => {
395
- const { title, graphId } = args;
395
+ const { title, graph_id } = args;
396
+ const FUZZY_LIMIT = 3;
396
397
  try {
397
398
  const dbFile = resolvedDbPath;
398
399
  const db = new Database(dbFile, { readonly: true });
399
- const stmt = db.prepare(`
400
+ // Try exact match first
401
+ const exactStmt = db.prepare(`
400
402
  SELECT id, subject, documentText, tags, editedAt, createdAt
401
403
  FROM notes
402
404
  WHERE isDeleted = 0 AND graphId = ? AND subject = ?
403
405
  `);
404
- const result = stmt.get(graphId, title);
406
+ const exactResult = exactStmt.get(graph_id, title);
407
+ if (exactResult) {
408
+ db.close();
409
+ const note = {
410
+ id: exactResult.id,
411
+ subject: exactResult.subject,
412
+ documentText: exactResult.documentText,
413
+ tags: exactResult.tags ? JSON.parse(exactResult.tags) : [],
414
+ editedAt: formatDate(exactResult.editedAt),
415
+ createdAt: formatDate(exactResult.createdAt),
416
+ };
417
+ return {
418
+ content: [
419
+ {
420
+ type: "text",
421
+ text: JSON.stringify({ title, graph_id, note }, null, 2),
422
+ },
423
+ ],
424
+ };
425
+ }
426
+ // No exact match - try fuzzy search
427
+ const searchTerm = title.toLowerCase();
428
+ const fuzzyStmt = db.prepare(`
429
+ SELECT id, subject, documentText, tags, editedAt, createdAt,
430
+ CASE
431
+ WHEN LOWER(subject) LIKE ? THEN 2
432
+ WHEN LOWER(subject) LIKE ? THEN 1
433
+ ELSE 0
434
+ END as relevance
435
+ FROM notes
436
+ WHERE isDeleted = 0
437
+ AND graphId = ?
438
+ AND (LOWER(subject) LIKE ? OR LOWER(subject) LIKE ?)
439
+ ORDER BY relevance DESC, editedAt DESC
440
+ LIMIT ?
441
+ `);
442
+ const fuzzyResults = fuzzyStmt.all(`${searchTerm}%`, // starts with (score 2)
443
+ `%${searchTerm}%`, // contains (score 1)
444
+ graph_id, `${searchTerm}%`, // WHERE starts with
445
+ `%${searchTerm}%`, // WHERE contains
446
+ FUZZY_LIMIT);
405
447
  db.close();
406
- if (!result) {
448
+ if (fuzzyResults.length === 0) {
407
449
  return {
408
450
  content: [
409
451
  {
410
452
  type: "text",
411
- text: JSON.stringify({ error: `Note '${title}' not found`, title, graphId }),
453
+ text: JSON.stringify({
454
+ error: `No notes found matching '${title}'`,
455
+ query: title,
456
+ graph_id
457
+ }),
412
458
  },
413
459
  ],
414
460
  };
415
461
  }
416
- const note = {
462
+ const notes = fuzzyResults.map((result) => ({
417
463
  id: result.id,
418
464
  subject: result.subject,
419
465
  documentText: result.documentText,
420
466
  tags: result.tags ? JSON.parse(result.tags) : [],
421
467
  editedAt: formatDate(result.editedAt),
422
468
  createdAt: formatDate(result.createdAt),
423
- };
469
+ }));
470
+ // If only one fuzzy match, return it directly
471
+ if (notes.length === 1) {
472
+ return {
473
+ content: [
474
+ {
475
+ type: "text",
476
+ text: JSON.stringify({
477
+ query: title,
478
+ graph_id,
479
+ note: notes[0],
480
+ matchType: "fuzzy"
481
+ }, null, 2),
482
+ },
483
+ ],
484
+ };
485
+ }
424
486
  return {
425
487
  content: [
426
488
  {
427
489
  type: "text",
428
- text: JSON.stringify({ title, graphId, note }, null, 2),
490
+ text: JSON.stringify({
491
+ query: title,
492
+ graph_id,
493
+ matchCount: notes.length,
494
+ notes
495
+ }, null, 2),
429
496
  },
430
497
  ],
431
498
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reflect-mcp",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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",
@@ -15,7 +15,8 @@
15
15
  "build": "tsc",
16
16
  "dev": "tsx src/cli.ts",
17
17
  "start": "node dist/cli.js",
18
- "prepublishOnly": "npm run build"
18
+ "prepublishOnly": "npm run build",
19
+ "postinstall": "npm rebuild better-sqlite3 || true"
19
20
  },
20
21
  "keywords": [
21
22
  "mcp",
@@ -36,8 +37,9 @@
36
37
  },
37
38
  "dependencies": {
38
39
  "@modelcontextprotocol/sdk": "^1.25.1",
39
- "better-sqlite3": "^11.0.0",
40
+ "better-sqlite3": "^11.10.0",
40
41
  "fastmcp": "^3.25.4",
42
+ "reflect-mcp": "^1.0.3",
41
43
  "zod": "^4.1.13"
42
44
  },
43
45
  "devDependencies": {