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 +7 -0
- package/dist/cli.js +56 -0
- package/dist/pkcehandler.d.ts +87 -0
- package/dist/pkcehandler.js +312 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +73 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +545 -0
- package/dist/utils.d.ts +20 -0
- package/dist/utils.js +46 -0
- package/package.json +48 -0
package/dist/cli.d.ts
ADDED
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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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,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
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -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
|
+
}
|