purmemo-mcp 1.0.0 β 2.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/package.json +25 -11
- package/src/auth/oauth-manager.js +365 -0
- package/src/auth/token-store.js +155 -0
- package/src/server-oauth.js +374 -0
- package/src/setup.js +265 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "purmemo-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Official Model Context Protocol (MCP) server for Purmemo -
|
|
5
|
-
"main": "src/
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Official Model Context Protocol (MCP) server for Purmemo - Seamless OAuth authentication for your AI-powered second brain",
|
|
5
|
+
"main": "src/server-oauth.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"purmemo-mcp": "./
|
|
8
|
+
"purmemo-mcp": "./src/server-oauth.js",
|
|
9
|
+
"purmemo-mcp-setup": "./src/setup.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src/",
|
|
@@ -14,35 +15,48 @@
|
|
|
14
15
|
"LICENSE"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
|
-
"start": "node src/
|
|
18
|
+
"start": "node src/server-oauth.js",
|
|
19
|
+
"setup": "node src/setup.js setup",
|
|
20
|
+
"status": "node src/setup.js status",
|
|
21
|
+
"logout": "node src/setup.js logout",
|
|
22
|
+
"test": "echo \"No tests yet\"",
|
|
23
|
+
"postinstall": "node -e \"console.log('\\nπ§ pΕ«rmemo MCP v2.0.0 installed!\\n\\nNew users: Run \\'npx purmemo-mcp setup\\' to connect your account.\\nExisting users: Your API key still works (backwards compatible).\\n')\""
|
|
18
24
|
},
|
|
19
25
|
"keywords": [
|
|
20
26
|
"mcp",
|
|
21
27
|
"memory",
|
|
22
28
|
"ai",
|
|
23
29
|
"claude",
|
|
24
|
-
"purmemo"
|
|
30
|
+
"purmemo",
|
|
31
|
+
"oauth",
|
|
32
|
+
"authentication"
|
|
25
33
|
],
|
|
26
|
-
"author": "
|
|
34
|
+
"author": "Purmemo Team <support@purmemo.ai>",
|
|
27
35
|
"license": "MIT",
|
|
28
36
|
"repository": {
|
|
29
37
|
"type": "git",
|
|
30
38
|
"url": "git+https://github.com/coladapo/purmemo-mcp.git"
|
|
31
39
|
},
|
|
32
40
|
"bugs": {
|
|
33
|
-
"url": "https://github.com/coladapo/
|
|
41
|
+
"url": "https://github.com/coladapo/purmemo-mcp/issues",
|
|
34
42
|
"email": "support@purmemo.ai"
|
|
35
43
|
},
|
|
36
44
|
"funding": {
|
|
37
45
|
"type": "github",
|
|
38
46
|
"url": "https://github.com/sponsors/coladapo"
|
|
39
47
|
},
|
|
40
|
-
"support": "https://github.com/coladapo/
|
|
48
|
+
"support": "https://github.com/coladapo/purmemo-mcp/discussions",
|
|
41
49
|
"dependencies": {
|
|
42
50
|
"@modelcontextprotocol/sdk": "^1.16.0",
|
|
43
|
-
"node-fetch": "^3.3.2"
|
|
51
|
+
"node-fetch": "^3.3.2",
|
|
52
|
+
"express": "^4.18.2",
|
|
53
|
+
"open": "^10.0.0",
|
|
54
|
+
"commander": "^11.1.0",
|
|
55
|
+
"chalk": "^5.3.0",
|
|
56
|
+
"inquirer": "^9.2.12",
|
|
57
|
+
"ora": "^7.0.1"
|
|
44
58
|
},
|
|
45
59
|
"engines": {
|
|
46
60
|
"node": ">=18.0.0"
|
|
47
61
|
}
|
|
48
|
-
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Manager for Purmemo MCP
|
|
3
|
+
* Handles OAuth 2.1 + PKCE flow for seamless authentication
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import open from 'open';
|
|
9
|
+
import TokenStore from './token-store.js';
|
|
10
|
+
|
|
11
|
+
class OAuthManager {
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
this.apiUrl = config.apiUrl || process.env.PUO_MEMO_API_URL || 'https://api.purmemo.ai';
|
|
14
|
+
this.clientId = config.clientId || 'purmemo-mcp';
|
|
15
|
+
this.redirectUri = config.redirectUri || 'http://localhost:3456/callback';
|
|
16
|
+
this.tokenStore = new TokenStore();
|
|
17
|
+
this.server = null;
|
|
18
|
+
this.pendingAuth = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get current authentication token
|
|
23
|
+
* @returns {Promise<string|null>} Access token or null if not authenticated
|
|
24
|
+
*/
|
|
25
|
+
async getToken() {
|
|
26
|
+
// First check if we have a valid token
|
|
27
|
+
const storedToken = await this.tokenStore.getToken();
|
|
28
|
+
|
|
29
|
+
if (storedToken && storedToken.access_token) {
|
|
30
|
+
// Check if token needs refresh (expired or close to expiry)
|
|
31
|
+
if (this.isTokenExpired(storedToken)) {
|
|
32
|
+
try {
|
|
33
|
+
return await this.refreshToken(storedToken.refresh_token);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('Token refresh failed:', error.message);
|
|
36
|
+
// If refresh fails, start new OAuth flow
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return storedToken.access_token;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fallback to environment variable for backwards compatibility
|
|
44
|
+
const envApiKey = process.env.PUO_MEMO_API_KEY;
|
|
45
|
+
if (envApiKey) {
|
|
46
|
+
console.log('π Using API key from environment variable');
|
|
47
|
+
return envApiKey;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if token is expired or about to expire
|
|
55
|
+
*/
|
|
56
|
+
isTokenExpired(token) {
|
|
57
|
+
if (!token.expires_at) return false;
|
|
58
|
+
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const expiresAt = new Date(token.expires_at).getTime();
|
|
61
|
+
const bufferTime = 5 * 60 * 1000; // 5 minutes buffer
|
|
62
|
+
|
|
63
|
+
return now >= (expiresAt - bufferTime);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Start OAuth flow
|
|
68
|
+
* @returns {Promise<string>} Access token
|
|
69
|
+
*/
|
|
70
|
+
async authenticate() {
|
|
71
|
+
if (this.pendingAuth) {
|
|
72
|
+
return this.pendingAuth;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.pendingAuth = this.performOAuthFlow();
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const token = await this.pendingAuth;
|
|
79
|
+
return token;
|
|
80
|
+
} finally {
|
|
81
|
+
this.pendingAuth = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Perform the actual OAuth flow
|
|
87
|
+
*/
|
|
88
|
+
async performOAuthFlow() {
|
|
89
|
+
console.log('\nπ Starting Purmemo authentication...\n');
|
|
90
|
+
|
|
91
|
+
// Generate PKCE challenge
|
|
92
|
+
const codeVerifier = this.generateCodeVerifier();
|
|
93
|
+
const codeChallenge = this.generateCodeChallenge(codeVerifier);
|
|
94
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
95
|
+
|
|
96
|
+
// Build OAuth URL
|
|
97
|
+
const authUrl = new URL(`${this.apiUrl}/api/oauth/initiate`);
|
|
98
|
+
authUrl.searchParams.append('client_id', this.clientId);
|
|
99
|
+
authUrl.searchParams.append('redirect_uri', this.redirectUri);
|
|
100
|
+
authUrl.searchParams.append('response_type', 'code');
|
|
101
|
+
authUrl.searchParams.append('scope', 'memories.read memories.write entities.read');
|
|
102
|
+
authUrl.searchParams.append('state', state);
|
|
103
|
+
authUrl.searchParams.append('code_challenge', codeChallenge);
|
|
104
|
+
authUrl.searchParams.append('code_challenge_method', 'S256');
|
|
105
|
+
|
|
106
|
+
// Start local server for callback
|
|
107
|
+
const authCode = await this.startCallbackServer(state);
|
|
108
|
+
|
|
109
|
+
// Open browser for authentication
|
|
110
|
+
console.log('π± Opening browser for authentication...');
|
|
111
|
+
console.log(' If browser doesn\'t open, visit:');
|
|
112
|
+
console.log(` ${authUrl.toString()}\n`);
|
|
113
|
+
|
|
114
|
+
await open(authUrl.toString());
|
|
115
|
+
|
|
116
|
+
// Wait for callback with auth code
|
|
117
|
+
const code = await authCode;
|
|
118
|
+
|
|
119
|
+
// Exchange code for token
|
|
120
|
+
const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier);
|
|
121
|
+
|
|
122
|
+
// Store token securely
|
|
123
|
+
await this.tokenStore.saveToken(tokenResponse);
|
|
124
|
+
|
|
125
|
+
console.log('β
Authentication successful!\n');
|
|
126
|
+
|
|
127
|
+
return tokenResponse.access_token;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Start local server to handle OAuth callback
|
|
132
|
+
*/
|
|
133
|
+
startCallbackServer(expectedState) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const app = express();
|
|
136
|
+
let resolved = false;
|
|
137
|
+
|
|
138
|
+
app.get('/callback', async (req, res) => {
|
|
139
|
+
const { code, state, error, error_description } = req.query;
|
|
140
|
+
|
|
141
|
+
if (error) {
|
|
142
|
+
res.send(`
|
|
143
|
+
<html>
|
|
144
|
+
<head>
|
|
145
|
+
<title>Authentication Failed</title>
|
|
146
|
+
<style>
|
|
147
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
148
|
+
display: flex; justify-content: center; align-items: center;
|
|
149
|
+
height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
|
150
|
+
.container { background: white; padding: 40px; border-radius: 10px;
|
|
151
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3); text-align: center; max-width: 400px; }
|
|
152
|
+
h1 { color: #e53e3e; margin: 0 0 10px 0; }
|
|
153
|
+
p { color: #718096; margin: 10px 0; }
|
|
154
|
+
.error { background: #fed7d7; padding: 10px; border-radius: 5px;
|
|
155
|
+
color: #c53030; margin-top: 20px; }
|
|
156
|
+
</style>
|
|
157
|
+
</head>
|
|
158
|
+
<body>
|
|
159
|
+
<div class="container">
|
|
160
|
+
<h1>β Authentication Failed</h1>
|
|
161
|
+
<p>Unable to complete authentication</p>
|
|
162
|
+
<div class="error">${error}: ${error_description || 'Unknown error'}</div>
|
|
163
|
+
<p style="margin-top: 20px; font-size: 14px;">You can close this window</p>
|
|
164
|
+
</div>
|
|
165
|
+
</body>
|
|
166
|
+
</html>
|
|
167
|
+
`);
|
|
168
|
+
|
|
169
|
+
if (!resolved) {
|
|
170
|
+
resolved = true;
|
|
171
|
+
this.stopCallbackServer();
|
|
172
|
+
reject(new Error(`OAuth error: ${error} - ${error_description}`));
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (state !== expectedState) {
|
|
178
|
+
res.send(`
|
|
179
|
+
<html>
|
|
180
|
+
<head><title>Security Error</title></head>
|
|
181
|
+
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
|
182
|
+
<h1 style="color: red;">Security Error</h1>
|
|
183
|
+
<p>State mismatch - possible CSRF attack</p>
|
|
184
|
+
</body>
|
|
185
|
+
</html>
|
|
186
|
+
`);
|
|
187
|
+
|
|
188
|
+
if (!resolved) {
|
|
189
|
+
resolved = true;
|
|
190
|
+
this.stopCallbackServer();
|
|
191
|
+
reject(new Error('OAuth state mismatch'));
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Success response
|
|
197
|
+
res.send(`
|
|
198
|
+
<html>
|
|
199
|
+
<head>
|
|
200
|
+
<title>Authentication Successful</title>
|
|
201
|
+
<style>
|
|
202
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
203
|
+
display: flex; justify-content: center; align-items: center;
|
|
204
|
+
height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
|
205
|
+
.container { background: white; padding: 40px; border-radius: 10px;
|
|
206
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3); text-align: center; max-width: 400px; }
|
|
207
|
+
h1 { color: #48bb78; margin: 0 0 10px 0; }
|
|
208
|
+
p { color: #718096; margin: 10px 0; }
|
|
209
|
+
.success { background: #c6f6d5; padding: 15px; border-radius: 5px;
|
|
210
|
+
color: #22543d; margin-top: 20px; }
|
|
211
|
+
.logo { font-size: 48px; margin-bottom: 20px; }
|
|
212
|
+
</style>
|
|
213
|
+
</head>
|
|
214
|
+
<body>
|
|
215
|
+
<div class="container">
|
|
216
|
+
<div class="logo">π§ </div>
|
|
217
|
+
<h1>β
Authentication Successful!</h1>
|
|
218
|
+
<p>You're now connected to Purmemo</p>
|
|
219
|
+
<div class="success">
|
|
220
|
+
You can now close this window and return to Claude Desktop
|
|
221
|
+
</div>
|
|
222
|
+
<script>setTimeout(() => window.close(), 3000);</script>
|
|
223
|
+
</div>
|
|
224
|
+
</body>
|
|
225
|
+
</html>
|
|
226
|
+
`);
|
|
227
|
+
|
|
228
|
+
if (!resolved) {
|
|
229
|
+
resolved = true;
|
|
230
|
+
this.stopCallbackServer();
|
|
231
|
+
resolve(code);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Start server
|
|
236
|
+
this.server = app.listen(3456, () => {
|
|
237
|
+
console.log('π Waiting for authentication callback...\n');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Timeout after 5 minutes
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
if (!resolved) {
|
|
243
|
+
resolved = true;
|
|
244
|
+
this.stopCallbackServer();
|
|
245
|
+
reject(new Error('Authentication timeout'));
|
|
246
|
+
}
|
|
247
|
+
}, 5 * 60 * 1000);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Stop the callback server
|
|
253
|
+
*/
|
|
254
|
+
stopCallbackServer() {
|
|
255
|
+
if (this.server) {
|
|
256
|
+
this.server.close();
|
|
257
|
+
this.server = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Exchange authorization code for access token
|
|
263
|
+
*/
|
|
264
|
+
async exchangeCodeForToken(code, codeVerifier) {
|
|
265
|
+
const response = await fetch(`${this.apiUrl}/api/oauth/token`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: {
|
|
268
|
+
'Content-Type': 'application/json',
|
|
269
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
grant_type: 'authorization_code',
|
|
273
|
+
code,
|
|
274
|
+
client_id: this.clientId,
|
|
275
|
+
redirect_uri: this.redirectUri,
|
|
276
|
+
code_verifier: codeVerifier
|
|
277
|
+
})
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
const error = await response.text();
|
|
282
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const tokenData = await response.json();
|
|
286
|
+
|
|
287
|
+
// Add expiry time
|
|
288
|
+
if (tokenData.expires_in) {
|
|
289
|
+
tokenData.expires_at = new Date(Date.now() + tokenData.expires_in * 1000).toISOString();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Store user tier info
|
|
293
|
+
if (tokenData.user) {
|
|
294
|
+
tokenData.user_tier = tokenData.user.tier || 'free';
|
|
295
|
+
tokenData.memory_limit = tokenData.user.tier === 'pro' ? null : 100;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return tokenData;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Refresh access token
|
|
303
|
+
*/
|
|
304
|
+
async refreshToken(refreshToken) {
|
|
305
|
+
if (!refreshToken) {
|
|
306
|
+
throw new Error('No refresh token available');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log('π Refreshing authentication token...');
|
|
310
|
+
|
|
311
|
+
const response = await fetch(`${this.apiUrl}/api/auth/refresh`, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: {
|
|
314
|
+
'Content-Type': 'application/json',
|
|
315
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
refresh_token: refreshToken
|
|
319
|
+
})
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
const error = await response.text();
|
|
324
|
+
throw new Error(`Token refresh failed: ${error}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const tokenData = await response.json();
|
|
328
|
+
|
|
329
|
+
// Add expiry time
|
|
330
|
+
if (tokenData.expires_in) {
|
|
331
|
+
tokenData.expires_at = new Date(Date.now() + tokenData.expires_in * 1000).toISOString();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Store refreshed token
|
|
335
|
+
await this.tokenStore.saveToken(tokenData);
|
|
336
|
+
|
|
337
|
+
console.log('β
Token refreshed successfully');
|
|
338
|
+
|
|
339
|
+
return tokenData.access_token;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Clear stored authentication
|
|
344
|
+
*/
|
|
345
|
+
async logout() {
|
|
346
|
+
await this.tokenStore.clearToken();
|
|
347
|
+
console.log('π Logged out successfully');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Generate PKCE code verifier
|
|
352
|
+
*/
|
|
353
|
+
generateCodeVerifier() {
|
|
354
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Generate PKCE code challenge from verifier
|
|
359
|
+
*/
|
|
360
|
+
generateCodeChallenge(verifier) {
|
|
361
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export default OAuthManager;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure Token Storage for Purmemo MCP
|
|
3
|
+
* Stores OAuth tokens securely in user's home directory
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
class TokenStore {
|
|
12
|
+
constructor() {
|
|
13
|
+
// Store tokens in user's home directory
|
|
14
|
+
this.configDir = path.join(os.homedir(), '.purmemo');
|
|
15
|
+
this.tokenFile = path.join(this.configDir, 'auth.json');
|
|
16
|
+
this.encryptionKey = this.getEncryptionKey();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get or generate encryption key for token storage
|
|
21
|
+
*/
|
|
22
|
+
getEncryptionKey() {
|
|
23
|
+
// Use machine ID + user info for key generation
|
|
24
|
+
const machineId = os.hostname() + os.userInfo().username;
|
|
25
|
+
return crypto.createHash('sha256').update(machineId).digest();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Ensure config directory exists
|
|
30
|
+
*/
|
|
31
|
+
async ensureConfigDir() {
|
|
32
|
+
try {
|
|
33
|
+
await fs.mkdir(this.configDir, { recursive: true });
|
|
34
|
+
// Set restrictive permissions (owner read/write only)
|
|
35
|
+
if (process.platform !== 'win32') {
|
|
36
|
+
await fs.chmod(this.configDir, 0o700);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('Failed to create config directory:', error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Encrypt data
|
|
45
|
+
*/
|
|
46
|
+
encrypt(data) {
|
|
47
|
+
const iv = crypto.randomBytes(16);
|
|
48
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv);
|
|
49
|
+
|
|
50
|
+
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
|
|
51
|
+
encrypted += cipher.final('hex');
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
iv: iv.toString('hex'),
|
|
55
|
+
data: encrypted
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decrypt data
|
|
61
|
+
*/
|
|
62
|
+
decrypt(encryptedData) {
|
|
63
|
+
const iv = Buffer.from(encryptedData.iv, 'hex');
|
|
64
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv);
|
|
65
|
+
|
|
66
|
+
let decrypted = decipher.update(encryptedData.data, 'hex', 'utf8');
|
|
67
|
+
decrypted += decipher.final('utf8');
|
|
68
|
+
|
|
69
|
+
return JSON.parse(decrypted);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Save token to disk
|
|
74
|
+
*/
|
|
75
|
+
async saveToken(tokenData) {
|
|
76
|
+
await this.ensureConfigDir();
|
|
77
|
+
|
|
78
|
+
// Encrypt token data
|
|
79
|
+
const encrypted = this.encrypt(tokenData);
|
|
80
|
+
|
|
81
|
+
// Write to file
|
|
82
|
+
await fs.writeFile(
|
|
83
|
+
this.tokenFile,
|
|
84
|
+
JSON.stringify(encrypted, null, 2),
|
|
85
|
+
'utf8'
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Set restrictive permissions
|
|
89
|
+
if (process.platform !== 'win32') {
|
|
90
|
+
await fs.chmod(this.tokenFile, 0o600);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get stored token
|
|
96
|
+
*/
|
|
97
|
+
async getToken() {
|
|
98
|
+
try {
|
|
99
|
+
const data = await fs.readFile(this.tokenFile, 'utf8');
|
|
100
|
+
const encrypted = JSON.parse(data);
|
|
101
|
+
return this.decrypt(encrypted);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// File doesn't exist or is corrupted
|
|
104
|
+
if (error.code === 'ENOENT') {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
console.error('Failed to read token:', error.message);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Clear stored token
|
|
114
|
+
*/
|
|
115
|
+
async clearToken() {
|
|
116
|
+
try {
|
|
117
|
+
await fs.unlink(this.tokenFile);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// Ignore if file doesn't exist
|
|
120
|
+
if (error.code !== 'ENOENT') {
|
|
121
|
+
console.error('Failed to clear token:', error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if token exists
|
|
128
|
+
*/
|
|
129
|
+
async hasToken() {
|
|
130
|
+
try {
|
|
131
|
+
await fs.access(this.tokenFile);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get user info from stored token
|
|
140
|
+
*/
|
|
141
|
+
async getUserInfo() {
|
|
142
|
+
const token = await this.getToken();
|
|
143
|
+
if (!token) return null;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
user_id: token.user?.id,
|
|
147
|
+
email: token.user?.email,
|
|
148
|
+
tier: token.user_tier || 'free',
|
|
149
|
+
memory_limit: token.memory_limit,
|
|
150
|
+
expires_at: token.expires_at
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default TokenStore;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pΕ«rmemo MCP Server v2.0.0 - With OAuth Support
|
|
4
|
+
* Seamless authentication without manual API key configuration
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import {
|
|
10
|
+
CallToolRequestSchema,
|
|
11
|
+
ListToolsRequestSchema,
|
|
12
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
import fetch from 'node-fetch';
|
|
14
|
+
import OAuthManager from './auth/oauth-manager.js';
|
|
15
|
+
|
|
16
|
+
// Initialize OAuth manager
|
|
17
|
+
const authManager = new OAuthManager({
|
|
18
|
+
apiUrl: process.env.PUO_MEMO_API_URL || 'https://api.purmemo.ai'
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// API URL configuration
|
|
22
|
+
const API_URL = process.env.PUO_MEMO_API_URL || 'https://api.purmemo.ai';
|
|
23
|
+
|
|
24
|
+
// User state
|
|
25
|
+
let userInfo = null;
|
|
26
|
+
let memoryCount = 0;
|
|
27
|
+
|
|
28
|
+
// Tool definitions
|
|
29
|
+
const TOOLS = [
|
|
30
|
+
{
|
|
31
|
+
name: 'memory',
|
|
32
|
+
description: 'πΎ Save anything to memory',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
content: { type: 'string', description: 'What to remember' },
|
|
37
|
+
title: { type: 'string', description: 'Optional: Title for the memory' },
|
|
38
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional: Tags' },
|
|
39
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Optional: File paths or URLs' }
|
|
40
|
+
},
|
|
41
|
+
required: ['content']
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'recall',
|
|
46
|
+
description: 'π Search your memories',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
query: { type: 'string', description: 'What to search for' },
|
|
51
|
+
limit: { type: 'integer', description: 'How many results (default: 10)', default: 10 },
|
|
52
|
+
search_type: { type: 'string', enum: ['keyword', 'semantic', 'hybrid'], default: 'hybrid' }
|
|
53
|
+
},
|
|
54
|
+
required: ['query']
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'entities',
|
|
59
|
+
description: 'π·οΈ Extract entities from memories (people, places, concepts)',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
entity_name: { type: 'string', description: 'Optional: Specific entity to look up' },
|
|
64
|
+
entity_type: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
enum: ['person', 'organization', 'location', 'concept', 'technology', 'project', 'document', 'event'],
|
|
67
|
+
description: 'Optional: Filter by entity type'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'attach',
|
|
74
|
+
description: 'π Attach files to an existing memory',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
memory_id: { type: 'string', description: 'Memory ID to attach files to' },
|
|
79
|
+
file_paths: { type: 'array', items: { type: 'string' }, description: 'File paths or URLs' }
|
|
80
|
+
},
|
|
81
|
+
required: ['memory_id', 'file_paths']
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'correction',
|
|
86
|
+
description: 'βοΈ Add a correction to an existing memory',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
memory_id: { type: 'string', description: 'ID of the memory to correct' },
|
|
91
|
+
correction: { type: 'string', description: 'The corrected content' },
|
|
92
|
+
reason: { type: 'string', description: 'Optional: Reason for the correction' }
|
|
93
|
+
},
|
|
94
|
+
required: ['memory_id', 'correction']
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// Create server
|
|
100
|
+
const server = new Server(
|
|
101
|
+
{
|
|
102
|
+
name: 'purmemo-mcp',
|
|
103
|
+
version: '2.0.0'
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
capabilities: {
|
|
107
|
+
tools: {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get authentication token, prompting for OAuth if needed
|
|
114
|
+
*/
|
|
115
|
+
async function getAuthToken() {
|
|
116
|
+
let token = await authManager.getToken();
|
|
117
|
+
|
|
118
|
+
if (!token) {
|
|
119
|
+
console.log('\nβ οΈ Authentication required for Purmemo MCP');
|
|
120
|
+
console.log('ββββββββββββββββββββββββββββββββββββββββββ\n');
|
|
121
|
+
console.log('This is a one-time setup. You\'ll be redirected to sign in.');
|
|
122
|
+
console.log('After authentication, all tools will work automatically.\n');
|
|
123
|
+
|
|
124
|
+
token = await authManager.authenticate();
|
|
125
|
+
|
|
126
|
+
// Get user info after authentication
|
|
127
|
+
userInfo = await authManager.tokenStore.getUserInfo();
|
|
128
|
+
if (userInfo) {
|
|
129
|
+
console.log(`π€ Authenticated as: ${userInfo.email}`);
|
|
130
|
+
console.log(`π Account tier: ${userInfo.tier}`);
|
|
131
|
+
if (userInfo.memory_limit) {
|
|
132
|
+
console.log(`π Memory limit: ${userInfo.memory_limit} (upgrade to Pro for unlimited)`);
|
|
133
|
+
}
|
|
134
|
+
console.log('');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return token;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if user has reached memory limit (for free tier)
|
|
143
|
+
*/
|
|
144
|
+
async function checkMemoryLimit(token) {
|
|
145
|
+
if (!userInfo) {
|
|
146
|
+
userInfo = await authManager.tokenStore.getUserInfo();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (userInfo && userInfo.tier === 'free' && userInfo.memory_limit) {
|
|
150
|
+
// Get current memory count
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(`${API_URL}/api/v5/memories?limit=1`, {
|
|
153
|
+
headers: {
|
|
154
|
+
'Authorization': `Bearer ${token}`,
|
|
155
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (response.ok) {
|
|
160
|
+
const data = await response.json();
|
|
161
|
+
memoryCount = data.total_count || 0;
|
|
162
|
+
|
|
163
|
+
if (memoryCount >= userInfo.memory_limit) {
|
|
164
|
+
return {
|
|
165
|
+
exceeded: true,
|
|
166
|
+
message: `You've reached your free tier limit of ${userInfo.memory_limit} memories. Upgrade to Pro at https://app.purmemo.ai for unlimited memories.`,
|
|
167
|
+
current: memoryCount,
|
|
168
|
+
limit: userInfo.memory_limit
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Failed to check memory limit:', error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { exceeded: false };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Handle tool listing
|
|
181
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
182
|
+
tools: TOOLS
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
// Handle tool execution
|
|
186
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
187
|
+
const { name, arguments: args } = request.params;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Get auth token (will prompt for OAuth if needed)
|
|
191
|
+
const token = await getAuthToken();
|
|
192
|
+
|
|
193
|
+
if (!token) {
|
|
194
|
+
throw new Error('Authentication required. Please run the setup command to authenticate.');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check memory limit for memory creation
|
|
198
|
+
if (name === 'memory') {
|
|
199
|
+
const limitCheck = await checkMemoryLimit(token);
|
|
200
|
+
if (limitCheck.exceeded) {
|
|
201
|
+
return {
|
|
202
|
+
error: 'Memory limit exceeded',
|
|
203
|
+
message: limitCheck.message,
|
|
204
|
+
current_count: limitCheck.current,
|
|
205
|
+
limit: limitCheck.limit,
|
|
206
|
+
upgrade_url: 'https://app.purmemo.ai/upgrade'
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let response;
|
|
212
|
+
|
|
213
|
+
switch (name) {
|
|
214
|
+
case 'memory':
|
|
215
|
+
// POST request for creating memory
|
|
216
|
+
response = await fetch(`${API_URL}/api/v5/memories`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: {
|
|
219
|
+
'Authorization': `Bearer ${token}`,
|
|
220
|
+
'Content-Type': 'application/json',
|
|
221
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify(args)
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Increment memory count for free tier tracking
|
|
227
|
+
if (response.ok && userInfo?.tier === 'free') {
|
|
228
|
+
memoryCount++;
|
|
229
|
+
if (userInfo.memory_limit) {
|
|
230
|
+
const remaining = userInfo.memory_limit - memoryCount;
|
|
231
|
+
if (remaining > 0 && remaining <= 10) {
|
|
232
|
+
console.log(`βΉοΈ ${remaining} memories remaining in free tier`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
|
|
238
|
+
case 'recall':
|
|
239
|
+
// GET request for search
|
|
240
|
+
const searchParams = new URLSearchParams({
|
|
241
|
+
q: args.query || '',
|
|
242
|
+
limit: args.limit || 10,
|
|
243
|
+
search_type: args.search_type || 'hybrid'
|
|
244
|
+
});
|
|
245
|
+
response = await fetch(`${API_URL}/api/v5/memories/search?${searchParams}`, {
|
|
246
|
+
headers: {
|
|
247
|
+
'Authorization': `Bearer ${token}`,
|
|
248
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case 'entities':
|
|
254
|
+
// GET request for entities
|
|
255
|
+
const entityParams = new URLSearchParams();
|
|
256
|
+
if (args.entity_name) entityParams.append('name', args.entity_name);
|
|
257
|
+
if (args.entity_type) entityParams.append('type', args.entity_type);
|
|
258
|
+
|
|
259
|
+
response = await fetch(`${API_URL}/api/v5/entities?${entityParams}`, {
|
|
260
|
+
headers: {
|
|
261
|
+
'Authorization': `Bearer ${token}`,
|
|
262
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case 'attach':
|
|
268
|
+
// POST request for attachments
|
|
269
|
+
response = await fetch(`${API_URL}/api/v5/memories/${args.memory_id}/attachments`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: {
|
|
272
|
+
'Authorization': `Bearer ${token}`,
|
|
273
|
+
'Content-Type': 'application/json',
|
|
274
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
275
|
+
},
|
|
276
|
+
body: JSON.stringify({ file_paths: args.file_paths })
|
|
277
|
+
});
|
|
278
|
+
break;
|
|
279
|
+
|
|
280
|
+
case 'correction':
|
|
281
|
+
// POST request for corrections
|
|
282
|
+
response = await fetch(`${API_URL}/api/v5/memories/${args.memory_id}/corrections`, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: {
|
|
285
|
+
'Authorization': `Bearer ${token}`,
|
|
286
|
+
'Content-Type': 'application/json',
|
|
287
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
288
|
+
},
|
|
289
|
+
body: JSON.stringify({
|
|
290
|
+
correction: args.correction,
|
|
291
|
+
reason: args.reason
|
|
292
|
+
})
|
|
293
|
+
});
|
|
294
|
+
break;
|
|
295
|
+
|
|
296
|
+
default:
|
|
297
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!response.ok) {
|
|
301
|
+
const error = await response.text();
|
|
302
|
+
|
|
303
|
+
// Handle specific error cases
|
|
304
|
+
if (response.status === 401) {
|
|
305
|
+
// Token might be expired, try to refresh
|
|
306
|
+
console.log('π Authentication expired, refreshing...');
|
|
307
|
+
await authManager.tokenStore.clearToken();
|
|
308
|
+
const newToken = await authManager.authenticate();
|
|
309
|
+
|
|
310
|
+
// Suggest retrying the operation
|
|
311
|
+
throw new Error('Authentication refreshed. Please try your request again.');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
throw new Error(`API error: ${error}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return await response.json();
|
|
318
|
+
|
|
319
|
+
} catch (error) {
|
|
320
|
+
console.error(`β Error executing ${name}:`, error.message);
|
|
321
|
+
|
|
322
|
+
// Provide helpful error messages
|
|
323
|
+
if (error.message.includes('ECONNREFUSED')) {
|
|
324
|
+
throw new Error('Unable to connect to Purmemo API. Please check your internet connection.');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (error.message.includes('Authentication')) {
|
|
328
|
+
throw new Error(`Authentication issue: ${error.message}. You may need to reconnect your account.`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Initialize on startup
|
|
336
|
+
async function initialize() {
|
|
337
|
+
console.log('π§ pΕ«rmemo MCP v2.0.0 starting...\n');
|
|
338
|
+
|
|
339
|
+
// Check if we have stored authentication
|
|
340
|
+
const hasAuth = await authManager.tokenStore.hasToken();
|
|
341
|
+
|
|
342
|
+
if (hasAuth) {
|
|
343
|
+
userInfo = await authManager.tokenStore.getUserInfo();
|
|
344
|
+
if (userInfo) {
|
|
345
|
+
console.log(`β
Authenticated as: ${userInfo.email}`);
|
|
346
|
+
console.log(`π Account tier: ${userInfo.tier}`);
|
|
347
|
+
if (userInfo.memory_limit) {
|
|
348
|
+
console.log(`π Memory limit: ${userInfo.memory_limit}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
// Check for legacy API key
|
|
353
|
+
if (process.env.PUO_MEMO_API_KEY) {
|
|
354
|
+
console.log('π Using API key from environment (legacy mode)');
|
|
355
|
+
console.log('π‘ Tip: Remove PUO_MEMO_API_KEY to use OAuth authentication');
|
|
356
|
+
} else {
|
|
357
|
+
console.log('π No authentication found');
|
|
358
|
+
console.log('π± You\'ll be prompted to sign in when you use your first tool');
|
|
359
|
+
console.log(' This is a one-time setup for seamless access');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
console.log('\nββββββββββββββββββββββββββββββββββββββββββ');
|
|
364
|
+
console.log('Ready to serve MCP requests\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Start server
|
|
368
|
+
initialize().then(() => {
|
|
369
|
+
const transport = new StdioServerTransport();
|
|
370
|
+
server.connect(transport);
|
|
371
|
+
}).catch(error => {
|
|
372
|
+
console.error('Failed to initialize:', error);
|
|
373
|
+
process.exit(1);
|
|
374
|
+
});
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pΕ«rmemo MCP Setup Command
|
|
4
|
+
* Allows users to manually authenticate or manage their connection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import inquirer from 'inquirer';
|
|
10
|
+
import OAuthManager from './auth/oauth-manager.js';
|
|
11
|
+
import TokenStore from './auth/token-store.js';
|
|
12
|
+
import ora from 'ora';
|
|
13
|
+
|
|
14
|
+
const program = new Command();
|
|
15
|
+
const authManager = new OAuthManager();
|
|
16
|
+
const tokenStore = new TokenStore();
|
|
17
|
+
|
|
18
|
+
// ASCII Art Banner
|
|
19
|
+
const banner = `
|
|
20
|
+
βββββββββββββββββββββββββββββββββββββββββββββ
|
|
21
|
+
β β
|
|
22
|
+
β π§ pΕ«rmemo MCP β
|
|
23
|
+
β Memory Management Tool β
|
|
24
|
+
β β
|
|
25
|
+
βββββββββββββββββββββββββββββββββββββββββββββ
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.name('purmemo-mcp')
|
|
30
|
+
.description('Setup and manage your pΕ«rmemo MCP connection')
|
|
31
|
+
.version('2.0.0');
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('setup')
|
|
35
|
+
.description('Connect your pΕ«rmemo account')
|
|
36
|
+
.action(async () => {
|
|
37
|
+
console.log(chalk.cyan(banner));
|
|
38
|
+
|
|
39
|
+
// Check if already authenticated
|
|
40
|
+
const hasAuth = await tokenStore.hasToken();
|
|
41
|
+
|
|
42
|
+
if (hasAuth) {
|
|
43
|
+
const userInfo = await tokenStore.getUserInfo();
|
|
44
|
+
console.log(chalk.green('β
You are already authenticated!'));
|
|
45
|
+
console.log(chalk.gray(` Email: ${userInfo?.email}`));
|
|
46
|
+
console.log(chalk.gray(` Tier: ${userInfo?.tier}`));
|
|
47
|
+
console.log('');
|
|
48
|
+
|
|
49
|
+
const { action } = await inquirer.prompt([
|
|
50
|
+
{
|
|
51
|
+
type: 'list',
|
|
52
|
+
name: 'action',
|
|
53
|
+
message: 'What would you like to do?',
|
|
54
|
+
choices: [
|
|
55
|
+
{ name: 'Keep current connection', value: 'keep' },
|
|
56
|
+
{ name: 'Sign in with a different account', value: 'reconnect' },
|
|
57
|
+
{ name: 'Sign out', value: 'logout' }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
if (action === 'keep') {
|
|
63
|
+
console.log(chalk.green('\n⨠Your connection is active and ready to use!'));
|
|
64
|
+
process.exit(0);
|
|
65
|
+
} else if (action === 'logout') {
|
|
66
|
+
await authManager.logout();
|
|
67
|
+
console.log(chalk.yellow('\nπ You have been signed out.'));
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
// Fall through to reconnect
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(chalk.yellow('\nπ± Starting authentication process...\n'));
|
|
74
|
+
console.log(chalk.gray('You will be redirected to your browser to sign in.'));
|
|
75
|
+
console.log(chalk.gray('Choose your preferred method: Google, GitHub, or Email.\n'));
|
|
76
|
+
|
|
77
|
+
const spinner = ora('Preparing authentication...').start();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 1000)); // Brief pause
|
|
81
|
+
spinner.stop();
|
|
82
|
+
|
|
83
|
+
const token = await authManager.authenticate();
|
|
84
|
+
|
|
85
|
+
if (token) {
|
|
86
|
+
const userInfo = await tokenStore.getUserInfo();
|
|
87
|
+
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(chalk.green.bold('π Success! You are now connected to pΕ«rmemo'));
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(chalk.white('Account Information:'));
|
|
92
|
+
console.log(chalk.gray('ββββββββββββββββββββββββββββββ'));
|
|
93
|
+
console.log(chalk.cyan(` Email: ${userInfo?.email}`));
|
|
94
|
+
console.log(chalk.cyan(` Tier: ${userInfo?.tier === 'pro' ? 'β Pro' : 'π Free'}`));
|
|
95
|
+
|
|
96
|
+
if (userInfo?.memory_limit) {
|
|
97
|
+
console.log(chalk.yellow(` Memory Limit: ${userInfo.memory_limit} memories`));
|
|
98
|
+
console.log(chalk.gray(` (Upgrade to Pro for unlimited memories)`));
|
|
99
|
+
} else {
|
|
100
|
+
console.log(chalk.green(` Memory Limit: Unlimited`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log(chalk.white('Available Tools:'));
|
|
105
|
+
console.log(chalk.gray('ββββββββββββββββββββββββββββββ'));
|
|
106
|
+
console.log(chalk.green(' β memory ') + chalk.gray('- Save anything to memory'));
|
|
107
|
+
console.log(chalk.green(' β recall ') + chalk.gray('- Search your memories'));
|
|
108
|
+
console.log(chalk.green(' β entities ') + chalk.gray('- Extract people, places, concepts'));
|
|
109
|
+
console.log(chalk.green(' β attach ') + chalk.gray('- Attach files to memories'));
|
|
110
|
+
console.log(chalk.green(' β correction') + chalk.gray('- Correct existing memories'));
|
|
111
|
+
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(chalk.cyan.bold('π You can now use all pΕ«rmemo tools in Claude Desktop!'));
|
|
114
|
+
console.log('');
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
spinner.fail('Authentication failed');
|
|
118
|
+
console.error(chalk.red(`\nβ Error: ${error.message}`));
|
|
119
|
+
console.log(chalk.gray('\nPlease try again or visit https://app.purmemo.ai for help.'));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
program
|
|
125
|
+
.command('status')
|
|
126
|
+
.description('Check your connection status')
|
|
127
|
+
.action(async () => {
|
|
128
|
+
console.log(chalk.cyan(banner));
|
|
129
|
+
|
|
130
|
+
const hasAuth = await tokenStore.hasToken();
|
|
131
|
+
|
|
132
|
+
if (!hasAuth) {
|
|
133
|
+
console.log(chalk.yellow('β οΈ Not authenticated'));
|
|
134
|
+
console.log(chalk.gray('\nRun "npx purmemo-mcp setup" to connect your account.'));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const userInfo = await tokenStore.getUserInfo();
|
|
139
|
+
const spinner = ora('Checking connection...').start();
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Test API connection
|
|
143
|
+
const token = await authManager.getToken();
|
|
144
|
+
const response = await fetch('https://api.purmemo.ai/api/v5/memories?limit=1', {
|
|
145
|
+
headers: {
|
|
146
|
+
'Authorization': `Bearer ${token}`,
|
|
147
|
+
'User-Agent': 'purmemo-mcp/2.0.0'
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
spinner.stop();
|
|
152
|
+
|
|
153
|
+
if (response.ok) {
|
|
154
|
+
const data = await response.json();
|
|
155
|
+
const memoryCount = data.total_count || 0;
|
|
156
|
+
|
|
157
|
+
console.log(chalk.green.bold('β
Connection Active'));
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(chalk.white('Account Details:'));
|
|
160
|
+
console.log(chalk.gray('ββββββββββββββββββββββββββββββ'));
|
|
161
|
+
console.log(` Email: ${userInfo?.email}`);
|
|
162
|
+
console.log(` Tier: ${userInfo?.tier === 'pro' ? 'β Pro' : 'π Free'}`);
|
|
163
|
+
console.log(` Memories: ${memoryCount}`);
|
|
164
|
+
|
|
165
|
+
if (userInfo?.memory_limit) {
|
|
166
|
+
const remaining = userInfo.memory_limit - memoryCount;
|
|
167
|
+
console.log(` Remaining: ${remaining} of ${userInfo.memory_limit}`);
|
|
168
|
+
|
|
169
|
+
if (remaining <= 10) {
|
|
170
|
+
console.log('');
|
|
171
|
+
console.log(chalk.yellow('β οΈ You are approaching your free tier limit.'));
|
|
172
|
+
console.log(chalk.gray(' Upgrade to Pro at https://app.purmemo.ai'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check token expiry
|
|
177
|
+
if (userInfo?.expires_at) {
|
|
178
|
+
const expiresAt = new Date(userInfo.expires_at);
|
|
179
|
+
const now = new Date();
|
|
180
|
+
const hoursUntilExpiry = Math.floor((expiresAt - now) / (1000 * 60 * 60));
|
|
181
|
+
|
|
182
|
+
if (hoursUntilExpiry < 24) {
|
|
183
|
+
console.log('');
|
|
184
|
+
console.log(chalk.yellow(`β οΈ Token expires in ${hoursUntilExpiry} hours`));
|
|
185
|
+
console.log(chalk.gray(' It will auto-refresh when needed.'));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
spinner.fail('Connection test failed');
|
|
190
|
+
console.log(chalk.red(`\nβ API returned status: ${response.status}`));
|
|
191
|
+
console.log(chalk.gray('\nTry running "npx purmemo-mcp setup" to reconnect.'));
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
spinner.fail('Connection test failed');
|
|
195
|
+
console.error(chalk.red(`\nβ Error: ${error.message}`));
|
|
196
|
+
console.log(chalk.gray('\nCheck your internet connection and try again.'));
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
program
|
|
201
|
+
.command('logout')
|
|
202
|
+
.description('Sign out from your pΕ«rmemo account')
|
|
203
|
+
.action(async () => {
|
|
204
|
+
console.log(chalk.cyan(banner));
|
|
205
|
+
|
|
206
|
+
const hasAuth = await tokenStore.hasToken();
|
|
207
|
+
|
|
208
|
+
if (!hasAuth) {
|
|
209
|
+
console.log(chalk.yellow('β οΈ You are not signed in.'));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const userInfo = await tokenStore.getUserInfo();
|
|
214
|
+
|
|
215
|
+
const { confirm } = await inquirer.prompt([
|
|
216
|
+
{
|
|
217
|
+
type: 'confirm',
|
|
218
|
+
name: 'confirm',
|
|
219
|
+
message: `Are you sure you want to sign out from ${userInfo?.email}?`,
|
|
220
|
+
default: false
|
|
221
|
+
}
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
if (confirm) {
|
|
225
|
+
await authManager.logout();
|
|
226
|
+
console.log(chalk.green('\nβ
Successfully signed out.'));
|
|
227
|
+
console.log(chalk.gray('Run "npx purmemo-mcp setup" to sign in again.'));
|
|
228
|
+
} else {
|
|
229
|
+
console.log(chalk.gray('\nSign out cancelled.'));
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
program
|
|
234
|
+
.command('upgrade')
|
|
235
|
+
.description('Open upgrade page to get Pro features')
|
|
236
|
+
.action(async () => {
|
|
237
|
+
console.log(chalk.cyan(banner));
|
|
238
|
+
console.log(chalk.yellow('π Opening upgrade page...'));
|
|
239
|
+
console.log(chalk.gray('\nPro features include:'));
|
|
240
|
+
console.log(chalk.green(' β’ Unlimited memories'));
|
|
241
|
+
console.log(chalk.green(' β’ Advanced AI models'));
|
|
242
|
+
console.log(chalk.green(' β’ Priority support'));
|
|
243
|
+
console.log(chalk.green(' β’ API access'));
|
|
244
|
+
|
|
245
|
+
const open = (await import('open')).default;
|
|
246
|
+
await open('https://app.purmemo.ai/upgrade');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Handle unknown commands
|
|
250
|
+
program.on('command:*', () => {
|
|
251
|
+
console.error(chalk.red('\nInvalid command: %s'), program.args.join(' '));
|
|
252
|
+
console.log(chalk.gray('Run "npx purmemo-mcp --help" for available commands.'));
|
|
253
|
+
process.exit(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Show help if no command provided
|
|
257
|
+
if (!process.argv.slice(2).length) {
|
|
258
|
+
console.log(chalk.cyan(banner));
|
|
259
|
+
console.log(chalk.white('Welcome to pΕ«rmemo MCP!\n'));
|
|
260
|
+
console.log(chalk.gray('Get started by connecting your account:\n'));
|
|
261
|
+
console.log(chalk.cyan(' npx purmemo-mcp setup\n'));
|
|
262
|
+
console.log(chalk.gray('For more commands, run: npx purmemo-mcp --help'));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
program.parse();
|