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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +582 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "purmemo-mcp",
3
- "version": "14.2.0",
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
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
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.0.0' },
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.1.0',
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.1.0',
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 transport (/mcp) ──
3522
- app.all('/mcp', async (req, res) => {
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
- if (sessionId && transports[sessionId]) {
3528
- const existing = transports[sessionId];
3529
- if (existing instanceof StreamableHTTPServerTransport) {
3530
- transport = existing;
3531
- } else {
3532
- return res.status(400).json({
3533
- jsonrpc: '2.0',
3534
- error: { code: -32000, message: 'Session uses a different transport protocol' },
3535
- id: null
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
- } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
3539
- transport = new StreamableHTTPServerTransport({
3540
- sessionIdGenerator: () => randomUUID(),
3541
- onsessioninitialized: (sid) => {
3542
- transports[sid] = transport;
3543
- connectionCount++;
3544
- connMonitor.trackConnection(sid, { type: 'streamable-http' });
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
- transport.onclose = () => {
3549
- const sid = transport.sessionId;
3550
- if (sid && transports[sid]) {
3551
- delete transports[sid];
3552
- connMonitor.trackDisconnection(sid);
3553
- structuredLog.info('StreamableHTTP session closed', { session_id: sid });
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
- await server.connect(transport);
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
- return res.status(400).json({
3559
- jsonrpc: '2.0',
3560
- error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
3561
- id: null
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
- await transport.handleRequest(req, res, req.body);
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 handling /mcp request', { error_message: error.message });
4001
+ structuredLog.error('Error in /mcp/messages', { error: error.message });
3568
4002
  if (!res.headersSent) {
3569
- res.status(500).json({
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.0.0',
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.0.0' },
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.0.0' },
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.0.0' },
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.0.0' },
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.0.0',
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.0.0',
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.0.0',
4582
+ version: '14.3.0',
4062
4583
  tier: '4-resources-prompts',
4063
4584
  api_url: API_URL,
4064
4585
  api_key_configured: !!resolvedApiKey,