mcp-learning-memory 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ACE Flash Memory MCP Server
4
+ * Model Context Protocol server for managing insights with feedback
5
+ */
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
+ import { log } from './logger.js';
10
+ import { addInsight, listIndex, getDetail, markHelpful, markHarmful, searchInsights, deleteInsight, exportMemory, } from './tools.js';
11
+ const server = new Server({
12
+ name: 'mcp-learning-memory',
13
+ version: '0.2.0',
14
+ }, {
15
+ capabilities: {
16
+ tools: {},
17
+ },
18
+ });
19
+ // List available tools
20
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
21
+ return {
22
+ tools: [
23
+ {
24
+ name: 'add_insight',
25
+ description: 'Add a new insight to memory',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ title: {
30
+ type: 'string',
31
+ description: 'Title of the insight (max 200 chars)',
32
+ },
33
+ tags: {
34
+ type: 'array',
35
+ items: { type: 'string' },
36
+ description: 'Tags for categorization (max 10 tags, 50 chars each)',
37
+ },
38
+ content: {
39
+ type: 'string',
40
+ description: 'Content of the insight (max 10000 chars)',
41
+ },
42
+ overwrite: {
43
+ type: 'boolean',
44
+ description: 'If true, overwrite existing insight with same title (default: false)',
45
+ },
46
+ sourceUrl: {
47
+ type: 'string',
48
+ description: 'Optional source URL for the insight (max 2000 chars)',
49
+ },
50
+ },
51
+ required: ['title', 'tags', 'content'],
52
+ },
53
+ },
54
+ {
55
+ name: 'list_index',
56
+ description: 'List all insights (index view without full content) with optional filtering and sorting',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: {
60
+ tags: {
61
+ type: 'array',
62
+ items: { type: 'string' },
63
+ description: 'Filter by tags (AND logic - insight must have all specified tags)',
64
+ },
65
+ minHelpful: {
66
+ type: 'number',
67
+ description: 'Minimum helpful count (default: 0)',
68
+ },
69
+ sortBy: {
70
+ type: 'string',
71
+ enum: ['created', 'lastUsed', 'helpful'],
72
+ description: 'Sort field (default: lastUsed)',
73
+ },
74
+ limit: {
75
+ type: 'number',
76
+ description: 'Maximum number of results (default: 50)',
77
+ },
78
+ },
79
+ },
80
+ },
81
+ {
82
+ name: 'get_detail',
83
+ description: 'Get full details of a specific insight',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ id: {
88
+ type: 'string',
89
+ description: 'ID of the insight to retrieve',
90
+ },
91
+ },
92
+ required: ['id'],
93
+ },
94
+ },
95
+ {
96
+ name: 'mark_helpful',
97
+ description: 'Mark an insight as helpful',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ id: {
102
+ type: 'string',
103
+ description: 'ID of the insight to mark as helpful',
104
+ },
105
+ },
106
+ required: ['id'],
107
+ },
108
+ },
109
+ {
110
+ name: 'mark_harmful',
111
+ description: 'Mark an insight as harmful',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ id: {
116
+ type: 'string',
117
+ description: 'ID of the insight to mark as harmful',
118
+ },
119
+ },
120
+ required: ['id'],
121
+ },
122
+ },
123
+ {
124
+ name: 'search_insights',
125
+ description: 'Search insights by query string with contextual ranking',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ query: {
130
+ type: 'string',
131
+ description: 'Search query string (searches in title, content, and tags)',
132
+ },
133
+ limit: {
134
+ type: 'number',
135
+ description: 'Maximum number of results (default: 5)',
136
+ },
137
+ },
138
+ required: ['query'],
139
+ },
140
+ },
141
+ {
142
+ name: 'delete_insight',
143
+ description: 'Delete an insight by ID',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ id: {
148
+ type: 'string',
149
+ description: 'ID of the insight to delete',
150
+ },
151
+ },
152
+ required: ['id'],
153
+ },
154
+ },
155
+ {
156
+ name: 'export_memory',
157
+ description: 'Export all insights in JSON or Markdown format',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ format: {
162
+ type: 'string',
163
+ enum: ['json', 'markdown'],
164
+ description: 'Export format: json (default) or markdown',
165
+ },
166
+ },
167
+ },
168
+ },
169
+ ],
170
+ };
171
+ });
172
+ // Handle tool calls
173
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
174
+ const { name, arguments: args } = request.params;
175
+ try {
176
+ switch (name) {
177
+ case 'add_insight': {
178
+ const { title, tags, content, overwrite, sourceUrl } = args;
179
+ const addResult = await addInsight(title, tags, content, overwrite ?? false, sourceUrl);
180
+ // Return human-readable format
181
+ if (addResult.success) {
182
+ const data = addResult.data;
183
+ return {
184
+ content: [{
185
+ type: 'text',
186
+ text: `${addResult.message}\n\nID: ${data.id}\nOverwritten: ${data.overwritten ? 'Yes' : 'No'}`,
187
+ }],
188
+ };
189
+ }
190
+ else {
191
+ // Handle error case (including duplicate detection)
192
+ let errorText = addResult.message;
193
+ if (addResult.data) {
194
+ const dupData = addResult.data;
195
+ if (dupData.duplicate) {
196
+ errorText += `\n\nExisting insight:\nID: ${dupData.existingId}\nTitle: "${dupData.existingTitle}"`;
197
+ }
198
+ }
199
+ return {
200
+ content: [{ type: 'text', text: errorText }],
201
+ isError: true,
202
+ };
203
+ }
204
+ }
205
+ case 'list_index': {
206
+ const { tags, minHelpful, sortBy, limit } = args;
207
+ const listResult = await listIndex({ tags, minHelpful, sortBy, limit });
208
+ // Return human-readable text format instead of JSON
209
+ if (listResult.success && Array.isArray(listResult.data)) {
210
+ const entries = listResult.data;
211
+ if (entries.length === 0) {
212
+ return {
213
+ content: [{ type: 'text', text: 'No insights found.' }],
214
+ };
215
+ }
216
+ const lines = [`${listResult.message}\n`];
217
+ for (const entry of entries) {
218
+ lines.push(`[${entry.id}] ${entry.title}`);
219
+ let statsLine = `Tags: ${entry.tags.join(', ') || 'none'} | Stats: Helpful ${entry.helpful} / Harmful ${entry.harmful} (Trust: ${entry.trustScore})`;
220
+ if (entry.warning) {
221
+ statsLine += ` ⚠️ ${entry.warning}`;
222
+ }
223
+ lines.push(statsLine);
224
+ if (entry.sourceUrl) {
225
+ lines.push(`Source: ${entry.sourceUrl}`);
226
+ }
227
+ if (entry.contentPreview) {
228
+ const preview = entry.contentTruncated
229
+ ? entry.contentPreview + '...'
230
+ : entry.contentPreview;
231
+ lines.push(`> ${preview}`);
232
+ }
233
+ lines.push('');
234
+ }
235
+ return {
236
+ content: [{ type: 'text', text: lines.join('\n').trim() }],
237
+ };
238
+ }
239
+ else {
240
+ // Handle error case
241
+ return {
242
+ content: [{ type: 'text', text: listResult.message }],
243
+ isError: true,
244
+ };
245
+ }
246
+ }
247
+ case 'get_detail': {
248
+ const { id } = args;
249
+ const detailResult = await getDetail(id);
250
+ // Return human-readable format
251
+ if (detailResult.success) {
252
+ const insight = detailResult.data;
253
+ // Calculate trust score for display
254
+ const trustScore = Number(((insight.helpful - insight.harmful * 2) / (insight.helpful + insight.harmful + 1)).toFixed(2));
255
+ const lines = [
256
+ insight.title,
257
+ ``,
258
+ `ID: ${insight.id}`,
259
+ `Tags: ${insight.tags.join(', ')}`,
260
+ `Stats: Helpful ${insight.helpful} / Harmful ${insight.harmful} (Trust: ${trustScore})`,
261
+ `Created: ${insight.created}`,
262
+ `Last Used: ${insight.lastUsed}`,
263
+ ];
264
+ if (insight.sourceUrl) {
265
+ lines.push(`Source: ${insight.sourceUrl}`);
266
+ }
267
+ if (trustScore < 0) {
268
+ lines.push(`⚠️ Warning: Low trust score`);
269
+ }
270
+ lines.push(``, `Content:`, insight.content);
271
+ return {
272
+ content: [{ type: 'text', text: lines.join('\n') }],
273
+ };
274
+ }
275
+ else {
276
+ return {
277
+ content: [{ type: 'text', text: detailResult.message }],
278
+ isError: true,
279
+ };
280
+ }
281
+ }
282
+ case 'mark_helpful': {
283
+ const { id } = args;
284
+ const helpfulResult = await markHelpful(id);
285
+ // Return human-readable format
286
+ if (helpfulResult.success) {
287
+ return {
288
+ content: [{ type: 'text', text: helpfulResult.message }],
289
+ };
290
+ }
291
+ else {
292
+ return {
293
+ content: [{ type: 'text', text: helpfulResult.message }],
294
+ isError: true,
295
+ };
296
+ }
297
+ }
298
+ case 'mark_harmful': {
299
+ const { id } = args;
300
+ const harmfulResult = await markHarmful(id);
301
+ // Return human-readable format
302
+ if (harmfulResult.success) {
303
+ return {
304
+ content: [{ type: 'text', text: harmfulResult.message }],
305
+ };
306
+ }
307
+ else {
308
+ return {
309
+ content: [{ type: 'text', text: harmfulResult.message }],
310
+ isError: true,
311
+ };
312
+ }
313
+ }
314
+ case 'search_insights': {
315
+ const { query, limit } = args;
316
+ const searchResult = await searchInsights(query, limit);
317
+ // Return human-readable text format instead of JSON
318
+ if (searchResult.success && Array.isArray(searchResult.data)) {
319
+ const entries = searchResult.data;
320
+ if (entries.length === 0) {
321
+ return {
322
+ content: [{ type: 'text', text: searchResult.message }],
323
+ };
324
+ }
325
+ const lines = [`${searchResult.message}\n`];
326
+ for (const entry of entries) {
327
+ lines.push(`[${entry.id}] ${entry.title}`);
328
+ let statsLine = `Tags: ${entry.tags.join(', ') || 'none'} | Stats: Helpful ${entry.helpful} / Harmful ${entry.harmful} (Trust: ${entry.trustScore})`;
329
+ if (entry.warning) {
330
+ statsLine += ` ⚠️ ${entry.warning}`;
331
+ }
332
+ lines.push(statsLine);
333
+ if (entry.sourceUrl) {
334
+ lines.push(`Source: ${entry.sourceUrl}`);
335
+ }
336
+ if (entry.contentPreview) {
337
+ const preview = entry.contentTruncated
338
+ ? entry.contentPreview + '...'
339
+ : entry.contentPreview;
340
+ lines.push(`> ${preview}`);
341
+ }
342
+ lines.push('');
343
+ }
344
+ return {
345
+ content: [{ type: 'text', text: lines.join('\n').trim() }],
346
+ };
347
+ }
348
+ else {
349
+ // Handle error case
350
+ return {
351
+ content: [{ type: 'text', text: searchResult.message }],
352
+ isError: true,
353
+ };
354
+ }
355
+ }
356
+ case 'delete_insight': {
357
+ const { id } = args;
358
+ const deleteResult = await deleteInsight(id);
359
+ if (deleteResult.success) {
360
+ return {
361
+ content: [{ type: 'text', text: deleteResult.message }],
362
+ };
363
+ }
364
+ else {
365
+ return {
366
+ content: [{ type: 'text', text: deleteResult.message }],
367
+ isError: true,
368
+ };
369
+ }
370
+ }
371
+ case 'export_memory': {
372
+ const { format } = args;
373
+ const exportResult = await exportMemory(format ?? 'json');
374
+ if (exportResult.success) {
375
+ const resultData = exportResult.data;
376
+ return {
377
+ content: [{
378
+ type: 'text',
379
+ text: `${exportResult.message}\n\n${resultData.data}`,
380
+ }],
381
+ };
382
+ }
383
+ else {
384
+ return {
385
+ content: [{ type: 'text', text: exportResult.message }],
386
+ isError: true,
387
+ };
388
+ }
389
+ }
390
+ default:
391
+ return {
392
+ content: [
393
+ {
394
+ type: 'text',
395
+ text: `Unknown tool: ${name}`,
396
+ },
397
+ ],
398
+ isError: true,
399
+ };
400
+ }
401
+ }
402
+ catch (error) {
403
+ log.error('Tool execution error:', error);
404
+ return {
405
+ content: [
406
+ {
407
+ type: 'text',
408
+ text: `Error executing tool: ${error.message}`,
409
+ },
410
+ ],
411
+ isError: true,
412
+ };
413
+ }
414
+ });
415
+ // Start the server
416
+ async function main() {
417
+ const transport = new StdioServerTransport();
418
+ await server.connect(transport);
419
+ log.info('MCP Learning Memory server running');
420
+ }
421
+ main().catch((error) => {
422
+ log.error('Server error:', error);
423
+ process.exit(1);
424
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Logger that ONLY uses console.error
3
+ * Never use console.error - it breaks MCP protocol
4
+ */
5
+ export declare const log: {
6
+ info: (...args: unknown[]) => void;
7
+ error: (...args: unknown[]) => void;
8
+ };
package/dist/logger.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Logger that ONLY uses console.error
3
+ * Never use console.error - it breaks MCP protocol
4
+ */
5
+ export const log = {
6
+ info: (...args) => console.error('[INFO]', ...args),
7
+ error: (...args) => console.error('[ERROR]', ...args),
8
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * File storage with atomic writes
3
+ */
4
+ import type { Memory } from './types.js';
5
+ export declare function getMemoryPath(): string;
6
+ export declare function loadMemory(): Promise<Memory>;
7
+ export declare function saveMemory(memory: Memory): Promise<void>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * File storage with atomic writes
3
+ */
4
+ import { promises as fs } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { log } from './logger.js';
8
+ export function getMemoryPath() {
9
+ const xdgDataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
10
+ const aceDir = join(xdgDataHome, 'ace-flash-memory');
11
+ return join(aceDir, 'memory.json');
12
+ }
13
+ function isValidInsight(data) {
14
+ if (typeof data !== 'object' || data === null)
15
+ return false;
16
+ const obj = data;
17
+ const hasRequiredFields = typeof obj.id === 'string' &&
18
+ typeof obj.title === 'string' &&
19
+ Array.isArray(obj.tags) &&
20
+ obj.tags.every((tag) => typeof tag === 'string') &&
21
+ typeof obj.content === 'string' &&
22
+ typeof obj.helpful === 'number' &&
23
+ typeof obj.harmful === 'number' &&
24
+ typeof obj.created === 'string' &&
25
+ typeof obj.lastUsed === 'string';
26
+ if (!hasRequiredFields)
27
+ return false;
28
+ // sourceUrl is optional, but if present must be a string
29
+ if (obj.sourceUrl !== undefined && typeof obj.sourceUrl !== 'string') {
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+ function isValidMemory(data) {
35
+ if (typeof data !== 'object' || data === null)
36
+ return false;
37
+ const obj = data;
38
+ if (!Array.isArray(obj.insights))
39
+ return false;
40
+ // Validate each insight in the array
41
+ return obj.insights.every(isValidInsight);
42
+ }
43
+ async function createBackupAndReturnEmpty(memoryPath, reason) {
44
+ log.error(reason);
45
+ // Use ISO timestamp format (matching documentation examples)
46
+ // Replace colons and dots with hyphens for filesystem compatibility
47
+ const isoTimestamp = new Date().toISOString().replace(/:/g, '-').replace(/\./g, '-');
48
+ const randomSuffix = Math.random().toString(36).slice(2, 11);
49
+ const backupPath = `${memoryPath}.corrupt.${isoTimestamp}.${randomSuffix}`;
50
+ try {
51
+ await fs.rename(memoryPath, backupPath);
52
+ log.info(`Corrupted file backed up to: ${backupPath}`);
53
+ }
54
+ catch (backupError) {
55
+ log.error('Failed to create backup:', backupError);
56
+ // Even if backup fails, we still return empty memory to allow recovery
57
+ }
58
+ return { insights: [] };
59
+ }
60
+ export async function loadMemory() {
61
+ const memoryPath = getMemoryPath();
62
+ try {
63
+ const data = await fs.readFile(memoryPath, 'utf-8');
64
+ const parsed = JSON.parse(data);
65
+ if (!isValidMemory(parsed)) {
66
+ return await createBackupAndReturnEmpty(memoryPath, 'Memory file has invalid structure, creating backup and starting fresh');
67
+ }
68
+ return parsed;
69
+ }
70
+ catch (error) {
71
+ const nodeError = error;
72
+ if (nodeError.code === 'ENOENT') {
73
+ log.info('Memory file not found, creating new one');
74
+ return { insights: [] };
75
+ }
76
+ // Handle corrupted JSON (SyntaxError from JSON.parse)
77
+ if (error instanceof SyntaxError) {
78
+ return await createBackupAndReturnEmpty(memoryPath, 'Memory file is corrupted (invalid JSON), creating backup and starting fresh');
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+ export async function saveMemory(memory) {
84
+ const memoryPath = getMemoryPath();
85
+ const xdgDataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
86
+ const memoryDir = join(xdgDataHome, 'ace-flash-memory');
87
+ // Use unique temp file: process.pid + random suffix to avoid collisions
88
+ const tmpPath = `${memoryPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 11)}`;
89
+ // Ensure directory exists
90
+ await fs.mkdir(memoryDir, { recursive: true });
91
+ // Atomic write: write to unique .tmp then rename
92
+ await fs.writeFile(tmpPath, JSON.stringify(memory, null, 2), 'utf-8');
93
+ await fs.rename(tmpPath, memoryPath);
94
+ log.info('Memory saved successfully');
95
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tool implementations for MCP server
3
+ */
4
+ import type { ToolResponse, ListIndexParams } from './types.js';
5
+ export declare function addInsight(title: string, tags: string[], content: string, overwrite?: boolean, sourceUrl?: string): Promise<ToolResponse>;
6
+ export declare function listIndex(params?: ListIndexParams): Promise<ToolResponse>;
7
+ export declare function getDetail(id: string): Promise<ToolResponse>;
8
+ export declare function markHelpful(id: string): Promise<ToolResponse>;
9
+ export declare function markHarmful(id: string): Promise<ToolResponse>;
10
+ export declare function searchInsights(query: string, limit?: number): Promise<ToolResponse>;
11
+ export declare function deleteInsight(id: string): Promise<ToolResponse>;
12
+ export declare function exportMemory(format?: 'json' | 'markdown'): Promise<ToolResponse>;