reflect-mcp 1.0.0

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.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reflect MCP Server - CLI Entry Point
4
+ *
5
+ * Run with: npx reflect-mcp <db-path>
6
+ */
7
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reflect MCP Server - CLI Entry Point
4
+ *
5
+ * Run with: npx reflect-mcp <db-path>
6
+ */
7
+ import { startReflectMCPServer } from "./server.js";
8
+ import { DEFAULT_DB_PATH } from "./utils.js";
9
+ const REFLECT_CLIENT_ID = "55798f25d5a24efb95e4174fff3d219e";
10
+ // Parse command line arguments
11
+ function parseArgs() {
12
+ const args = process.argv.slice(2);
13
+ let dbPath = DEFAULT_DB_PATH;
14
+ let port = 3000;
15
+ for (let i = 0; i < args.length; i++) {
16
+ if (args[i] === "--port" && args[i + 1]) {
17
+ port = parseInt(args[++i]);
18
+ }
19
+ else if (args[i] === "--help" || args[i] === "-h") {
20
+ console.log(`
21
+ Usage: reflect-mcp [db-path] [options]
22
+
23
+ Arguments:
24
+ db-path Path to Reflect SQLite database
25
+ (default: ${DEFAULT_DB_PATH})
26
+
27
+ Options:
28
+ --port <port> Port to run server on (default: 3000)
29
+ --help, -h Show this help message
30
+
31
+ Examples:
32
+ npx reflect-mcp
33
+ npx reflect-mcp ~/Library/Application\\ Support/Reflect/File\\ System/000/t/00/00000000
34
+ npx reflect-mcp /path/to/reflect/db --port 4000
35
+ `);
36
+ process.exit(0);
37
+ }
38
+ else if (!args[i].startsWith("--")) {
39
+ // Positional argument = db path
40
+ dbPath = args[i];
41
+ }
42
+ }
43
+ return { dbPath, port };
44
+ }
45
+ const { dbPath, port: PORT } = parseArgs();
46
+ startReflectMCPServer({
47
+ clientId: REFLECT_CLIENT_ID,
48
+ port: PORT,
49
+ dbPath,
50
+ }).then(() => {
51
+ console.log(`Reflect MCP Server running on http://localhost:${PORT}`);
52
+ console.log(`Database: ${dbPath}`);
53
+ }).catch((err) => {
54
+ console.error("Failed to start server:", err);
55
+ process.exit(1);
56
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * PKCE OAuth Proxy (No Client Secret Required)
3
+ *
4
+ * This module provides a custom OAuth proxy that uses PKCE for authentication
5
+ * without requiring a client secret, suitable for public clients.
6
+ */
7
+ export interface TokenData {
8
+ accessToken: string;
9
+ refreshToken?: string;
10
+ expiresAt: Date;
11
+ }
12
+ export interface PKCEOAuthProxyConfig {
13
+ baseUrl: string;
14
+ clientId: string;
15
+ authorizationEndpoint: string;
16
+ tokenEndpoint: string;
17
+ scopes: string[];
18
+ redirectPath?: string;
19
+ tokenStoragePath?: string;
20
+ }
21
+ export declare class PKCEOAuthProxy {
22
+ private config;
23
+ private transactions;
24
+ private tokens;
25
+ private cleanupInterval;
26
+ constructor(options: PKCEOAuthProxyConfig);
27
+ private loadTokensFromDisk;
28
+ private saveTokensToDisk;
29
+ private generatePKCE;
30
+ private generateId;
31
+ getAuthorizationServerMetadata(): {
32
+ issuer: string;
33
+ authorizationEndpoint: string;
34
+ tokenEndpoint: string;
35
+ registrationEndpoint: string;
36
+ responseTypesSupported: string[];
37
+ grantTypesSupported: string[];
38
+ codeChallengeMethodsSupported: string[];
39
+ scopesSupported: string[];
40
+ };
41
+ authorize(params: {
42
+ client_id: string;
43
+ redirect_uri: string;
44
+ response_type: string;
45
+ state?: string;
46
+ scope?: string;
47
+ code_challenge?: string;
48
+ code_challenge_method?: string;
49
+ }): Promise<Response>;
50
+ handleCallback(request: Request): Promise<Response>;
51
+ exchangeAuthorizationCode(params: {
52
+ grant_type: string;
53
+ code: string;
54
+ client_id: string;
55
+ redirect_uri: string;
56
+ code_verifier?: string;
57
+ client_secret?: string;
58
+ }): Promise<{
59
+ access_token: string;
60
+ token_type: string;
61
+ expires_in: number;
62
+ refresh_token?: string;
63
+ scope?: string;
64
+ }>;
65
+ exchangeRefreshToken(params: {
66
+ grant_type: string;
67
+ refresh_token: string;
68
+ client_id: string;
69
+ client_secret?: string;
70
+ }): Promise<{
71
+ access_token: string;
72
+ token_type: string;
73
+ expires_in: number;
74
+ refresh_token?: string;
75
+ }>;
76
+ registerClient(request: {
77
+ redirect_uris?: string[];
78
+ client_name?: string;
79
+ }): Promise<{
80
+ client_id: string;
81
+ client_name?: string;
82
+ redirect_uris?: string[];
83
+ }>;
84
+ loadUpstreamTokens(proxyToken: string): TokenData | null;
85
+ private startCleanup;
86
+ destroy(): void;
87
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * PKCE OAuth Proxy (No Client Secret Required)
3
+ *
4
+ * This module provides a custom OAuth proxy that uses PKCE for authentication
5
+ * without requiring a client secret, suitable for public clients.
6
+ */
7
+ import * as crypto from "crypto";
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as os from "os";
11
+ import { OAuthProxyError } from "fastmcp/auth";
12
+ // ============================================================================
13
+ // PKCEOAuthProxy Class
14
+ // ============================================================================
15
+ export class PKCEOAuthProxy {
16
+ config;
17
+ // In-memory storage for transactions (short-lived, don't need persistence)
18
+ transactions = new Map();
19
+ // Token storage - persisted to disk
20
+ tokens = new Map();
21
+ cleanupInterval = null;
22
+ constructor(options) {
23
+ this.config = {
24
+ baseUrl: options.baseUrl,
25
+ clientId: options.clientId,
26
+ authorizationEndpoint: options.authorizationEndpoint,
27
+ tokenEndpoint: options.tokenEndpoint,
28
+ redirectPath: options.redirectPath || "/oauth/callback",
29
+ scopes: options.scopes,
30
+ tokenStoragePath: options.tokenStoragePath || path.join(os.homedir(), ".reflect-mcp-tokens.json"),
31
+ };
32
+ this.loadTokensFromDisk();
33
+ this.startCleanup();
34
+ }
35
+ // Load tokens from disk on startup
36
+ loadTokensFromDisk() {
37
+ try {
38
+ if (fs.existsSync(this.config.tokenStoragePath)) {
39
+ const data = fs.readFileSync(this.config.tokenStoragePath, "utf-8");
40
+ const stored = JSON.parse(data);
41
+ for (const [key, value] of Object.entries(stored)) {
42
+ const expiresAt = new Date(value.expiresAt);
43
+ // Only load non-expired tokens
44
+ if (expiresAt > new Date()) {
45
+ this.tokens.set(key, {
46
+ accessToken: value.accessToken,
47
+ refreshToken: value.refreshToken,
48
+ expiresAt,
49
+ });
50
+ }
51
+ }
52
+ console.log(`[PKCEProxy] Loaded ${this.tokens.size} tokens from disk`);
53
+ }
54
+ }
55
+ catch (error) {
56
+ console.warn("[PKCEProxy] Failed to load tokens from disk:", error);
57
+ }
58
+ }
59
+ // Save tokens to disk
60
+ saveTokensToDisk() {
61
+ try {
62
+ const toStore = {};
63
+ for (const [key, value] of this.tokens) {
64
+ toStore[key] = {
65
+ accessToken: value.accessToken,
66
+ refreshToken: value.refreshToken,
67
+ expiresAt: value.expiresAt.toISOString(),
68
+ };
69
+ }
70
+ fs.writeFileSync(this.config.tokenStoragePath, JSON.stringify(toStore, null, 2));
71
+ }
72
+ catch (error) {
73
+ console.error("[PKCEProxy] Failed to save tokens to disk:", error);
74
+ }
75
+ }
76
+ // Generate PKCE code verifier and challenge
77
+ generatePKCE() {
78
+ // Generate a random code verifier (43-128 characters)
79
+ const verifier = crypto.randomBytes(32).toString("base64url");
80
+ // Create code challenge: BASE64URL(SHA256(code_verifier))
81
+ const challenge = crypto
82
+ .createHash("sha256")
83
+ .update(verifier)
84
+ .digest("base64url");
85
+ return { verifier, challenge };
86
+ }
87
+ generateId() {
88
+ return crypto.randomBytes(16).toString("hex");
89
+ }
90
+ // Get authorization server metadata for MCP clients
91
+ getAuthorizationServerMetadata() {
92
+ return {
93
+ issuer: this.config.baseUrl,
94
+ authorizationEndpoint: `${this.config.baseUrl}/oauth/authorize`,
95
+ tokenEndpoint: `${this.config.baseUrl}/oauth/token`,
96
+ registrationEndpoint: `${this.config.baseUrl}/oauth/register`,
97
+ responseTypesSupported: ["code"],
98
+ grantTypesSupported: ["authorization_code", "refresh_token"],
99
+ codeChallengeMethodsSupported: ["S256"],
100
+ scopesSupported: this.config.scopes,
101
+ };
102
+ }
103
+ // Handle /oauth/authorize - redirect to upstream with PKCE
104
+ async authorize(params) {
105
+ console.log("[PKCEProxy] Authorize called with params:", params);
106
+ if (params.response_type !== "code") {
107
+ return new Response(JSON.stringify({ error: "unsupported_response_type" }), {
108
+ status: 400,
109
+ headers: { "Content-Type": "application/json" },
110
+ });
111
+ }
112
+ // Generate our own PKCE for upstream
113
+ const pkce = this.generatePKCE();
114
+ const transactionId = this.generateId();
115
+ // Store transaction
116
+ const transaction = {
117
+ codeVerifier: pkce.verifier,
118
+ codeChallenge: pkce.challenge,
119
+ clientCallbackUrl: params.redirect_uri,
120
+ clientId: params.client_id,
121
+ clientState: params.state || this.generateId(),
122
+ scope: params.scope ? params.scope.split(" ") : this.config.scopes,
123
+ createdAt: new Date(),
124
+ expiresAt: new Date(Date.now() + 600 * 1000), // 10 minutes
125
+ };
126
+ this.transactions.set(transactionId, transaction);
127
+ console.log("[PKCEProxy] Created transaction:", transactionId);
128
+ // Build upstream authorization URL
129
+ const authUrl = new URL(this.config.authorizationEndpoint);
130
+ authUrl.searchParams.set("client_id", this.config.clientId);
131
+ authUrl.searchParams.set("redirect_uri", `${this.config.baseUrl}${this.config.redirectPath}`);
132
+ authUrl.searchParams.set("response_type", "code");
133
+ authUrl.searchParams.set("scope", transaction.scope.join(","));
134
+ authUrl.searchParams.set("state", transactionId); // Use transaction ID as state
135
+ authUrl.searchParams.set("code_challenge", pkce.challenge);
136
+ authUrl.searchParams.set("code_challenge_method", "S256");
137
+ console.log("[PKCEProxy] Redirecting to:", authUrl.toString());
138
+ return new Response(null, {
139
+ status: 302,
140
+ headers: { Location: authUrl.toString() },
141
+ });
142
+ }
143
+ // Handle /oauth/callback - exchange code for tokens
144
+ // FastMCP passes a Request object, so we need to extract params from URL
145
+ async handleCallback(request) {
146
+ const url = new URL(request.url);
147
+ const code = url.searchParams.get("code");
148
+ const state = url.searchParams.get("state");
149
+ console.log("[PKCEProxy] Callback received with state:", state, "code:", code ? "present" : "missing");
150
+ if (!state) {
151
+ console.error("[PKCEProxy] Missing state parameter");
152
+ return new Response(JSON.stringify({ error: "missing_state" }), {
153
+ status: 400,
154
+ headers: { "Content-Type": "application/json" },
155
+ });
156
+ }
157
+ if (!code) {
158
+ console.error("[PKCEProxy] Missing code parameter");
159
+ return new Response(JSON.stringify({ error: "missing_code" }), {
160
+ status: 400,
161
+ headers: { "Content-Type": "application/json" },
162
+ });
163
+ }
164
+ const transaction = this.transactions.get(state);
165
+ if (!transaction) {
166
+ console.error("[PKCEProxy] Transaction not found for state:", state);
167
+ console.error("[PKCEProxy] Available transactions:", Array.from(this.transactions.keys()));
168
+ return new Response(JSON.stringify({ error: "invalid_state" }), {
169
+ status: 400,
170
+ headers: { "Content-Type": "application/json" },
171
+ });
172
+ }
173
+ if (transaction.expiresAt < new Date()) {
174
+ this.transactions.delete(state);
175
+ return new Response(JSON.stringify({ error: "transaction_expired" }), {
176
+ status: 400,
177
+ headers: { "Content-Type": "application/json" },
178
+ });
179
+ }
180
+ // Exchange code for tokens with upstream (NO client_secret!)
181
+ console.log("[PKCEProxy] Exchanging code for tokens...");
182
+ const tokenResponse = await fetch(this.config.tokenEndpoint, {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
185
+ body: new URLSearchParams({
186
+ grant_type: "authorization_code",
187
+ code: code,
188
+ client_id: this.config.clientId,
189
+ redirect_uri: `${this.config.baseUrl}${this.config.redirectPath}`,
190
+ code_verifier: transaction.codeVerifier, // PKCE verifier, no secret!
191
+ }),
192
+ });
193
+ if (!tokenResponse.ok) {
194
+ const error = await tokenResponse.text();
195
+ console.error("[PKCEProxy] Token exchange failed:", error);
196
+ return new Response(JSON.stringify({ error: "token_exchange_failed", details: error }), {
197
+ status: 400,
198
+ headers: { "Content-Type": "application/json" },
199
+ });
200
+ }
201
+ const tokens = await tokenResponse.json();
202
+ console.log("[PKCEProxy] Got tokens, expires_in:", tokens.expires_in);
203
+ // Generate a proxy token to give to the client
204
+ const proxyToken = this.generateId();
205
+ this.tokens.set(proxyToken, {
206
+ accessToken: tokens.access_token,
207
+ refreshToken: tokens.refresh_token,
208
+ expiresAt: new Date(Date.now() + (tokens.expires_in || 3600) * 1000),
209
+ });
210
+ this.saveTokensToDisk(); // Persist to disk
211
+ // Redirect back to client with our proxy token
212
+ const clientRedirect = new URL(transaction.clientCallbackUrl);
213
+ clientRedirect.searchParams.set("code", proxyToken);
214
+ clientRedirect.searchParams.set("state", transaction.clientState);
215
+ // Clean up transaction
216
+ this.transactions.delete(state);
217
+ console.log("[PKCEProxy] Redirecting to client:", clientRedirect.toString());
218
+ return new Response(null, {
219
+ status: 302,
220
+ headers: { Location: clientRedirect.toString() },
221
+ });
222
+ }
223
+ // Handle /oauth/token - exchange proxy code for access token
224
+ // FastMCP expects a TokenResponse object, not a Response
225
+ async exchangeAuthorizationCode(params) {
226
+ console.log("[PKCEProxy] exchangeAuthorizationCode called with code:", params.code?.slice(0, 8) + "...");
227
+ if (!params.code) {
228
+ throw new OAuthProxyError("invalid_request", "Missing authorization code", 400);
229
+ }
230
+ const tokenData = this.tokens.get(params.code);
231
+ if (!tokenData) {
232
+ console.error("[PKCEProxy] Token not found for code:", params.code);
233
+ console.error("[PKCEProxy] Available tokens:", Array.from(this.tokens.keys()).map(k => k.slice(0, 8) + "..."));
234
+ throw new OAuthProxyError("invalid_grant", "Invalid or expired authorization code", 400);
235
+ }
236
+ // Remove the code (single use)
237
+ this.tokens.delete(params.code);
238
+ // Generate a new access token for the client
239
+ const accessToken = this.generateId();
240
+ this.tokens.set(accessToken, tokenData);
241
+ this.saveTokensToDisk(); // Persist to disk
242
+ const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
243
+ console.log("[PKCEProxy] Issuing access token, expires in:", expiresIn, "seconds");
244
+ return {
245
+ access_token: accessToken,
246
+ token_type: "Bearer",
247
+ expires_in: expiresIn > 0 ? expiresIn : 3600,
248
+ // Note: Not returning refresh_token since Reflect doesn't support refresh_token grant
249
+ // This tells the MCP client to re-authenticate via OAuth when the token expires
250
+ };
251
+ }
252
+ // Handle refresh token exchange
253
+ // Note: Reflect's API doesn't support standard refresh_token grant
254
+ // We throw an OAuthProxyError to trigger re-authentication via OAuth flow
255
+ async exchangeRefreshToken(params) {
256
+ console.log("[PKCEProxy] exchangeRefreshToken called - Reflect doesn't support refresh_token grant");
257
+ console.log("[PKCEProxy] Triggering re-authentication via OAuth flow...");
258
+ // Reflect's token endpoint only accepts authorization_code grant, not refresh_token
259
+ // Throw OAuthProxyError so FastMCP handles it properly and triggers re-auth
260
+ throw new OAuthProxyError("invalid_grant", "Refresh tokens are not supported. Please re-authenticate.", 400);
261
+ }
262
+ // Handle /oauth/register (Dynamic Client Registration)
263
+ async registerClient(request) {
264
+ // For public clients, we just acknowledge the registration
265
+ // The actual client_id is configured server-side
266
+ return {
267
+ client_id: this.generateId(),
268
+ client_name: request.client_name,
269
+ redirect_uris: request.redirect_uris,
270
+ };
271
+ }
272
+ // Load upstream tokens for a given proxy token
273
+ loadUpstreamTokens(proxyToken) {
274
+ const data = this.tokens.get(proxyToken);
275
+ if (!data)
276
+ return null;
277
+ if (data.expiresAt < new Date()) {
278
+ this.tokens.delete(proxyToken);
279
+ this.saveTokensToDisk();
280
+ return null;
281
+ }
282
+ return data;
283
+ }
284
+ // Cleanup expired transactions and tokens
285
+ startCleanup() {
286
+ this.cleanupInterval = setInterval(() => {
287
+ const now = new Date();
288
+ let tokensChanged = false;
289
+ for (const [id, tx] of this.transactions) {
290
+ if (tx.expiresAt < now)
291
+ this.transactions.delete(id);
292
+ }
293
+ for (const [id, token] of this.tokens) {
294
+ if (token.expiresAt < now) {
295
+ this.tokens.delete(id);
296
+ tokensChanged = true;
297
+ }
298
+ }
299
+ if (tokensChanged) {
300
+ this.saveTokensToDisk();
301
+ }
302
+ }, 60000); // Every minute
303
+ }
304
+ destroy() {
305
+ if (this.cleanupInterval) {
306
+ clearInterval(this.cleanupInterval);
307
+ this.cleanupInterval = null;
308
+ }
309
+ // Save tokens before shutdown
310
+ this.saveTokensToDisk();
311
+ }
312
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Reflect MCP Server Factory
3
+ *
4
+ * Creates and configures the FastMCP server with PKCE OAuth
5
+ */
6
+ export interface ServerConfig {
7
+ clientId: string;
8
+ port?: number;
9
+ dbPath?: string;
10
+ }
11
+ export declare function startReflectMCPServer(config: ServerConfig): Promise<void>;
12
+ export { PKCEOAuthProxy } from "./pkcehandler.js";
13
+ export * from "./utils.js";
package/dist/server.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reflect MCP Server Factory
3
+ *
4
+ * Creates and configures the FastMCP server with PKCE OAuth
5
+ */
6
+ import { FastMCP } from "fastmcp";
7
+ import { PKCEOAuthProxy } from "./pkcehandler.js";
8
+ import { registerTools } from "./tools/index.js";
9
+ export async function startReflectMCPServer(config) {
10
+ const port = config.port || 3000;
11
+ const baseUrl = `http://localhost:${port}`;
12
+ // Create PKCE OAuth Proxy (no client_secret required!)
13
+ const pkceProxy = new PKCEOAuthProxy({
14
+ baseUrl,
15
+ clientId: config.clientId,
16
+ authorizationEndpoint: "https://reflect.app/oauth",
17
+ tokenEndpoint: "https://reflect.app/api/oauth/token",
18
+ scopes: ["read:graph", "write:graph"],
19
+ });
20
+ // Get auth server metadata
21
+ const authServerMetadata = pkceProxy.getAuthorizationServerMetadata();
22
+ // Create FastMCP server
23
+ const server = new FastMCP({
24
+ name: "Reflect MCP Server",
25
+ oauth: {
26
+ authorizationServer: authServerMetadata,
27
+ enabled: true,
28
+ protectedResource: {
29
+ resource: baseUrl,
30
+ authorizationServers: [authServerMetadata.issuer],
31
+ scopesSupported: ["read:graph", "write:graph"],
32
+ },
33
+ proxy: pkceProxy,
34
+ },
35
+ authenticate: async (request) => {
36
+ const authHeader = request.headers.authorization;
37
+ if (!authHeader?.startsWith("Bearer ")) {
38
+ return undefined;
39
+ }
40
+ const token = authHeader.slice(7);
41
+ try {
42
+ const tokenData = pkceProxy.loadUpstreamTokens(token);
43
+ if (!tokenData) {
44
+ return undefined;
45
+ }
46
+ const expiresIn = Math.floor((tokenData.expiresAt.getTime() - Date.now()) / 1000);
47
+ return {
48
+ accessToken: tokenData.accessToken,
49
+ refreshToken: tokenData.refreshToken,
50
+ expiresIn,
51
+ };
52
+ }
53
+ catch (error) {
54
+ console.error("[Auth] Error:", error);
55
+ return undefined;
56
+ }
57
+ },
58
+ version: "1.0.0",
59
+ });
60
+ // Register all tools
61
+ registerTools(server, config.dbPath);
62
+ // Start server
63
+ await server.start({
64
+ httpStream: {
65
+ port,
66
+ stateless: true,
67
+ },
68
+ transportType: "httpStream",
69
+ });
70
+ }
71
+ // Also export for programmatic use
72
+ export { PKCEOAuthProxy } from "./pkcehandler.js";
73
+ export * from "./utils.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Reflect MCP Tools
3
+ *
4
+ * All tools for interacting with Reflect notes
5
+ */
6
+ import { FastMCP } from "fastmcp";
7
+ export declare function registerTools(server: FastMCP, dbPath?: string): void;
@@ -0,0 +1,545 @@
1
+ /**
2
+ * Reflect MCP Tools
3
+ *
4
+ * All tools for interacting with Reflect notes
5
+ */
6
+ import { z } from "zod";
7
+ import Database from "better-sqlite3";
8
+ import { DEFAULT_DB_PATH, expandPath, stripHtml, formatDate, getDateForTimezone } from "../utils.js";
9
+ export function registerTools(server, dbPath) {
10
+ const resolvedDbPath = expandPath(dbPath || DEFAULT_DB_PATH);
11
+ // Tool: Get all Reflect graphs
12
+ server.addTool({
13
+ name: "get_graphs",
14
+ description: "Get a list of all Reflect graphs accessible with the current access token",
15
+ parameters: z.object({}),
16
+ execute: async (_args, { session }) => {
17
+ if (!session) {
18
+ return {
19
+ content: [
20
+ {
21
+ type: "text",
22
+ text: JSON.stringify({ error: "Not authenticated. Please complete OAuth flow first." }),
23
+ },
24
+ ],
25
+ };
26
+ }
27
+ const { accessToken } = session;
28
+ try {
29
+ const response = await fetch("https://reflect.app/api/graphs", {
30
+ headers: {
31
+ Authorization: `Bearer ${accessToken}`,
32
+ },
33
+ });
34
+ if (!response.ok) {
35
+ throw new Error(`HTTP error! status: ${response.status}`);
36
+ }
37
+ const data = await response.json();
38
+ return {
39
+ content: [
40
+ {
41
+ type: "text",
42
+ text: JSON.stringify(data, null, 2),
43
+ },
44
+ ],
45
+ };
46
+ }
47
+ catch (e) {
48
+ return {
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text: JSON.stringify({ error: String(e) }),
53
+ },
54
+ ],
55
+ };
56
+ }
57
+ },
58
+ });
59
+ // Tool: Get backlinks for a note from local Reflect SQLite database
60
+ server.addTool({
61
+ name: "get_backlinks",
62
+ description: "Get backlinks for a note from the local Reflect database. Returns notes that link to the specified note.",
63
+ parameters: z.object({
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"),
66
+ limit: z.number().default(10).describe("Maximum number of backlinks to return"),
67
+ }),
68
+ execute: async (args) => {
69
+ const { subject, graphId, limit } = args;
70
+ try {
71
+ const dbFile = resolvedDbPath;
72
+ const db = new Database(dbFile, { readonly: true });
73
+ const stmt = db.prepare(`
74
+ SELECT bl.contextHtml, bl.label, bl.updatedAt, source.subject AS from_subject
75
+ FROM noteBacklinks bl
76
+ JOIN notes target ON bl.toNoteId = target.id
77
+ JOIN notes source ON bl.fromNoteId = source.id
78
+ WHERE target.subject = ? AND target.graphId = ?
79
+ ORDER BY bl.updatedAt DESC
80
+ LIMIT ?
81
+ `);
82
+ const results = stmt.all(subject, graphId, limit);
83
+ db.close();
84
+ const backlinks = results.map((row) => ({
85
+ fromSubject: row.from_subject,
86
+ label: row.label,
87
+ contextText: stripHtml(row.contextHtml),
88
+ updatedAt: formatDate(row.updatedAt),
89
+ }));
90
+ return {
91
+ content: [
92
+ {
93
+ type: "text",
94
+ text: JSON.stringify({ subject, graphId, backlinks }, null, 2),
95
+ },
96
+ ],
97
+ };
98
+ }
99
+ catch (e) {
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: JSON.stringify({ error: String(e) }),
105
+ },
106
+ ],
107
+ };
108
+ }
109
+ },
110
+ });
111
+ // Tool: Get recent daily notes
112
+ server.addTool({
113
+ name: "get_daily_notes",
114
+ description: "Get the most recent daily notes from the local Reflect database",
115
+ parameters: z.object({
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"),
118
+ }),
119
+ execute: async (args) => {
120
+ const { limit, graphId } = args;
121
+ try {
122
+ const dbFile = resolvedDbPath;
123
+ const db = new Database(dbFile, { readonly: true });
124
+ const stmt = db.prepare(`
125
+ SELECT id, subject, documentText, editedAt, tags, dailyDate, graphId
126
+ FROM notes
127
+ WHERE isDaily = 1 AND isDeleted = 0 AND LENGTH(documentText) > 0 AND graphId = ?
128
+ ORDER BY dailyDate DESC
129
+ LIMIT ?
130
+ `);
131
+ const rows = stmt.all(graphId, limit);
132
+ db.close();
133
+ const dailyNotes = rows.map((row) => ({
134
+ id: row.id,
135
+ subject: row.subject,
136
+ documentText: row.documentText?.slice(0, 500) || "",
137
+ editedAt: formatDate(row.editedAt),
138
+ tags: row.tags ? JSON.parse(row.tags) : [],
139
+ dailyDate: formatDate(row.dailyDate),
140
+ graphId: row.graphId,
141
+ }));
142
+ return {
143
+ content: [
144
+ {
145
+ type: "text",
146
+ text: JSON.stringify({ graphId, count: dailyNotes.length, dailyNotes }, null, 2),
147
+ },
148
+ ],
149
+ };
150
+ }
151
+ catch (e) {
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text",
156
+ text: JSON.stringify({ error: String(e) }),
157
+ },
158
+ ],
159
+ };
160
+ }
161
+ },
162
+ });
163
+ // Tool: Get daily note by date
164
+ server.addTool({
165
+ name: "get_daily_note_by_date",
166
+ description: "Get the daily note for a specific date from the local Reflect database",
167
+ parameters: z.object({
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"),
170
+ }),
171
+ execute: async (args) => {
172
+ const { date, graphId } = args;
173
+ try {
174
+ const dbFile = resolvedDbPath;
175
+ const db = new Database(dbFile, { readonly: true });
176
+ const dateObj = new Date(date + "T00:00:00");
177
+ const dateMs = dateObj.getTime();
178
+ const stmt = db.prepare(`
179
+ SELECT id, subject, documentText, editedAt, tags, dailyDate, graphId
180
+ FROM notes
181
+ WHERE isDaily = 1 AND isDeleted = 0 AND graphId = ? AND dailyDate = ?
182
+ `);
183
+ const result = stmt.get(graphId, dateMs);
184
+ db.close();
185
+ if (!result) {
186
+ return {
187
+ content: [
188
+ {
189
+ type: "text",
190
+ text: JSON.stringify({ error: `No daily note found for ${date}`, date, graphId }),
191
+ },
192
+ ],
193
+ };
194
+ }
195
+ const dailyNote = {
196
+ id: result.id,
197
+ subject: result.subject,
198
+ documentText: result.documentText,
199
+ editedAt: formatDate(result.editedAt),
200
+ tags: result.tags ? JSON.parse(result.tags) : [],
201
+ dailyDate: formatDate(result.dailyDate),
202
+ graphId: result.graphId,
203
+ };
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text",
208
+ text: JSON.stringify({ date, graphId, dailyNote }, null, 2),
209
+ },
210
+ ],
211
+ };
212
+ }
213
+ catch (e) {
214
+ return {
215
+ content: [
216
+ {
217
+ type: "text",
218
+ text: JSON.stringify({ error: String(e) }),
219
+ },
220
+ ],
221
+ };
222
+ }
223
+ },
224
+ });
225
+ // Tool: Get notes with most backlinks
226
+ server.addTool({
227
+ name: "get_backlinked_notes",
228
+ description: "Get notes that have at least a minimum number of backlinks from the local Reflect database",
229
+ parameters: z.object({
230
+ minBacklinks: z.number().default(5).describe("Minimum number of backlinks a note must have"),
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"),
233
+ }),
234
+ execute: async (args) => {
235
+ const { minBacklinks, limit, graphId } = args;
236
+ try {
237
+ const dbFile = resolvedDbPath;
238
+ const db = new Database(dbFile, { readonly: true });
239
+ const stmt = db.prepare(`
240
+ SELECT n.id, n.subject, COUNT(bl.id) as backlink_count, n.documentText
241
+ FROM notes n
242
+ JOIN noteBacklinks bl ON bl.toNoteId = n.id
243
+ WHERE n.isDeleted = 0 AND n.subject != 'Audio Memos' AND n.subject != 'Links' AND n.graphId = ?
244
+ GROUP BY n.id
245
+ HAVING COUNT(bl.id) >= ?
246
+ ORDER BY backlink_count DESC
247
+ LIMIT ?
248
+ `);
249
+ const results = stmt.all(graphId, minBacklinks, limit);
250
+ db.close();
251
+ const notes = results.map((row) => ({
252
+ id: row.id,
253
+ subject: row.subject,
254
+ backlinkCount: row.backlink_count,
255
+ documentText: row.documentText?.slice(0, 200) || "",
256
+ }));
257
+ return {
258
+ content: [
259
+ {
260
+ type: "text",
261
+ text: JSON.stringify({ graphId, minBacklinks, count: notes.length, notes }, null, 2),
262
+ },
263
+ ],
264
+ };
265
+ }
266
+ catch (e) {
267
+ return {
268
+ content: [
269
+ {
270
+ type: "text",
271
+ text: JSON.stringify({ error: String(e) }),
272
+ },
273
+ ],
274
+ };
275
+ }
276
+ },
277
+ });
278
+ // Tool: Get all tags with usage counts
279
+ server.addTool({
280
+ name: "get_tags",
281
+ description: "Get all unique tags with their usage counts from the local Reflect database",
282
+ parameters: z.object({
283
+ graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
284
+ limit: z.number().default(50).describe("Maximum number of tags to return"),
285
+ }),
286
+ execute: async (args) => {
287
+ const { graphId, limit } = args;
288
+ try {
289
+ const dbFile = resolvedDbPath;
290
+ const db = new Database(dbFile, { readonly: true });
291
+ const stmt = db.prepare(`
292
+ SELECT tags FROM notes
293
+ WHERE isDeleted = 0 AND graphId = ? AND tags IS NOT NULL AND tags != '[]'
294
+ `);
295
+ const rows = stmt.all(graphId);
296
+ db.close();
297
+ const tagCounts = {};
298
+ for (const row of rows) {
299
+ try {
300
+ const tags = JSON.parse(row.tags);
301
+ for (const tag of tags) {
302
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
303
+ }
304
+ }
305
+ catch {
306
+ continue;
307
+ }
308
+ }
309
+ const sortedTags = Object.entries(tagCounts)
310
+ .map(([tag, count]) => ({ tag, count }))
311
+ .sort((a, b) => b.count - a.count)
312
+ .slice(0, limit);
313
+ return {
314
+ content: [
315
+ {
316
+ type: "text",
317
+ text: JSON.stringify({ graphId, totalTags: Object.keys(tagCounts).length, tags: sortedTags }, null, 2),
318
+ },
319
+ ],
320
+ };
321
+ }
322
+ catch (e) {
323
+ return {
324
+ content: [
325
+ {
326
+ type: "text",
327
+ text: JSON.stringify({ error: String(e) }),
328
+ },
329
+ ],
330
+ };
331
+ }
332
+ },
333
+ });
334
+ // Tool: Get notes with a specific tag
335
+ server.addTool({
336
+ name: "get_notes_with_tag",
337
+ description: "Get notes that have a specific tag from the local Reflect database",
338
+ parameters: z.object({
339
+ tag: z.string().describe("The tag to search for"),
340
+ graphId: z.string().default("rapheal-brain").describe("The graph ID to search in"),
341
+ limit: z.number().default(20).describe("Maximum number of notes to return"),
342
+ }),
343
+ execute: async (args) => {
344
+ const { tag, graphId, limit } = args;
345
+ try {
346
+ const dbFile = resolvedDbPath;
347
+ const db = new Database(dbFile, { readonly: true });
348
+ const stmt = db.prepare(`
349
+ SELECT id, subject, tags, editedAt, LENGTH(documentText) as docLen, documentText
350
+ FROM notes
351
+ WHERE isDeleted = 0 AND graphId = ? AND tags LIKE ?
352
+ ORDER BY editedAt DESC
353
+ LIMIT ?
354
+ `);
355
+ const results = stmt.all(graphId, `%"${tag}"%`, limit);
356
+ db.close();
357
+ const notes = results.map((row) => ({
358
+ id: row.id,
359
+ subject: row.subject,
360
+ tags: row.tags ? JSON.parse(row.tags) : [],
361
+ editedAt: formatDate(row.editedAt),
362
+ documentLength: row.docLen,
363
+ documentText: row.documentText?.slice(0, 300) || "",
364
+ }));
365
+ return {
366
+ content: [
367
+ {
368
+ type: "text",
369
+ text: JSON.stringify({ tag, graphId, count: notes.length, notes }, null, 2),
370
+ },
371
+ ],
372
+ };
373
+ }
374
+ catch (e) {
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: JSON.stringify({ error: String(e) }),
380
+ },
381
+ ],
382
+ };
383
+ }
384
+ },
385
+ });
386
+ // Tool: Get a note by title
387
+ server.addTool({
388
+ name: "get_note",
389
+ description: "Get a note by its title (subject) from the local Reflect database",
390
+ parameters: z.object({
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"),
393
+ }),
394
+ execute: async (args) => {
395
+ const { title, graphId } = args;
396
+ try {
397
+ const dbFile = resolvedDbPath;
398
+ const db = new Database(dbFile, { readonly: true });
399
+ const stmt = db.prepare(`
400
+ SELECT id, subject, documentText, tags, editedAt, createdAt
401
+ FROM notes
402
+ WHERE isDeleted = 0 AND graphId = ? AND subject = ?
403
+ `);
404
+ const result = stmt.get(graphId, title);
405
+ db.close();
406
+ if (!result) {
407
+ return {
408
+ content: [
409
+ {
410
+ type: "text",
411
+ text: JSON.stringify({ error: `Note '${title}' not found`, title, graphId }),
412
+ },
413
+ ],
414
+ };
415
+ }
416
+ const note = {
417
+ id: result.id,
418
+ subject: result.subject,
419
+ documentText: result.documentText,
420
+ tags: result.tags ? JSON.parse(result.tags) : [],
421
+ editedAt: formatDate(result.editedAt),
422
+ createdAt: formatDate(result.createdAt),
423
+ };
424
+ return {
425
+ content: [
426
+ {
427
+ type: "text",
428
+ text: JSON.stringify({ title, graphId, note }, null, 2),
429
+ },
430
+ ],
431
+ };
432
+ }
433
+ catch (e) {
434
+ return {
435
+ content: [
436
+ {
437
+ type: "text",
438
+ text: JSON.stringify({ error: String(e) }),
439
+ },
440
+ ],
441
+ };
442
+ }
443
+ },
444
+ });
445
+ // Tool: Create a new note in Reflect via API
446
+ server.addTool({
447
+ name: "create_note",
448
+ description: "Create a new note in Reflect. Must add the tasks field if there are any actionable items to add. Pass in the user's timezone to ensure the note is created with the correct date. Check what tags the user has already created, and determine which tag to use for this content, or create a new tag if no tags fit the content.",
449
+ parameters: z.object({
450
+ subject: z.string().describe("The title/subject of the note. Example: 'Meeting Summary - Project Planning'"),
451
+ content: z.string().describe("The markdown content for the note. This is the main body of the note."),
452
+ graph_id: z.string().describe("The unique identifier of the Reflect graph where the note should be created."),
453
+ timezone: z.string().describe("The user's timezone in IANA format. Example: 'America/New_York', 'Europe/London', 'Asia/Tokyo'. Used to determine the correct date for the daily note backlink."),
454
+ tag: z.string().describe("The tag to add to the note. Example: 'personal'"),
455
+ tasks: z.array(z.string()).optional().describe("A list of tasks to add to the note. Must add this field if there are any actionable items. Example: ['Review PR', 'Schedule meeting']"),
456
+ }),
457
+ execute: async (args, { session }) => {
458
+ if (!session) {
459
+ return {
460
+ content: [
461
+ {
462
+ type: "text",
463
+ text: JSON.stringify({ error: "Not authenticated. Please complete OAuth flow first." }),
464
+ },
465
+ ],
466
+ };
467
+ }
468
+ const { accessToken } = session;
469
+ const { subject, content, graph_id, timezone, tag, tasks } = args;
470
+ const todayDate = getDateForTimezone(timezone);
471
+ const contentParts = [];
472
+ contentParts.push(`#ai-generated\n`);
473
+ contentParts.push(`- [[${tag}]]\n`);
474
+ const contentLines = content.split('\n');
475
+ const indentedContent = contentLines.map(line => ` ${line}`).join('\n');
476
+ contentParts.push(indentedContent);
477
+ if (tasks && tasks.length > 0) {
478
+ contentParts.push('');
479
+ contentParts.push(' ## Tasks');
480
+ const formattedTasks = tasks.map(task => ` - + ${task}`).join('\n');
481
+ contentParts.push(formattedTasks);
482
+ }
483
+ const fullContent = contentParts.join('\n');
484
+ try {
485
+ const response = await fetch(`https://reflect.app/api/graphs/${graph_id}/notes`, {
486
+ method: "POST",
487
+ headers: {
488
+ Authorization: `Bearer ${accessToken}`,
489
+ "Content-Type": "application/json",
490
+ },
491
+ body: JSON.stringify({
492
+ subject: subject,
493
+ content_markdown: fullContent,
494
+ pinned: false,
495
+ }),
496
+ });
497
+ if (!response.ok) {
498
+ const errorText = await response.text();
499
+ throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
500
+ }
501
+ const data = await response.json();
502
+ const dailyNoteResponse = await fetch(`https://reflect.app/api/graphs/${graph_id}/daily-notes`, {
503
+ method: "PUT",
504
+ headers: {
505
+ Authorization: `Bearer ${accessToken}`,
506
+ "Content-Type": "application/json",
507
+ },
508
+ body: JSON.stringify({
509
+ date: todayDate,
510
+ text: `[[${subject}]]`,
511
+ transform_type: "list-append",
512
+ list_name: `[[${tag}]]`,
513
+ }),
514
+ });
515
+ if (!dailyNoteResponse.ok) {
516
+ const errorText = await dailyNoteResponse.text();
517
+ console.error(`Failed to append to daily notes: ${dailyNoteResponse.status}, ${errorText}`);
518
+ }
519
+ const message = `Note "${subject}" created with tag [[${tag}]]${tasks?.length ? ` and ${tasks.length} task(s)` : ''} and linked in daily notes`;
520
+ return {
521
+ content: [
522
+ {
523
+ type: "text",
524
+ text: JSON.stringify({
525
+ success: true,
526
+ note: data,
527
+ message: message
528
+ }, null, 2),
529
+ },
530
+ ],
531
+ };
532
+ }
533
+ catch (e) {
534
+ return {
535
+ content: [
536
+ {
537
+ type: "text",
538
+ text: JSON.stringify({ error: String(e) }),
539
+ },
540
+ ],
541
+ };
542
+ }
543
+ },
544
+ });
545
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Utility functions for the Reflect MCP Server
3
+ */
4
+ export declare const DEFAULT_DB_PATH = "~/Library/Application Support/Reflect/File System/000/t/00/00000000";
5
+ /**
6
+ * Expands ~ to the user's home directory
7
+ */
8
+ export declare function expandPath(filePath: string): string;
9
+ /**
10
+ * Strips HTML tags from a string, converting <br> to newlines
11
+ */
12
+ export declare function stripHtml(html: string | null): string;
13
+ /**
14
+ * Formats a timestamp in milliseconds to an ISO date string
15
+ */
16
+ export declare function formatDate(timestampMs: number): string;
17
+ /**
18
+ * Gets today's date in YYYY-MM-DD format for a specific timezone
19
+ */
20
+ export declare function getDateForTimezone(timezone: string): string;
package/dist/utils.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Utility functions for the Reflect MCP Server
3
+ */
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ // Reflect local database path
7
+ export const DEFAULT_DB_PATH = "~/Library/Application Support/Reflect/File System/000/t/00/00000000";
8
+ /**
9
+ * Expands ~ to the user's home directory
10
+ */
11
+ export function expandPath(filePath) {
12
+ if (filePath.startsWith("~")) {
13
+ return path.join(os.homedir(), filePath.slice(1));
14
+ }
15
+ return filePath;
16
+ }
17
+ /**
18
+ * Strips HTML tags from a string, converting <br> to newlines
19
+ */
20
+ export function stripHtml(html) {
21
+ if (!html)
22
+ return "";
23
+ let text = html.replace(/<br\s*\/?>/gi, "\n");
24
+ text = text.replace(/<[^>]+>/g, "");
25
+ text = text.replace(/\n\s*\n/g, "\n\n");
26
+ return text.trim();
27
+ }
28
+ /**
29
+ * Formats a timestamp in milliseconds to an ISO date string
30
+ */
31
+ export function formatDate(timestampMs) {
32
+ return new Date(timestampMs).toISOString();
33
+ }
34
+ /**
35
+ * Gets today's date in YYYY-MM-DD format for a specific timezone
36
+ */
37
+ export function getDateForTimezone(timezone) {
38
+ const now = new Date();
39
+ const formatter = new Intl.DateTimeFormat('en-CA', {
40
+ timeZone: timezone,
41
+ year: 'numeric',
42
+ month: '2-digit',
43
+ day: '2-digit',
44
+ });
45
+ return formatter.format(now);
46
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "reflect-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Reflect Notes - connect your notes to Claude Desktop. Just run: npx reflect-mcp",
5
+ "type": "module",
6
+ "main": "dist/server.js",
7
+ "types": "dist/server.d.ts",
8
+ "bin": {
9
+ "reflect-mcp": "./dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsx src/cli.ts",
17
+ "start": "node dist/cli.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "reflect",
23
+ "claude",
24
+ "ai",
25
+ "notes",
26
+ "model-context-protocol"
27
+ ],
28
+ "author": "",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": ""
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "dependencies": {
38
+ "better-sqlite3": "^11.0.0",
39
+ "fastmcp": "^3.25.4",
40
+ "zod": "^4.1.13"
41
+ },
42
+ "devDependencies": {
43
+ "@types/better-sqlite3": "^7.6.13",
44
+ "@types/node": "^25.0.0",
45
+ "tsx": "^4.21.0",
46
+ "typescript": "^5.9.3"
47
+ }
48
+ }