purmemo-mcp 14.2.0 β 14.3.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 +1 -1
- package/src/server.js +582 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "purmemo-mcp",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.3.0",
|
|
4
4
|
"mcpName": "io.github.purmemo-ai/purmemo",
|
|
5
5
|
"description": "MCP server for pΕ«rmemo - AI conversation memory that works everywhere. Save and recall conversations across Claude Desktop, Cursor, and other MCP-compatible platforms. Intelligent context extraction, smart titles, living documents.",
|
|
6
6
|
"main": "src/server.js",
|
package/src/server.js
CHANGED
|
@@ -38,9 +38,8 @@
|
|
|
38
38
|
|
|
39
39
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
40
40
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
41
|
-
|
|
41
|
+
// SSEServerTransport kept for legacy /sse endpoint (Claude Desktop)
|
|
42
42
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
43
|
-
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
44
43
|
import {
|
|
45
44
|
CallToolRequestSchema,
|
|
46
45
|
ListToolsRequestSchema,
|
|
@@ -746,6 +745,13 @@ const TOOLS = [
|
|
|
746
745
|
idempotentHint: false,
|
|
747
746
|
openWorldHint: true
|
|
748
747
|
},
|
|
748
|
+
_meta: {
|
|
749
|
+
'openai/outputTemplate': 'ui://widgets/save.html',
|
|
750
|
+
'openai/toolInvocation/invoking': 'Saving to your memory vault...',
|
|
751
|
+
'openai/toolInvocation/invoked': 'Saved to memory',
|
|
752
|
+
'openai/widgetAccessible': true,
|
|
753
|
+
'openai/widgetDomain': 'save.widgets.purmemo.ai'
|
|
754
|
+
},
|
|
749
755
|
description: `Save complete conversations as living documents. REQUIRED: Send COMPLETE conversation in 'conversationContent' parameter (minimum 100 chars, should be thousands). Include EVERY message verbatim - NO summaries or partial content.
|
|
750
756
|
|
|
751
757
|
Intelligently tracks context, extracts project details, and maintains a single memory per conversation topic.
|
|
@@ -850,6 +856,13 @@ const TOOLS = [
|
|
|
850
856
|
idempotentHint: true,
|
|
851
857
|
openWorldHint: true
|
|
852
858
|
},
|
|
859
|
+
_meta: {
|
|
860
|
+
'openai/outputTemplate': 'ui://widgets/recall-v39.html',
|
|
861
|
+
'openai/toolInvocation/invoking': 'Searching your memory vault...',
|
|
862
|
+
'openai/toolInvocation/invoked': 'Memories recalled',
|
|
863
|
+
'openai/widgetAccessible': true,
|
|
864
|
+
'openai/widgetDomain': 'recall.widgets.purmemo.ai'
|
|
865
|
+
},
|
|
853
866
|
description: `Search and retrieve saved memories with intelligent semantic ranking.
|
|
854
867
|
|
|
855
868
|
π― BASIC SEARCH:
|
|
@@ -943,6 +956,13 @@ const TOOLS = [
|
|
|
943
956
|
idempotentHint: true,
|
|
944
957
|
openWorldHint: true
|
|
945
958
|
},
|
|
959
|
+
_meta: {
|
|
960
|
+
'openai/outputTemplate': 'ui://widgets/memory-detail.html',
|
|
961
|
+
'openai/toolInvocation/invoking': 'Loading memory...',
|
|
962
|
+
'openai/toolInvocation/invoked': 'Memory loaded',
|
|
963
|
+
'openai/widgetAccessible': true,
|
|
964
|
+
'openai/widgetDomain': 'detail.widgets.purmemo.ai'
|
|
965
|
+
},
|
|
946
966
|
description: 'Get complete details of a specific memory, including all linked parts if chunked',
|
|
947
967
|
inputSchema: {
|
|
948
968
|
type: 'object',
|
|
@@ -969,6 +989,13 @@ const TOOLS = [
|
|
|
969
989
|
idempotentHint: true,
|
|
970
990
|
openWorldHint: true
|
|
971
991
|
},
|
|
992
|
+
_meta: {
|
|
993
|
+
'openai/outputTemplate': 'ui://widgets/discover.html',
|
|
994
|
+
'openai/toolInvocation/invoking': 'Finding related memories across platforms...',
|
|
995
|
+
'openai/toolInvocation/invoked': 'Connections found',
|
|
996
|
+
'openai/widgetAccessible': true,
|
|
997
|
+
'openai/widgetDomain': 'discover.widgets.purmemo.ai'
|
|
998
|
+
},
|
|
972
999
|
description: `CROSS-PLATFORM DISCOVERY: Find related conversations across ALL AI platforms.
|
|
973
1000
|
|
|
974
1001
|
Uses Purmemo's semantic clustering to automatically discover conversations about similar topics,
|
|
@@ -1023,6 +1050,13 @@ const TOOLS = [
|
|
|
1023
1050
|
idempotentHint: true,
|
|
1024
1051
|
openWorldHint: true
|
|
1025
1052
|
},
|
|
1053
|
+
_meta: {
|
|
1054
|
+
'openai/outputTemplate': 'ui://widgets/context.html',
|
|
1055
|
+
'openai/toolInvocation/invoking': 'Loading your context...',
|
|
1056
|
+
'openai/toolInvocation/invoked': 'Context ready',
|
|
1057
|
+
'openai/widgetAccessible': true,
|
|
1058
|
+
'openai/widgetDomain': 'context.widgets.purmemo.ai'
|
|
1059
|
+
},
|
|
1026
1060
|
description: `Get the current user's cognitive identity and active session context.
|
|
1027
1061
|
|
|
1028
1062
|
Call this at the START of a conversation to understand who you're talking to β
|
|
@@ -1312,7 +1346,7 @@ Returns the full catalog of workflows organized by category with descriptions.`,
|
|
|
1312
1346
|
];
|
|
1313
1347
|
|
|
1314
1348
|
const server = new Server(
|
|
1315
|
-
{ name: 'purmemo-mcp', version: '14.
|
|
1349
|
+
{ name: 'purmemo-mcp', version: '14.3.0' },
|
|
1316
1350
|
{
|
|
1317
1351
|
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
1318
1352
|
instructions: `Purmemo is a cross-platform AI conversation memory system. Use these tools to save, search, and discover conversations across ChatGPT, Claude, Gemini, and other platforms.
|
|
@@ -1375,31 +1409,36 @@ const RESOURCES = [
|
|
|
1375
1409
|
uri: 'ui://widgets/recall-v39.html',
|
|
1376
1410
|
name: 'Recall Widget',
|
|
1377
1411
|
description: 'Interactive memory recall card list for ChatGPT Apps SDK.',
|
|
1378
|
-
mimeType: 'text/html+skybridge'
|
|
1412
|
+
mimeType: 'text/html+skybridge',
|
|
1413
|
+
_meta: { 'openai/widgetCSP': { connect_domains: [], resource_domains: [] }, 'openai/widgetDomain': 'recall.widgets.purmemo.ai' }
|
|
1379
1414
|
},
|
|
1380
1415
|
{
|
|
1381
1416
|
uri: 'ui://widgets/save.html',
|
|
1382
1417
|
name: 'Save Widget',
|
|
1383
1418
|
description: 'Save confirmation card for ChatGPT Apps SDK.',
|
|
1384
|
-
mimeType: 'text/html+skybridge'
|
|
1419
|
+
mimeType: 'text/html+skybridge',
|
|
1420
|
+
_meta: { 'openai/widgetCSP': { connect_domains: [], resource_domains: [] }, 'openai/widgetDomain': 'save.widgets.purmemo.ai' }
|
|
1385
1421
|
},
|
|
1386
1422
|
{
|
|
1387
1423
|
uri: 'ui://widgets/memory-detail.html',
|
|
1388
1424
|
name: 'Memory Detail Widget',
|
|
1389
1425
|
description: 'Full memory content viewer for ChatGPT Apps SDK.',
|
|
1390
|
-
mimeType: 'text/html+skybridge'
|
|
1426
|
+
mimeType: 'text/html+skybridge',
|
|
1427
|
+
_meta: { 'openai/widgetCSP': { connect_domains: [], resource_domains: [] }, 'openai/widgetDomain': 'detail.widgets.purmemo.ai' }
|
|
1391
1428
|
},
|
|
1392
1429
|
{
|
|
1393
1430
|
uri: 'ui://widgets/context.html',
|
|
1394
1431
|
name: 'Context Widget',
|
|
1395
1432
|
description: 'User context and stats display for ChatGPT Apps SDK.',
|
|
1396
|
-
mimeType: 'text/html+skybridge'
|
|
1433
|
+
mimeType: 'text/html+skybridge',
|
|
1434
|
+
_meta: { 'openai/widgetCSP': { connect_domains: [], resource_domains: [] }, 'openai/widgetDomain': 'context.widgets.purmemo.ai' }
|
|
1397
1435
|
},
|
|
1398
1436
|
{
|
|
1399
1437
|
uri: 'ui://widgets/discover.html',
|
|
1400
1438
|
name: 'Discover Widget',
|
|
1401
1439
|
description: 'Cross-platform conversation discovery for ChatGPT Apps SDK.',
|
|
1402
|
-
mimeType: 'text/html+skybridge'
|
|
1440
|
+
mimeType: 'text/html+skybridge',
|
|
1441
|
+
_meta: { 'openai/widgetCSP': { connect_domains: [], resource_domains: [] }, 'openai/widgetDomain': 'discover.widgets.purmemo.ai' }
|
|
1403
1442
|
}
|
|
1404
1443
|
];
|
|
1405
1444
|
|
|
@@ -3473,7 +3512,7 @@ if (REMOTE_MODE) {
|
|
|
3473
3512
|
|
|
3474
3513
|
res.json({
|
|
3475
3514
|
status: 'healthy',
|
|
3476
|
-
version: '14.
|
|
3515
|
+
version: '14.3.0',
|
|
3477
3516
|
timestamp: new Date().toISOString(),
|
|
3478
3517
|
active_connections: Object.keys(transports).length,
|
|
3479
3518
|
metrics: {
|
|
@@ -3499,7 +3538,7 @@ if (REMOTE_MODE) {
|
|
|
3499
3538
|
consecutive_failures: apiCircuitBreaker.failureCount
|
|
3500
3539
|
},
|
|
3501
3540
|
service_info: {
|
|
3502
|
-
version: '14.
|
|
3541
|
+
version: '14.3.0',
|
|
3503
3542
|
runtime: 'node',
|
|
3504
3543
|
api_backend: API_URL,
|
|
3505
3544
|
environment: process.env.NODE_ENV || 'production',
|
|
@@ -3518,63 +3557,515 @@ if (REMOTE_MODE) {
|
|
|
3518
3557
|
});
|
|
3519
3558
|
});
|
|
3520
3559
|
|
|
3521
|
-
// ββ Streamable HTTP
|
|
3522
|
-
|
|
3523
|
-
try {
|
|
3524
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
3525
|
-
let transport;
|
|
3560
|
+
// ββ Custom Streamable HTTP handler (mirrors Python main.py POST /mcp/messages) ββ
|
|
3561
|
+
// NOT using MCP SDK transport β custom handler for ChatGPT widget compatibility
|
|
3526
3562
|
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3563
|
+
// Streamable HTTP sessions
|
|
3564
|
+
const mcpSessions = new Map();
|
|
3565
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2024-11-05', '2025-11-05', '2025-03-26']);
|
|
3566
|
+
|
|
3567
|
+
// Session cleanup β remove stale sessions every 5 minutes (matches Python)
|
|
3568
|
+
const sessionCleanupInterval = setInterval(() => {
|
|
3569
|
+
const maxAge = 30 * 60 * 1000; // 30 minutes
|
|
3570
|
+
const now = Date.now();
|
|
3571
|
+
let cleaned = 0;
|
|
3572
|
+
for (const [sid, sess] of mcpSessions) {
|
|
3573
|
+
if (now - sess.lastActivity > maxAge) {
|
|
3574
|
+
mcpSessions.delete(sid);
|
|
3575
|
+
cleaned++;
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
if (cleaned > 0) structuredLog.info('Cleaned up stale sessions', { count: cleaned });
|
|
3579
|
+
}, 5 * 60 * 1000);
|
|
3580
|
+
|
|
3581
|
+
// Helper: validate API key from Authorization header
|
|
3582
|
+
async function validateApiKeyFromRequest(req) {
|
|
3583
|
+
const auth = req.headers.authorization;
|
|
3584
|
+
if (!auth || !auth.startsWith('Bearer ')) return null;
|
|
3585
|
+
const token = auth.split(' ')[1];
|
|
3586
|
+
try {
|
|
3587
|
+
const resp = await fetch(`${API_URL}/api/v1/auth/me`, {
|
|
3588
|
+
headers: { 'Authorization': `Bearer ${token}`, 'User-Agent': 'purmemo-mcp/14.3.0' },
|
|
3589
|
+
signal: AbortSignal.timeout(10000)
|
|
3590
|
+
});
|
|
3591
|
+
if (resp.ok) return token;
|
|
3592
|
+
// Silent token refresh if 401 and we have a refresh token
|
|
3593
|
+
if (resp.status === 401 && refreshTokenStore[token]) {
|
|
3594
|
+
try {
|
|
3595
|
+
const refreshResp = await fetch(`${API_URL}/api/v1/auth/refresh`, {
|
|
3596
|
+
method: 'POST',
|
|
3597
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3598
|
+
body: JSON.stringify({ refresh_token: refreshTokenStore[token] }),
|
|
3599
|
+
signal: AbortSignal.timeout(10000)
|
|
3536
3600
|
});
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
structuredLog.info('StreamableHTTP session initialized', { session_id: sid });
|
|
3601
|
+
if (refreshResp.ok) {
|
|
3602
|
+
const data = await refreshResp.json();
|
|
3603
|
+
const newToken = data.access_token || data.api_key;
|
|
3604
|
+
if (newToken) {
|
|
3605
|
+
if (data.refresh_token) refreshTokenStore[newToken] = data.refresh_token;
|
|
3606
|
+
delete refreshTokenStore[token];
|
|
3607
|
+
return newToken;
|
|
3608
|
+
}
|
|
3546
3609
|
}
|
|
3547
|
-
}
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3610
|
+
} catch {}
|
|
3611
|
+
}
|
|
3612
|
+
return null;
|
|
3613
|
+
} catch { return null; }
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
// Helper: SSE response
|
|
3617
|
+
function sendSSE(res, data) {
|
|
3618
|
+
res.writeHead(200, {
|
|
3619
|
+
'Content-Type': 'text/event-stream',
|
|
3620
|
+
'Cache-Control': 'no-cache',
|
|
3621
|
+
'Connection': 'keep-alive',
|
|
3622
|
+
...CORS_HEADERS
|
|
3623
|
+
});
|
|
3624
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
3625
|
+
res.end();
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
const CORS_HEADERS = {
|
|
3629
|
+
'Access-Control-Allow-Origin': '*',
|
|
3630
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
3631
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, Accept',
|
|
3632
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
3633
|
+
'Access-Control-Expose-Headers': 'Mcp-Session-Id'
|
|
3634
|
+
};
|
|
3635
|
+
|
|
3636
|
+
// Helper: JSON response with CORS
|
|
3637
|
+
function sendJSON(res, data, statusCode = 200, extraHeaders = {}) {
|
|
3638
|
+
res.writeHead(statusCode, {
|
|
3639
|
+
'Content-Type': 'application/json',
|
|
3640
|
+
...CORS_HEADERS,
|
|
3641
|
+
...extraHeaders
|
|
3642
|
+
});
|
|
3643
|
+
res.end(JSON.stringify(data));
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
// Helper: execute a tool call (proxies to backend or handles locally)
|
|
3647
|
+
async function executeToolForRemote(toolName, toolArgs, apiKey) {
|
|
3648
|
+
// Track tool usage
|
|
3649
|
+
toolCallCounts[toolName] = (toolCallCounts[toolName] || 0) + 1;
|
|
3650
|
+
|
|
3651
|
+
// get_user_context handled locally (same as Python)
|
|
3652
|
+
if (toolName === 'get_user_context') {
|
|
3653
|
+
try {
|
|
3654
|
+
const result = await handleGetUserContext({});
|
|
3655
|
+
return result;
|
|
3656
|
+
} catch (e) {
|
|
3657
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
// All other tools proxy to backend
|
|
3662
|
+
try {
|
|
3663
|
+
const resp = await fetch(`${API_URL}/api/v10/mcp/tools/execute`, {
|
|
3664
|
+
method: 'POST',
|
|
3665
|
+
headers: {
|
|
3666
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
3667
|
+
'Content-Type': 'application/json',
|
|
3668
|
+
'User-Agent': 'purmemo-mcp/14.3.0',
|
|
3669
|
+
'X-MCP-Version': '14.3.0'
|
|
3670
|
+
},
|
|
3671
|
+
body: JSON.stringify({ tool: toolName, arguments: toolArgs }),
|
|
3672
|
+
signal: AbortSignal.timeout(30000)
|
|
3673
|
+
});
|
|
3674
|
+
|
|
3675
|
+
if (resp.status === 401) {
|
|
3676
|
+
// Silent token refresh β try refreshing before telling user to reconnect
|
|
3677
|
+
if (refreshTokenStore[apiKey]) {
|
|
3678
|
+
try {
|
|
3679
|
+
const refreshResp = await fetch(`${API_URL}/api/v1/auth/refresh`, {
|
|
3680
|
+
method: 'POST',
|
|
3681
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3682
|
+
body: JSON.stringify({ refresh_token: refreshTokenStore[apiKey] }),
|
|
3683
|
+
signal: AbortSignal.timeout(10000)
|
|
3684
|
+
});
|
|
3685
|
+
if (refreshResp.ok) {
|
|
3686
|
+
const refreshData = await refreshResp.json();
|
|
3687
|
+
const newToken = refreshData.access_token || refreshData.api_key;
|
|
3688
|
+
if (newToken) {
|
|
3689
|
+
if (refreshData.refresh_token) refreshTokenStore[newToken] = refreshData.refresh_token;
|
|
3690
|
+
delete refreshTokenStore[apiKey];
|
|
3691
|
+
// Retry the tool call with new token
|
|
3692
|
+
const retryResp = await fetch(`${API_URL}/api/v10/mcp/tools/execute`, {
|
|
3693
|
+
method: 'POST',
|
|
3694
|
+
headers: {
|
|
3695
|
+
'Authorization': `Bearer ${newToken}`,
|
|
3696
|
+
'Content-Type': 'application/json',
|
|
3697
|
+
'User-Agent': 'purmemo-mcp/14.3.0'
|
|
3698
|
+
},
|
|
3699
|
+
body: JSON.stringify({ tool: toolName, arguments: toolArgs }),
|
|
3700
|
+
signal: AbortSignal.timeout(30000)
|
|
3701
|
+
});
|
|
3702
|
+
if (retryResp.ok) {
|
|
3703
|
+
structuredLog.info('Silent token refresh succeeded', { tool: toolName });
|
|
3704
|
+
return await retryResp.json();
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
} catch (e) {
|
|
3709
|
+
structuredLog.warn('Silent token refresh failed', { error: e.message });
|
|
3554
3710
|
}
|
|
3711
|
+
}
|
|
3712
|
+
return {
|
|
3713
|
+
isError: true,
|
|
3714
|
+
content: [{ type: 'text', text: 'Session expired. Please reconnect via Settings β Connectors β purmemo β Uninstall then re-add.' }]
|
|
3555
3715
|
};
|
|
3556
|
-
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
if (resp.status === 429) {
|
|
3719
|
+
try {
|
|
3720
|
+
const errorData = await resp.json();
|
|
3721
|
+
const detail = typeof errorData.detail === 'object' ? errorData.detail : errorData;
|
|
3722
|
+
const upgradeUrl = detail.upgrade_url || 'https://app.purmemo.ai/dashboard?modal=plans';
|
|
3723
|
+
const message = detail.message || 'Monthly quota exceeded';
|
|
3724
|
+
const usage = detail.current_usage || '?';
|
|
3725
|
+
const limit = detail.limit || detail.quota_limit || '?';
|
|
3726
|
+
const now = new Date();
|
|
3727
|
+
const resetDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
3728
|
+
const resetStr = resetDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
3729
|
+
return {
|
|
3730
|
+
isError: true,
|
|
3731
|
+
content: [{ type: 'text', text: `${message}\n\nUsage: ${usage}/${limit} this month\n\nUpgrade to Pro: ${upgradeUrl}\n\nResets on ${resetStr}` }]
|
|
3732
|
+
};
|
|
3733
|
+
} catch {
|
|
3734
|
+
return { isError: true, content: [{ type: 'text', text: 'Monthly quota exceeded. Upgrade at https://app.purmemo.ai/dashboard?modal=plans' }] };
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
if (!resp.ok) {
|
|
3739
|
+
const errText = await resp.text();
|
|
3740
|
+
recentErrors.push({ timestamp: new Date().toISOString(), tool: toolName, status: resp.status, error: errText.substring(0, 200) });
|
|
3741
|
+
if (recentErrors.length > 100) recentErrors.shift();
|
|
3742
|
+
return { error: `API error ${resp.status}: ${errText.substring(0, 200)}` };
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
const data = await resp.json();
|
|
3746
|
+
return data;
|
|
3747
|
+
} catch (e) {
|
|
3748
|
+
recentErrors.push({ timestamp: new Date().toISOString(), tool: toolName, error: e.message });
|
|
3749
|
+
if (recentErrors.length > 100) recentErrors.shift();
|
|
3750
|
+
return { error: e.name === 'AbortError' ? 'Request timeout' : e.message };
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
// ββ CORS preflight ββ
|
|
3755
|
+
app.options('/mcp/messages', (req, res) => {
|
|
3756
|
+
res.writeHead(204, CORS_HEADERS);
|
|
3757
|
+
res.end();
|
|
3758
|
+
});
|
|
3759
|
+
|
|
3760
|
+
// ββ POST /mcp/messages β main Streamable HTTP dispatch ββ
|
|
3761
|
+
app.post('/mcp/messages', async (req, res) => {
|
|
3762
|
+
try {
|
|
3763
|
+
const body = req.body;
|
|
3764
|
+
const method = body?.method;
|
|
3765
|
+
const requestId = body?.id;
|
|
3766
|
+
|
|
3767
|
+
// ββ initialize ββ
|
|
3768
|
+
if (method === 'initialize') {
|
|
3769
|
+
const apiKey = await validateApiKeyFromRequest(req);
|
|
3770
|
+
if (!apiKey) {
|
|
3771
|
+
return sendJSON(res, {
|
|
3772
|
+
jsonrpc: '2.0', id: requestId,
|
|
3773
|
+
error: { code: -32001, message: 'Authentication required', data: { type: 'authorization_required' } }
|
|
3774
|
+
}, 401, {
|
|
3775
|
+
'WWW-Authenticate': `Bearer resource_metadata="https://${req.get('host')}/.well-known/oauth-protected-resource"`
|
|
3776
|
+
});
|
|
3777
|
+
}
|
|
3778
|
+
const sessionId = randomUUID();
|
|
3779
|
+
mcpSessions.set(sessionId, { token: apiKey, createdAt: Date.now(), lastActivity: Date.now() });
|
|
3780
|
+
connectionCount++;
|
|
3781
|
+
connMonitor.trackConnection(sessionId, { type: 'streamable-http' });
|
|
3782
|
+
|
|
3783
|
+
const clientVersion = body?.params?.protocolVersion || '2025-03-26';
|
|
3784
|
+
const negotiatedVersion = SUPPORTED_PROTOCOL_VERSIONS.has(clientVersion) ? clientVersion : '2024-11-05';
|
|
3785
|
+
|
|
3786
|
+
return sendJSON(res, {
|
|
3787
|
+
jsonrpc: '2.0', id: requestId,
|
|
3788
|
+
result: {
|
|
3789
|
+
protocolVersion: negotiatedVersion,
|
|
3790
|
+
capabilities: { tools: { listChanged: true }, resources: { subscribe: false, listChanged: false }, prompts: { listChanged: false }, logging: {} },
|
|
3791
|
+
serverInfo: { name: 'purmemo-mcp', version: '14.3.0' },
|
|
3792
|
+
instructions: 'pΕ«rmemo tools are ready. Save memories, recall information, and run memory-powered workflows.'
|
|
3793
|
+
}
|
|
3794
|
+
}, 200, { 'Mcp-Session-Id': sessionId });
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
// ββ notifications/initialized ββ
|
|
3798
|
+
if (method === 'notifications/initialized') {
|
|
3799
|
+
return res.status(200).end();
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// ββ ping ββ
|
|
3803
|
+
if (method === 'ping') {
|
|
3804
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, result: {} });
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
// ββ tools/list (PUBLIC β no auth required) ββ
|
|
3808
|
+
if (method === 'tools/list') {
|
|
3809
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, result: { tools: TOOLS } });
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
// ββ Auth required for remaining methods ββ
|
|
3813
|
+
const sessionId = req.headers['mcp-session-id'] || req.headers['Mcp-Session-Id'];
|
|
3814
|
+
let apiKey = null;
|
|
3815
|
+
if (sessionId && mcpSessions.has(sessionId)) {
|
|
3816
|
+
apiKey = mcpSessions.get(sessionId).token;
|
|
3817
|
+
mcpSessions.get(sessionId).lastActivity = Date.now();
|
|
3557
3818
|
} else {
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3819
|
+
apiKey = await validateApiKeyFromRequest(req);
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
if (!apiKey) {
|
|
3823
|
+
return sendJSON(res, {
|
|
3824
|
+
jsonrpc: '2.0', id: requestId,
|
|
3825
|
+
error: { code: -32001, message: 'Authentication required' }
|
|
3826
|
+
}, 401, CORS_HEADERS);
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
// ββ resources/list ββ
|
|
3830
|
+
if (method === 'resources/list') {
|
|
3831
|
+
return sendJSON(res, {
|
|
3832
|
+
jsonrpc: '2.0', id: requestId,
|
|
3833
|
+
result: { resources: RESOURCES, resourceTemplates: RESOURCE_TEMPLATES }
|
|
3562
3834
|
});
|
|
3563
3835
|
}
|
|
3564
3836
|
|
|
3565
|
-
|
|
3837
|
+
// ββ resources/read ββ
|
|
3838
|
+
if (method === 'resources/read') {
|
|
3839
|
+
const uri = body?.params?.uri || '';
|
|
3840
|
+
|
|
3841
|
+
// Widget resources β return HTML directly as JSON (NOT SSE)
|
|
3842
|
+
const widgetFiles = {
|
|
3843
|
+
'ui://widgets/recall-v39.html': 'recall.html',
|
|
3844
|
+
'ui://widgets/save.html': 'save.html',
|
|
3845
|
+
'ui://widgets/memory-detail.html': 'memory-detail.html',
|
|
3846
|
+
'ui://widgets/context.html': 'context.html',
|
|
3847
|
+
'ui://widgets/discover.html': 'discover.html'
|
|
3848
|
+
};
|
|
3849
|
+
if (widgetFiles[uri]) {
|
|
3850
|
+
const { readFileSync: rfs } = await import('node:fs');
|
|
3851
|
+
const { dirname: dn, join: jn } = await import('node:path');
|
|
3852
|
+
const { fileURLToPath: fu } = await import('node:url');
|
|
3853
|
+
const html = rfs(jn(dn(fu(import.meta.url)), 'remote', 'widgets', widgetFiles[uri]), 'utf8');
|
|
3854
|
+
return sendJSON(res, {
|
|
3855
|
+
jsonrpc: '2.0', id: requestId,
|
|
3856
|
+
result: { contents: [{ uri, mimeType: 'text/html+skybridge', text: html }] }
|
|
3857
|
+
});
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
// Memory resources β proxy to backend
|
|
3861
|
+
try {
|
|
3862
|
+
const authHeaders = { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': 'purmemo-mcp/14.3.0' };
|
|
3863
|
+
let text = '', mimeType = 'text/plain';
|
|
3864
|
+
|
|
3865
|
+
if (uri === 'memory://me') {
|
|
3866
|
+
const [meResp, statsResp, memsResp, sessResp] = await Promise.allSettled([
|
|
3867
|
+
fetch(`${API_URL}/api/v1/auth/me`, { headers: authHeaders, signal: AbortSignal.timeout(10000) }),
|
|
3868
|
+
fetch(`${API_URL}/api/v1/stats/`, { headers: authHeaders, signal: AbortSignal.timeout(10000) }),
|
|
3869
|
+
fetch(`${API_URL}/api/v1/memories/?limit=20&sort=created_at&order=desc`, { headers: authHeaders, signal: AbortSignal.timeout(10000) }),
|
|
3870
|
+
fetch(`${API_URL}/api/v1/identity/session`, { headers: authHeaders, signal: AbortSignal.timeout(10000) })
|
|
3871
|
+
]);
|
|
3872
|
+
const me = meResp.status === 'fulfilled' && meResp.value.ok ? await meResp.value.json() : {};
|
|
3873
|
+
const stats = statsResp.status === 'fulfilled' && statsResp.value.ok ? await statsResp.value.json() : {};
|
|
3874
|
+
const mems = memsResp.status === 'fulfilled' && memsResp.value.ok ? await memsResp.value.json() : [];
|
|
3875
|
+
const sess = sessResp.status === 'fulfilled' && sessResp.value.ok ? await sessResp.value.json() : {};
|
|
3876
|
+
const identity = me.identity || {};
|
|
3877
|
+
const session = sess.session || {};
|
|
3878
|
+
const name = me.full_name || (me.email || '').split('@')[0] || 'User';
|
|
3879
|
+
const lines = [`## About Me β ${name}\n`];
|
|
3880
|
+
if (identity.role) lines.push(`**Role:** ${identity.role}`);
|
|
3881
|
+
if (identity.primary_domain) lines.push(`**Domain:** ${identity.primary_domain}`);
|
|
3882
|
+
if (identity.expertise?.length) lines.push(`**Expertise:** ${identity.expertise.join(', ')}`);
|
|
3883
|
+
if (identity.tools?.length) lines.push(`**Tools I use:** ${identity.tools.join(', ')}`);
|
|
3884
|
+
if (identity.work_style) lines.push(`**Work style:** ${identity.work_style}`);
|
|
3885
|
+
if (session.context) lines.push(`**Working on:** ${session.context}`);
|
|
3886
|
+
const total = stats.total_memories || 0;
|
|
3887
|
+
const thisWeek = stats.memories_this_week || 0;
|
|
3888
|
+
const BLOCKLIST = new Set(['user', 'purmemo-web']);
|
|
3889
|
+
const platforms = (stats.platforms || []).filter(p => p && !BLOCKLIST.has(p.toLowerCase()) && !p.includes(' '));
|
|
3890
|
+
if (total) lines.push(`\n**Memory vault:** ${total.toLocaleString()} memories across ${platforms.slice(0, 6).join(', ')}`);
|
|
3891
|
+
if (thisWeek) lines.push(`**This week:** ${thisWeek} memories saved`);
|
|
3892
|
+
const memList = Array.isArray(mems) ? mems : (mems.memories || []);
|
|
3893
|
+
const projCounts = {};
|
|
3894
|
+
for (const m of memList) {
|
|
3895
|
+
const proj = (m.project_name || '').trim();
|
|
3896
|
+
if (proj) projCounts[proj] = (projCounts[proj] || 0) + 1;
|
|
3897
|
+
}
|
|
3898
|
+
const ranked = Object.entries(projCounts).filter(([, c]) => c >= 2).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
3899
|
+
if (ranked.length) lines.push(`\n**Recent work:** ${ranked.map(([p, c]) => `${p} (${c} recent)`).join('; ')}`);
|
|
3900
|
+
text = lines.join('\n');
|
|
3901
|
+
} else if (uri === 'memory://context' || uri === 'memory://projects' || uri === 'memory://stats') {
|
|
3902
|
+
// Delegate to existing handlers via makeApiCall
|
|
3903
|
+
try {
|
|
3904
|
+
if (uri === 'memory://context') {
|
|
3905
|
+
const data = await fetch(`${API_URL}/api/v1/memories/?limit=5&sort=created_at&order=desc`, { headers: authHeaders, signal: AbortSignal.timeout(10000) });
|
|
3906
|
+
const mems = data.ok ? await data.json() : [];
|
|
3907
|
+
const memList = Array.isArray(mems) ? mems : (mems.memories || []);
|
|
3908
|
+
text = memList.map((m, i) => `${i + 1}. **${m.title || 'Untitled'}** (${new Date(m.created_at).toLocaleDateString()})\n ${(m.content || '').substring(0, 150)}...`).join('\n\n');
|
|
3909
|
+
} else if (uri === 'memory://projects') {
|
|
3910
|
+
const data = await fetch(`${API_URL}/api/v1/memories/?limit=20&sort=created_at&order=desc`, { headers: authHeaders, signal: AbortSignal.timeout(10000) });
|
|
3911
|
+
const mems = data.ok ? await data.json() : [];
|
|
3912
|
+
const memList = Array.isArray(mems) ? mems : (mems.memories || []);
|
|
3913
|
+
const byProj = {};
|
|
3914
|
+
for (const m of memList) { const p = m.project_name || 'Other'; (byProj[p] = byProj[p] || []).push(m.title || 'Untitled'); }
|
|
3915
|
+
text = Object.entries(byProj).map(([p, titles]) => `## ${p}\n${titles.slice(0, 3).map(t => `- ${t}`).join('\n')}`).join('\n\n');
|
|
3916
|
+
} else {
|
|
3917
|
+
const data = await fetch(`${API_URL}/api/v1/stats/`, { headers: authHeaders, signal: AbortSignal.timeout(10000) });
|
|
3918
|
+
const stats = data.ok ? await data.json() : {};
|
|
3919
|
+
text = `## Memory Vault Stats\n\n**Total:** ${stats.total_memories || 0}\n**This week:** ${stats.memories_this_week || 0}\n**Platforms:** ${(stats.platforms || []).join(', ')}`;
|
|
3920
|
+
}
|
|
3921
|
+
} catch (e) { text = `Error loading ${uri}: ${e.message}`; }
|
|
3922
|
+
} else if (uri.startsWith('memory://')) {
|
|
3923
|
+
const memId = uri.replace('memory://', '');
|
|
3924
|
+
try {
|
|
3925
|
+
const data = await fetch(`${API_URL}/api/v1/memories/${memId}/`, { headers: authHeaders, signal: AbortSignal.timeout(10000) });
|
|
3926
|
+
text = data.ok ? JSON.stringify(await data.json(), null, 2) : `Memory not found: ${memId}`;
|
|
3927
|
+
mimeType = 'application/json';
|
|
3928
|
+
} catch (e) { text = `Error: ${e.message}`; }
|
|
3929
|
+
} else {
|
|
3930
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, error: { code: -32602, message: `Unknown resource: ${uri}` } });
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
return sendJSON(res, {
|
|
3934
|
+
jsonrpc: '2.0', id: requestId,
|
|
3935
|
+
result: { contents: [{ uri, mimeType, text }] }
|
|
3936
|
+
});
|
|
3937
|
+
} catch (e) {
|
|
3938
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, error: { code: -32603, message: e.message } });
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
// ββ prompts/list ββ
|
|
3943
|
+
if (method === 'prompts/list') {
|
|
3944
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, result: { prompts: PROMPTS } });
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
// ββ prompts/get ββ
|
|
3948
|
+
if (method === 'prompts/get') {
|
|
3949
|
+
const promptName = body?.params?.name || '';
|
|
3950
|
+
const promptArgs = body?.params?.arguments || {};
|
|
3951
|
+
// Delegate to existing prompt handler logic
|
|
3952
|
+
let messages;
|
|
3953
|
+
if (promptName === 'load-context') {
|
|
3954
|
+
const topic = promptArgs.topic || '';
|
|
3955
|
+
messages = [{ role: 'user', content: { type: 'text', text: topic
|
|
3956
|
+
? `Before I start working on "${topic}", please recall relevant past conversations using recall_memories.`
|
|
3957
|
+
: `Please load my recent context using recall_memories. Search for my most recent work and summarize.` } }];
|
|
3958
|
+
} else if (promptName === 'save-this-conversation') {
|
|
3959
|
+
messages = [{ role: 'user', content: { type: 'text', text: `Please save our current conversation using the save_conversation tool. Include the COMPLETE conversation content.` } }];
|
|
3960
|
+
} else if (promptName === 'catch-me-up') {
|
|
3961
|
+
const project = promptArgs.project || 'this project';
|
|
3962
|
+
messages = [{ role: 'user', content: { type: 'text', text: `Please catch me up on "${project}" using recall_memories. Summarize what's been done, what's in progress, and what's next.` } }];
|
|
3963
|
+
} else if (promptName === 'weekly-review') {
|
|
3964
|
+
messages = [{ role: 'user', content: { type: 'text', text: `Please give me a weekly review using recall_memories. Search conversations from the past 7 days and organize by projects, decisions, and next steps.` } }];
|
|
3965
|
+
} else {
|
|
3966
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, error: { code: -32602, message: `Unknown prompt: ${promptName}` } });
|
|
3967
|
+
}
|
|
3968
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, result: { description: `Prompt: ${promptName}`, messages } });
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
// ββ tools/call β SSE streaming response ββ
|
|
3972
|
+
if (method === 'tools/call') {
|
|
3973
|
+
const toolName = body?.params?.name;
|
|
3974
|
+
const toolArgs = body?.params?.arguments || {};
|
|
3975
|
+
if (!toolName) {
|
|
3976
|
+
return sendSSE(res, { jsonrpc: '2.0', id: requestId, error: { code: -32602, message: 'Missing tool name' } });
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
structuredLog.info('Tool call via /mcp/messages', { tool: toolName });
|
|
3980
|
+
|
|
3981
|
+
const result = await executeToolForRemote(toolName, toolArgs, apiKey);
|
|
3982
|
+
|
|
3983
|
+
if (result?.error) {
|
|
3984
|
+
return sendSSE(res, { jsonrpc: '2.0', id: requestId, error: { code: -32603, message: result.error } });
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
// Pre-formatted errors (quota, auth) already have content
|
|
3988
|
+
if (result?.isError && result?.content) {
|
|
3989
|
+
return sendSSE(res, { jsonrpc: '2.0', id: requestId, result });
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
// Normal result β wrap in content if needed
|
|
3993
|
+
const content = result?.content || [{ type: 'text', text: JSON.stringify(result?.data || result, null, 2) }];
|
|
3994
|
+
return sendSSE(res, { jsonrpc: '2.0', id: requestId, result: { content } });
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
// ββ Unknown method ββ
|
|
3998
|
+
return sendJSON(res, { jsonrpc: '2.0', id: requestId, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
3999
|
+
|
|
3566
4000
|
} catch (error) {
|
|
3567
|
-
structuredLog.error('Error
|
|
4001
|
+
structuredLog.error('Error in /mcp/messages', { error: error.message });
|
|
3568
4002
|
if (!res.headersSent) {
|
|
3569
|
-
res.
|
|
3570
|
-
jsonrpc: '2.0',
|
|
3571
|
-
error: { code: -32603, message: 'Internal server error' },
|
|
3572
|
-
id: null
|
|
3573
|
-
});
|
|
4003
|
+
sendJSON(res, { jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal server error' } }, 500);
|
|
3574
4004
|
}
|
|
3575
4005
|
}
|
|
3576
4006
|
});
|
|
3577
4007
|
|
|
4008
|
+
// ββ GET /mcp/messages β SSE keepalive stream ββ
|
|
4009
|
+
app.get('/mcp/messages', async (req, res) => {
|
|
4010
|
+
if (!req.headers.accept?.includes('text/event-stream')) {
|
|
4011
|
+
return res.status(406).json({ error: 'Must accept text/event-stream' });
|
|
4012
|
+
}
|
|
4013
|
+
const apiKey = await validateApiKeyFromRequest(req);
|
|
4014
|
+
if (!apiKey) return res.status(401).json({ error: 'Authentication required' });
|
|
4015
|
+
|
|
4016
|
+
const sessionId = req.headers['mcp-session-id'] || randomUUID();
|
|
4017
|
+
if (!mcpSessions.has(sessionId)) {
|
|
4018
|
+
mcpSessions.set(sessionId, { token: apiKey, createdAt: Date.now(), lastActivity: Date.now() });
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
res.writeHead(200, {
|
|
4022
|
+
'Content-Type': 'text/event-stream',
|
|
4023
|
+
'Cache-Control': 'no-cache',
|
|
4024
|
+
'Connection': 'keep-alive',
|
|
4025
|
+
'Mcp-Session-Id': sessionId,
|
|
4026
|
+
...CORS_HEADERS
|
|
4027
|
+
});
|
|
4028
|
+
|
|
4029
|
+
const heartbeat = setInterval(() => {
|
|
4030
|
+
if (res.writableEnded) { clearInterval(heartbeat); return; }
|
|
4031
|
+
res.write(`data: ${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/keepalive', params: { timestamp: new Date().toISOString() } })}\n\n`);
|
|
4032
|
+
}, 30000);
|
|
4033
|
+
|
|
4034
|
+
req.on('close', () => {
|
|
4035
|
+
clearInterval(heartbeat);
|
|
4036
|
+
connMonitor.trackDisconnection(sessionId);
|
|
4037
|
+
});
|
|
4038
|
+
});
|
|
4039
|
+
|
|
4040
|
+
// ββ DELETE /mcp/messages β session termination ββ
|
|
4041
|
+
app.delete('/mcp/messages', (req, res) => {
|
|
4042
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
4043
|
+
if (!sessionId) return res.status(400).send('Missing Mcp-Session-Id');
|
|
4044
|
+
if (mcpSessions.delete(sessionId)) {
|
|
4045
|
+
connMonitor.trackDisconnection(sessionId);
|
|
4046
|
+
return res.status(204).end();
|
|
4047
|
+
}
|
|
4048
|
+
res.status(404).end();
|
|
4049
|
+
});
|
|
4050
|
+
|
|
4051
|
+
// ββ OPTIONS /mcp/messages β CORS preflight ββ
|
|
4052
|
+
// (already defined above)
|
|
4053
|
+
|
|
4054
|
+
// ββ /mcp β direct handlers (NOT aliases β ChatGPT validates this URL) ββ
|
|
4055
|
+
app.options('/mcp', (req, res) => { res.writeHead(204, CORS_HEADERS); res.end(); });
|
|
4056
|
+
app.post('/mcp', async (req, res) => {
|
|
4057
|
+
// Same handler as /mcp/messages β ChatGPT uses this URL
|
|
4058
|
+
req.url = '/mcp/messages';
|
|
4059
|
+
return app._router.handle(req, res, () => res.status(404).end());
|
|
4060
|
+
});
|
|
4061
|
+
|
|
4062
|
+
// ββ /mcp/sse β legacy SSE endpoint (Python had this) ββ
|
|
4063
|
+
app.get('/mcp/sse', async (req, res) => {
|
|
4064
|
+
// Forward to /sse handler
|
|
4065
|
+
req.url = '/sse';
|
|
4066
|
+
return app._router.handle(req, res, () => res.status(404).end());
|
|
4067
|
+
});
|
|
4068
|
+
|
|
3578
4069
|
// ββ Deprecated SSE transport (/sse + /messages) ββ
|
|
3579
4070
|
app.get('/sse', async (req, res) => {
|
|
3580
4071
|
structuredLog.info('SSE connection established (deprecated transport)');
|
|
@@ -3634,7 +4125,7 @@ if (REMOTE_MODE) {
|
|
|
3634
4125
|
res.json({
|
|
3635
4126
|
mcp_version: '2025-06-18',
|
|
3636
4127
|
server_name: 'pΕ«rmemo MCP Server',
|
|
3637
|
-
server_version: '14.
|
|
4128
|
+
server_version: '14.3.0',
|
|
3638
4129
|
transports: [
|
|
3639
4130
|
{ type: 'http', url: `${serverUrl}/mcp` },
|
|
3640
4131
|
{ type: 'sse', url: `${serverUrl}/sse` }
|
|
@@ -3651,11 +4142,40 @@ if (REMOTE_MODE) {
|
|
|
3651
4142
|
});
|
|
3652
4143
|
|
|
3653
4144
|
app.get('/.well-known/mcp.json', (req, res) => {
|
|
3654
|
-
// Alias β same as /.well-known/mcp
|
|
3655
4145
|
req.url = '/.well-known/mcp';
|
|
3656
4146
|
app.handle(req, res);
|
|
3657
4147
|
});
|
|
3658
4148
|
|
|
4149
|
+
app.get('/.well-known/mcp-manifest.json', (req, res) => {
|
|
4150
|
+
const serverUrl = `https://${req.get('host')}`;
|
|
4151
|
+
res.json({
|
|
4152
|
+
name: 'purmemo',
|
|
4153
|
+
version: '14.3.0',
|
|
4154
|
+
description: 'AI-powered memory and knowledge management platform β save and recall conversations across Claude, ChatGPT, Gemini, and more',
|
|
4155
|
+
author: 'Purmemo',
|
|
4156
|
+
homepage: 'https://purmemo.ai',
|
|
4157
|
+
license: 'MIT',
|
|
4158
|
+
capabilities: { tools: true, resources: true, prompts: true },
|
|
4159
|
+
authentication: {
|
|
4160
|
+
type: 'oauth2',
|
|
4161
|
+
authorization_url: `${serverUrl}/oauth/authorize`,
|
|
4162
|
+
token_url: `${serverUrl}/oauth/token`,
|
|
4163
|
+
registration_url: `${serverUrl}/oauth/register`,
|
|
4164
|
+
scope: 'read write',
|
|
4165
|
+
pkce: true,
|
|
4166
|
+
pkce_method: 'S256'
|
|
4167
|
+
},
|
|
4168
|
+
endpoints: {
|
|
4169
|
+
base_url: serverUrl,
|
|
4170
|
+
mcp: '/mcp/messages',
|
|
4171
|
+
sse: '/sse',
|
|
4172
|
+
health: '/health'
|
|
4173
|
+
},
|
|
4174
|
+
tools: TOOLS.map(t => ({ name: t.name, description: t.description.split('\n')[0] })),
|
|
4175
|
+
contact: { email: 'support@purmemo.ai', documentation: 'https://docs.purmemo.ai/mcp' }
|
|
4176
|
+
});
|
|
4177
|
+
});
|
|
4178
|
+
|
|
3659
4179
|
// ββ OAuth Module ββ
|
|
3660
4180
|
const { generateCode, storeAuthCode, exchangeCodeForToken } = await import('./remote/oauth-simple.js');
|
|
3661
4181
|
const { readFileSync } = await import('node:fs');
|
|
@@ -3734,7 +4254,7 @@ if (REMOTE_MODE) {
|
|
|
3734
4254
|
const apiKey = Buffer.from(session, 'base64').toString('utf8');
|
|
3735
4255
|
// Validate against backend
|
|
3736
4256
|
const meResp = await fetch(`${API_URL}/api/v1/auth/me`, {
|
|
3737
|
-
headers: { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': 'purmemo-mcp/14.
|
|
4257
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': 'purmemo-mcp/14.3.0' },
|
|
3738
4258
|
signal: AbortSignal.timeout(10000)
|
|
3739
4259
|
});
|
|
3740
4260
|
if (meResp.ok) {
|
|
@@ -3784,7 +4304,7 @@ if (REMOTE_MODE) {
|
|
|
3784
4304
|
try {
|
|
3785
4305
|
const authResp = await fetch(`${API_URL}/api/v1/auth/login`, {
|
|
3786
4306
|
method: 'POST',
|
|
3787
|
-
headers: { 'Content-Type': 'application/json', 'User-Agent': 'purmemo-mcp/14.
|
|
4307
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'purmemo-mcp/14.3.0' },
|
|
3788
4308
|
body: JSON.stringify({ email, password }),
|
|
3789
4309
|
signal: AbortSignal.timeout(10000)
|
|
3790
4310
|
});
|
|
@@ -3823,7 +4343,7 @@ if (REMOTE_MODE) {
|
|
|
3823
4343
|
try {
|
|
3824
4344
|
const regResp = await fetch(`${API_URL}/api/v1/auth/register`, {
|
|
3825
4345
|
method: 'POST',
|
|
3826
|
-
headers: { 'Content-Type': 'application/json', 'User-Agent': 'purmemo-mcp/14.
|
|
4346
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'purmemo-mcp/14.3.0' },
|
|
3827
4347
|
body: JSON.stringify({ email, password }),
|
|
3828
4348
|
signal: AbortSignal.timeout(10000)
|
|
3829
4349
|
});
|
|
@@ -3864,7 +4384,7 @@ if (REMOTE_MODE) {
|
|
|
3864
4384
|
const { email } = req.body;
|
|
3865
4385
|
const resp = await fetch(`${API_URL}/api/v1/auth/check-email`, {
|
|
3866
4386
|
method: 'POST',
|
|
3867
|
-
headers: { 'Content-Type': 'application/json', 'User-Agent': 'purmemo-mcp/14.
|
|
4387
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'purmemo-mcp/14.3.0' },
|
|
3868
4388
|
body: JSON.stringify({ email }),
|
|
3869
4389
|
signal: AbortSignal.timeout(10000)
|
|
3870
4390
|
});
|
|
@@ -3993,7 +4513,7 @@ if (REMOTE_MODE) {
|
|
|
3993
4513
|
const serverUrl = `https://${req.get('host')}`;
|
|
3994
4514
|
res.json({
|
|
3995
4515
|
name: 'pΕ«rmemo MCP Server',
|
|
3996
|
-
version: '14.
|
|
4516
|
+
version: '14.3.0',
|
|
3997
4517
|
status: 'running',
|
|
3998
4518
|
endpoints: {
|
|
3999
4519
|
mcp: `${serverUrl}/mcp`,
|
|
@@ -4014,7 +4534,7 @@ if (REMOTE_MODE) {
|
|
|
4014
4534
|
app.listen(PORT, () => {
|
|
4015
4535
|
structuredLog.info('Purmemo Remote MCP Server started', {
|
|
4016
4536
|
mode: 'remote',
|
|
4017
|
-
version: '14.
|
|
4537
|
+
version: '14.3.0',
|
|
4018
4538
|
port: PORT,
|
|
4019
4539
|
api_url: API_URL,
|
|
4020
4540
|
api_key_configured: !!resolvedApiKey,
|
|
@@ -4036,6 +4556,7 @@ if (REMOTE_MODE) {
|
|
|
4036
4556
|
// Graceful shutdown
|
|
4037
4557
|
process.on('SIGINT', async () => {
|
|
4038
4558
|
structuredLog.info('Shutting down remote server...');
|
|
4559
|
+
clearInterval(sessionCleanupInterval);
|
|
4039
4560
|
connMonitor.stop();
|
|
4040
4561
|
for (const sid in transports) {
|
|
4041
4562
|
try { await transports[sid].close(); } catch {}
|
|
@@ -4058,7 +4579,7 @@ if (REMOTE_MODE) {
|
|
|
4058
4579
|
checkForUpdates();
|
|
4059
4580
|
structuredLog.info('Purmemo MCP Server started successfully', {
|
|
4060
4581
|
mode: 'stdio',
|
|
4061
|
-
version: '14.
|
|
4582
|
+
version: '14.3.0',
|
|
4062
4583
|
tier: '4-resources-prompts',
|
|
4063
4584
|
api_url: API_URL,
|
|
4064
4585
|
api_key_configured: !!resolvedApiKey,
|