@synth-coder/memhub 0.1.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/.eslintrc.cjs +46 -0
- package/.github/workflows/ci.yml +74 -0
- package/.iflow/commands/opsx-apply.md +152 -0
- package/.iflow/commands/opsx-archive.md +157 -0
- package/.iflow/commands/opsx-explore.md +173 -0
- package/.iflow/commands/opsx-propose.md +106 -0
- package/.iflow/skills/openspec-apply-change/SKILL.md +156 -0
- package/.iflow/skills/openspec-archive-change/SKILL.md +114 -0
- package/.iflow/skills/openspec-explore/SKILL.md +288 -0
- package/.iflow/skills/openspec-propose/SKILL.md +110 -0
- package/.prettierrc +11 -0
- package/README.md +171 -0
- package/README.zh-CN.md +169 -0
- package/dist/src/contracts/index.d.ts +7 -0
- package/dist/src/contracts/index.d.ts.map +1 -0
- package/dist/src/contracts/index.js +10 -0
- package/dist/src/contracts/index.js.map +1 -0
- package/dist/src/contracts/mcp.d.ts +194 -0
- package/dist/src/contracts/mcp.d.ts.map +1 -0
- package/dist/src/contracts/mcp.js +112 -0
- package/dist/src/contracts/mcp.js.map +1 -0
- package/dist/src/contracts/schemas.d.ts +1153 -0
- package/dist/src/contracts/schemas.d.ts.map +1 -0
- package/dist/src/contracts/schemas.js +246 -0
- package/dist/src/contracts/schemas.js.map +1 -0
- package/dist/src/contracts/types.d.ts +328 -0
- package/dist/src/contracts/types.d.ts.map +1 -0
- package/dist/src/contracts/types.js +30 -0
- package/dist/src/contracts/types.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/server/index.d.ts +5 -0
- package/dist/src/server/index.d.ts.map +1 -0
- package/dist/src/server/index.js +5 -0
- package/dist/src/server/index.js.map +1 -0
- package/dist/src/server/mcp-server.d.ts +80 -0
- package/dist/src/server/mcp-server.d.ts.map +1 -0
- package/dist/src/server/mcp-server.js +263 -0
- package/dist/src/server/mcp-server.js.map +1 -0
- package/dist/src/services/index.d.ts +5 -0
- package/dist/src/services/index.d.ts.map +1 -0
- package/dist/src/services/index.js +5 -0
- package/dist/src/services/index.js.map +1 -0
- package/dist/src/services/memory-service.d.ts +105 -0
- package/dist/src/services/memory-service.d.ts.map +1 -0
- package/dist/src/services/memory-service.js +447 -0
- package/dist/src/services/memory-service.js.map +1 -0
- package/dist/src/storage/frontmatter-parser.d.ts +69 -0
- package/dist/src/storage/frontmatter-parser.d.ts.map +1 -0
- package/dist/src/storage/frontmatter-parser.js +207 -0
- package/dist/src/storage/frontmatter-parser.js.map +1 -0
- package/dist/src/storage/index.d.ts +6 -0
- package/dist/src/storage/index.d.ts.map +1 -0
- package/dist/src/storage/index.js +6 -0
- package/dist/src/storage/index.js.map +1 -0
- package/dist/src/storage/markdown-storage.d.ts +76 -0
- package/dist/src/storage/markdown-storage.d.ts.map +1 -0
- package/dist/src/storage/markdown-storage.js +193 -0
- package/dist/src/storage/markdown-storage.js.map +1 -0
- package/dist/src/utils/index.d.ts +5 -0
- package/dist/src/utils/index.d.ts.map +1 -0
- package/dist/src/utils/index.js +5 -0
- package/dist/src/utils/index.js.map +1 -0
- package/dist/src/utils/slugify.d.ts +24 -0
- package/dist/src/utils/slugify.d.ts.map +1 -0
- package/dist/src/utils/slugify.js +56 -0
- package/dist/src/utils/slugify.js.map +1 -0
- package/docs/architecture.md +349 -0
- package/docs/contracts.md +119 -0
- package/docs/prompt-template.md +79 -0
- package/docs/proposal-close-gates.md +58 -0
- package/docs/tool-calling-policy.md +107 -0
- package/package.json +53 -0
- package/src/contracts/index.ts +12 -0
- package/src/contracts/mcp.ts +303 -0
- package/src/contracts/schemas.ts +311 -0
- package/src/contracts/types.ts +414 -0
- package/src/index.ts +8 -0
- package/src/server/index.ts +5 -0
- package/src/server/mcp-server.ts +352 -0
- package/src/services/index.ts +5 -0
- package/src/services/memory-service.ts +548 -0
- package/src/storage/frontmatter-parser.ts +243 -0
- package/src/storage/index.ts +6 -0
- package/src/storage/markdown-storage.ts +236 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/slugify.ts +63 -0
- package/test/contracts/schemas.test.ts +313 -0
- package/test/contracts/types.test.ts +21 -0
- package/test/frontmatter-parser-more.test.ts +94 -0
- package/test/server/mcp-server-internals.test.ts +257 -0
- package/test/server/mcp-server.test.ts +97 -0
- package/test/services/memory-service-edge.test.ts +248 -0
- package/test/services/memory-service.test.ts +279 -0
- package/test/storage/frontmatter-parser.test.ts +223 -0
- package/test/storage/markdown-storage.test.ts +217 -0
- package/test/storage/storage-edge.test.ts +238 -0
- package/test/utils/slugify-edge.test.ts +94 -0
- package/test/utils/slugify.test.ts +68 -0
- package/tsconfig.json +26 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema validation tests - RED phase (TDD)
|
|
3
|
+
* These tests define the expected behavior of Zod schemas
|
|
4
|
+
* They will fail until implementations are written
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
UUIDSchema,
|
|
10
|
+
ISO8601TimestampSchema,
|
|
11
|
+
TagSchema,
|
|
12
|
+
CategorySchema,
|
|
13
|
+
ImportanceSchema,
|
|
14
|
+
MemorySchema,
|
|
15
|
+
CreateMemoryInputSchema,
|
|
16
|
+
ReadMemoryInputSchema,
|
|
17
|
+
UpdateMemoryInputSchema,
|
|
18
|
+
DeleteMemoryInputSchema,
|
|
19
|
+
ListMemoryInputSchema,
|
|
20
|
+
SearchMemoryInputSchema,
|
|
21
|
+
} from '../../src/contracts/schemas.js';
|
|
22
|
+
|
|
23
|
+
describe('Schema Validation', () => {
|
|
24
|
+
describe('UUIDSchema', () => {
|
|
25
|
+
it('should accept valid UUID v4', () => {
|
|
26
|
+
const validUuid = '550e8400-e29b-41d4-a716-446655440000';
|
|
27
|
+
expect(UUIDSchema.safeParse(validUuid).success).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should reject invalid UUID format', () => {
|
|
31
|
+
const invalidUuid = 'not-a-uuid';
|
|
32
|
+
expect(UUIDSchema.safeParse(invalidUuid).success).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should reject empty string', () => {
|
|
36
|
+
expect(UUIDSchema.safeParse('').success).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('ISO8601TimestampSchema', () => {
|
|
41
|
+
it('should accept valid ISO 8601 timestamp', () => {
|
|
42
|
+
const validTimestamp = '2024-03-15T10:30:00Z';
|
|
43
|
+
expect(ISO8601TimestampSchema.safeParse(validTimestamp).success).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should reject invalid timestamp format', () => {
|
|
47
|
+
const invalidTimestamp = '2024-03-15';
|
|
48
|
+
expect(ISO8601TimestampSchema.safeParse(invalidTimestamp).success).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should reject non-ISO date strings', () => {
|
|
52
|
+
const nonIsoDate = 'March 15, 2024';
|
|
53
|
+
expect(ISO8601TimestampSchema.safeParse(nonIsoDate).success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('TagSchema', () => {
|
|
58
|
+
it('should accept valid tag with lowercase letters and hyphens', () => {
|
|
59
|
+
expect(TagSchema.safeParse('project-management').success).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should accept tag with numbers', () => {
|
|
63
|
+
expect(TagSchema.safeParse('task-123').success).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should reject uppercase letters', () => {
|
|
67
|
+
expect(TagSchema.safeParse('Project').success).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should reject spaces', () => {
|
|
71
|
+
expect(TagSchema.safeParse('project management').success).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should reject special characters', () => {
|
|
75
|
+
expect(TagSchema.safeParse('project@work').success).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should reject empty string', () => {
|
|
79
|
+
expect(TagSchema.safeParse('').success).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should reject tags over 50 characters', () => {
|
|
83
|
+
const longTag = 'a'.repeat(51);
|
|
84
|
+
expect(TagSchema.safeParse(longTag).success).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('CategorySchema', () => {
|
|
89
|
+
it('should accept valid category', () => {
|
|
90
|
+
expect(CategorySchema.safeParse('work').success).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should reject uppercase letters', () => {
|
|
94
|
+
expect(CategorySchema.safeParse('Work').success).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should reject empty string', () => {
|
|
98
|
+
expect(CategorySchema.safeParse('').success).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('ImportanceSchema', () => {
|
|
103
|
+
it('should accept importance level 1', () => {
|
|
104
|
+
expect(ImportanceSchema.safeParse(1).success).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should accept importance level 5', () => {
|
|
108
|
+
expect(ImportanceSchema.safeParse(5).success).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should reject level 0', () => {
|
|
112
|
+
expect(ImportanceSchema.safeParse(0).success).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should reject level 6', () => {
|
|
116
|
+
expect(ImportanceSchema.safeParse(6).success).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should reject non-integer values', () => {
|
|
120
|
+
expect(ImportanceSchema.safeParse(3.5).success).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('MemorySchema', () => {
|
|
125
|
+
const validMemory = {
|
|
126
|
+
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
127
|
+
createdAt: '2024-03-15T10:30:00Z',
|
|
128
|
+
updatedAt: '2024-03-15T10:30:00Z',
|
|
129
|
+
tags: ['work', 'project'],
|
|
130
|
+
category: 'general',
|
|
131
|
+
importance: 3,
|
|
132
|
+
title: 'Test Memory',
|
|
133
|
+
content: 'This is test content',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
it('should accept valid memory object', () => {
|
|
137
|
+
expect(MemorySchema.safeParse(validMemory).success).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should reject memory with invalid UUID', () => {
|
|
141
|
+
const invalid = { ...validMemory, id: 'invalid-uuid' };
|
|
142
|
+
expect(MemorySchema.safeParse(invalid).success).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should reject memory with invalid timestamp', () => {
|
|
146
|
+
const invalid = { ...validMemory, createdAt: 'invalid-date' };
|
|
147
|
+
expect(MemorySchema.safeParse(invalid).success).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should reject memory with empty title', () => {
|
|
151
|
+
const invalid = { ...validMemory, title: '' };
|
|
152
|
+
expect(MemorySchema.safeParse(invalid).success).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should reject memory with title over 200 characters', () => {
|
|
156
|
+
const invalid = { ...validMemory, title: 'a'.repeat(201) };
|
|
157
|
+
expect(MemorySchema.safeParse(invalid).success).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should reject memory with content over 100000 characters', () => {
|
|
161
|
+
const invalid = { ...validMemory, content: 'a'.repeat(100001) };
|
|
162
|
+
expect(MemorySchema.safeParse(invalid).success).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should reject memory with invalid importance', () => {
|
|
166
|
+
const invalid = { ...validMemory, importance: 10 };
|
|
167
|
+
expect(MemorySchema.safeParse(invalid).success).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('CreateMemoryInputSchema', () => {
|
|
172
|
+
it('should accept valid create input', () => {
|
|
173
|
+
const input = {
|
|
174
|
+
title: 'New Memory',
|
|
175
|
+
content: 'Content here',
|
|
176
|
+
};
|
|
177
|
+
expect(CreateMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should accept input with optional fields', () => {
|
|
181
|
+
const input = {
|
|
182
|
+
title: 'New Memory',
|
|
183
|
+
content: 'Content here',
|
|
184
|
+
tags: ['tag1', 'tag2'],
|
|
185
|
+
category: 'work',
|
|
186
|
+
importance: 4,
|
|
187
|
+
};
|
|
188
|
+
expect(CreateMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should reject missing title', () => {
|
|
192
|
+
const input = { content: 'Content here' };
|
|
193
|
+
expect(CreateMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should reject missing content', () => {
|
|
197
|
+
const input = { title: 'New Memory' };
|
|
198
|
+
expect(CreateMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should apply default values for optional fields', () => {
|
|
202
|
+
const input = {
|
|
203
|
+
title: 'New Memory',
|
|
204
|
+
content: 'Content here',
|
|
205
|
+
};
|
|
206
|
+
const result = CreateMemoryInputSchema.parse(input);
|
|
207
|
+
expect(result.tags).toEqual([]);
|
|
208
|
+
expect(result.category).toBe('general');
|
|
209
|
+
expect(result.importance).toBe(3);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('ReadMemoryInputSchema', () => {
|
|
214
|
+
it('should accept valid read input', () => {
|
|
215
|
+
const input = { id: '550e8400-e29b-41d4-a716-446655440000' };
|
|
216
|
+
expect(ReadMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should reject invalid UUID', () => {
|
|
220
|
+
const input = { id: 'invalid-uuid' };
|
|
221
|
+
expect(ReadMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('UpdateMemoryInputSchema', () => {
|
|
226
|
+
it('should accept valid update input with ID only', () => {
|
|
227
|
+
const input = { id: '550e8400-e29b-41d4-a716-446655440000' };
|
|
228
|
+
expect(UpdateMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should accept update with partial fields', () => {
|
|
232
|
+
const input = {
|
|
233
|
+
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
234
|
+
title: 'Updated Title',
|
|
235
|
+
};
|
|
236
|
+
expect(UpdateMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should reject missing ID', () => {
|
|
240
|
+
const input = { title: 'Updated Title' };
|
|
241
|
+
expect(UpdateMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('DeleteMemoryInputSchema', () => {
|
|
246
|
+
it('should accept valid delete input', () => {
|
|
247
|
+
const input = { id: '550e8400-e29b-41d4-a716-446655440000' };
|
|
248
|
+
expect(DeleteMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should reject invalid UUID', () => {
|
|
252
|
+
const input = { id: 'invalid' };
|
|
253
|
+
expect(DeleteMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('ListMemoryInputSchema', () => {
|
|
258
|
+
it('should accept empty list input', () => {
|
|
259
|
+
expect(ListMemoryInputSchema.safeParse({}).success).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should accept input with all filters', () => {
|
|
263
|
+
const input = {
|
|
264
|
+
category: 'work',
|
|
265
|
+
tags: ['project'],
|
|
266
|
+
fromDate: '2024-01-01T00:00:00Z',
|
|
267
|
+
toDate: '2024-12-31T23:59:59Z',
|
|
268
|
+
limit: 50,
|
|
269
|
+
offset: 10,
|
|
270
|
+
sortBy: 'createdAt',
|
|
271
|
+
sortOrder: 'desc',
|
|
272
|
+
};
|
|
273
|
+
expect(ListMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should reject limit over 100', () => {
|
|
277
|
+
const input = { limit: 101 };
|
|
278
|
+
expect(ListMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should reject negative offset', () => {
|
|
282
|
+
const input = { offset: -1 };
|
|
283
|
+
expect(ListMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('SearchMemoryInputSchema', () => {
|
|
288
|
+
it('should accept valid search input', () => {
|
|
289
|
+
const input = { query: 'search term' };
|
|
290
|
+
expect(SearchMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should reject empty query', () => {
|
|
294
|
+
const input = { query: '' };
|
|
295
|
+
expect(SearchMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should reject query over 1000 characters', () => {
|
|
299
|
+
const input = { query: 'a'.repeat(1001) };
|
|
300
|
+
expect(SearchMemoryInputSchema.safeParse(input).success).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should accept search with filters', () => {
|
|
304
|
+
const input = {
|
|
305
|
+
query: 'project',
|
|
306
|
+
limit: 20,
|
|
307
|
+
category: 'work',
|
|
308
|
+
tags: ['important'],
|
|
309
|
+
};
|
|
310
|
+
expect(SearchMemoryInputSchema.safeParse(input).success).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types Tests
|
|
3
|
+
* Tests for type definitions and error codes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { ErrorCode } from '../../src/contracts/types.js';
|
|
8
|
+
|
|
9
|
+
describe('ErrorCode', () => {
|
|
10
|
+
it('should have correct error code values', () => {
|
|
11
|
+
expect(ErrorCode.PARSE_ERROR).toBe(-32700);
|
|
12
|
+
expect(ErrorCode.INVALID_REQUEST).toBe(-32600);
|
|
13
|
+
expect(ErrorCode.METHOD_NOT_FOUND).toBe(-32601);
|
|
14
|
+
expect(ErrorCode.INVALID_PARAMS).toBe(-32602);
|
|
15
|
+
expect(ErrorCode.INTERNAL_ERROR).toBe(-32603);
|
|
16
|
+
expect(ErrorCode.NOT_FOUND).toBe(-32001);
|
|
17
|
+
expect(ErrorCode.STORAGE_ERROR).toBe(-32002);
|
|
18
|
+
expect(ErrorCode.VALIDATION_ERROR).toBe(-32003);
|
|
19
|
+
expect(ErrorCode.DUPLICATE_ERROR).toBe(-32004);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Additional FrontMatter Parser Tests
|
|
3
|
+
* For better branch coverage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
parseFrontMatter,
|
|
9
|
+
stringifyFrontMatter,
|
|
10
|
+
FrontMatterError,
|
|
11
|
+
} from '../src/storage/frontmatter-parser.js';
|
|
12
|
+
import type { MemoryFrontMatter } from '../src/contracts/types.js';
|
|
13
|
+
|
|
14
|
+
describe('parseFrontMatter additional branches', () => {
|
|
15
|
+
it('should throw for invalid front matter format', () => {
|
|
16
|
+
// Missing closing ---
|
|
17
|
+
const markdown = '---\nid: "test"\n# Title\n\nContent';
|
|
18
|
+
expect(() => parseFrontMatter(markdown)).toThrow(FrontMatterError);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should parse with Windows line endings', () => {
|
|
22
|
+
const markdown = `---\r\nid: "550e8400-e29b-41d4-a716-446655440000"\r\ncreated_at: "2024-03-15T10:30:00Z"\r\nupdated_at: "2024-03-15T10:30:00Z"\r\ntags: []\r\ncategory: "general"\r\nimportance: 3\r\n---\r\n\r\n# Title\r\n\r\nContent`;
|
|
23
|
+
|
|
24
|
+
const result = parseFrontMatter(markdown);
|
|
25
|
+
expect(result.frontMatter.id).toBe('550e8400-e29b-41d4-a716-446655440000');
|
|
26
|
+
expect(result.title).toBe('Title');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should handle front matter with extra whitespace', () => {
|
|
30
|
+
const markdown = `---
|
|
31
|
+
id: "550e8400-e29b-41d4-a716-446655440000"
|
|
32
|
+
created_at: "2024-03-15T10:30:00Z"
|
|
33
|
+
updated_at: "2024-03-15T10:30:00Z"
|
|
34
|
+
tags: []
|
|
35
|
+
category: "general"
|
|
36
|
+
importance: 3
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
# Title
|
|
40
|
+
|
|
41
|
+
Content`;
|
|
42
|
+
|
|
43
|
+
const result = parseFrontMatter(markdown);
|
|
44
|
+
expect(result.frontMatter.id).toBe('550e8400-e29b-41d4-a716-446655440000');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('stringifyFrontMatter additional branches', () => {
|
|
49
|
+
const baseFrontMatter: MemoryFrontMatter = {
|
|
50
|
+
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
51
|
+
created_at: '2024-03-15T10:30:00Z',
|
|
52
|
+
updated_at: '2024-03-15T10:30:00Z',
|
|
53
|
+
tags: [],
|
|
54
|
+
category: 'general',
|
|
55
|
+
importance: 3,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
it('should handle empty title', () => {
|
|
59
|
+
const result = stringifyFrontMatter(baseFrontMatter, '', 'Content');
|
|
60
|
+
expect(result).toContain('---');
|
|
61
|
+
expect(result).toContain('Content');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should handle empty content', () => {
|
|
65
|
+
const result = stringifyFrontMatter(baseFrontMatter, 'Title', '');
|
|
66
|
+
expect(result).toContain('# Title');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle both empty title and content', () => {
|
|
70
|
+
const result = stringifyFrontMatter(baseFrontMatter, '', '');
|
|
71
|
+
expect(result).toContain('---');
|
|
72
|
+
expect(result).toContain('id:');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should handle single tag', () => {
|
|
76
|
+
const fm = { ...baseFrontMatter, tags: ['single'] };
|
|
77
|
+
const result = stringifyFrontMatter(fm, 'Title', 'Content');
|
|
78
|
+
expect(result).toContain('single');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle many tags', () => {
|
|
82
|
+
const fm = { ...baseFrontMatter, tags: ['a', 'b', 'c', 'd', 'e'] };
|
|
83
|
+
const result = stringifyFrontMatter(fm, 'Title', 'Content');
|
|
84
|
+
expect(result).toContain('a');
|
|
85
|
+
expect(result).toContain('e');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should end with single newline', () => {
|
|
89
|
+
const result = stringifyFrontMatter(baseFrontMatter, 'Title', 'Content');
|
|
90
|
+
const lines = result.split('\n');
|
|
91
|
+
expect(lines[lines.length - 1]).toBe('');
|
|
92
|
+
expect(lines[lines.length - 2]).not.toBe('');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { McpServer } from '../../src/server/mcp-server.js';
|
|
6
|
+
import { MCP_METHODS, ERROR_CODES, MCP_PROTOCOL_VERSION } from '../../src/contracts/mcp.js';
|
|
7
|
+
import { ErrorCode } from '../../src/contracts/types.js';
|
|
8
|
+
import { ServiceError } from '../../src/services/memory-service.js';
|
|
9
|
+
|
|
10
|
+
interface ServerPrivate {
|
|
11
|
+
handleMethod: (method: string, params: unknown) => Promise<unknown>;
|
|
12
|
+
handleMessage: (message: string) => Promise<void>;
|
|
13
|
+
handleError: (id: string | number | null, error: unknown) => void;
|
|
14
|
+
sendResponse: (id: string | number, result: unknown) => void;
|
|
15
|
+
sendError: (id: string | number | null, code: number, message: string, data?: Record<string, unknown>) => void;
|
|
16
|
+
log: (level: 'debug' | 'info' | 'warn' | 'error', message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('McpServer internals', () => {
|
|
20
|
+
let tempDir: string;
|
|
21
|
+
let server: McpServer;
|
|
22
|
+
let serverPrivate: ServerPrivate;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tempDir = mkdtempSync(join(tmpdir(), 'memhub-server-int-test-'));
|
|
26
|
+
process.env.MEMHUB_STORAGE_PATH = tempDir;
|
|
27
|
+
server = new McpServer();
|
|
28
|
+
serverPrivate = server as unknown as ServerPrivate;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
33
|
+
delete process.env.MEMHUB_STORAGE_PATH;
|
|
34
|
+
delete process.env.MEMHUB_LOG_LEVEL;
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('handles initialize method', async () => {
|
|
39
|
+
const result = (await serverPrivate.handleMethod(MCP_METHODS.INITIALIZE, {
|
|
40
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
41
|
+
capabilities: {},
|
|
42
|
+
clientInfo: { name: 'tester', version: '1.0.0' },
|
|
43
|
+
})) as { protocolVersion: string; serverInfo: { name: string; version: string } };
|
|
44
|
+
|
|
45
|
+
expect(result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
|
|
46
|
+
expect(result.serverInfo.name).toBe('memhub');
|
|
47
|
+
expect(result.serverInfo.version.length).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles tools/list method', async () => {
|
|
51
|
+
const result = (await serverPrivate.handleMethod(MCP_METHODS.TOOLS_LIST, {})) as {
|
|
52
|
+
tools: Array<{ name: string }>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
expect(result.tools.length).toBe(2);
|
|
56
|
+
expect(result.tools.some(t => t.name === 'memory_load')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles tools/call for STM load + update flow', async () => {
|
|
60
|
+
const updateResult = (await serverPrivate.handleMethod(MCP_METHODS.TOOLS_CALL, {
|
|
61
|
+
name: 'memory_update',
|
|
62
|
+
arguments: {
|
|
63
|
+
sessionId: '550e8400-e29b-41d4-a716-446655440111',
|
|
64
|
+
entryType: 'decision',
|
|
65
|
+
title: 'TDD note',
|
|
66
|
+
content: 'Write tests first',
|
|
67
|
+
tags: ['tdd'],
|
|
68
|
+
category: 'engineering',
|
|
69
|
+
},
|
|
70
|
+
})) as { content: Array<{ text: string }> };
|
|
71
|
+
|
|
72
|
+
const updatePayload = JSON.parse(updateResult.content[0].text) as { id: string; sessionId: string };
|
|
73
|
+
expect(updatePayload.id).toBeTruthy();
|
|
74
|
+
expect(updatePayload.sessionId).toBe('550e8400-e29b-41d4-a716-446655440111');
|
|
75
|
+
|
|
76
|
+
const loadById = (await serverPrivate.handleMethod(MCP_METHODS.TOOLS_CALL, {
|
|
77
|
+
name: 'memory_load',
|
|
78
|
+
arguments: { id: updatePayload.id },
|
|
79
|
+
})) as { content: Array<{ text: string }> };
|
|
80
|
+
expect(loadById.content[0].text).toContain('TDD note');
|
|
81
|
+
|
|
82
|
+
const loadBySession = (await serverPrivate.handleMethod(MCP_METHODS.TOOLS_CALL, {
|
|
83
|
+
name: 'memory_load',
|
|
84
|
+
arguments: { sessionId: '550e8400-e29b-41d4-a716-446655440111', limit: 10 },
|
|
85
|
+
})) as { content: Array<{ text: string }> };
|
|
86
|
+
expect(loadBySession.content[0].text).toContain('items');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns tool error payload for unknown tool', async () => {
|
|
90
|
+
const result = (await serverPrivate.handleMethod(MCP_METHODS.TOOLS_CALL, {
|
|
91
|
+
name: 'unknown_tool',
|
|
92
|
+
arguments: {},
|
|
93
|
+
})) as { isError?: boolean; content: Array<{ text: string }> };
|
|
94
|
+
|
|
95
|
+
expect(result.isError).toBe(true);
|
|
96
|
+
expect(result.content[0].text).toContain('Unknown tool');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('handles lifecycle methods', async () => {
|
|
100
|
+
await expect(serverPrivate.handleMethod(MCP_METHODS.INITIALIZED, {})).resolves.toBeNull();
|
|
101
|
+
await expect(serverPrivate.handleMethod(MCP_METHODS.SHUTDOWN, {})).resolves.toBeNull();
|
|
102
|
+
|
|
103
|
+
const exitSpy = vi
|
|
104
|
+
.spyOn(process, 'exit')
|
|
105
|
+
.mockImplementation(() => undefined as never);
|
|
106
|
+
|
|
107
|
+
await expect(serverPrivate.handleMethod(MCP_METHODS.EXIT, {})).resolves.toBeNull();
|
|
108
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws ServiceError for unknown method', async () => {
|
|
112
|
+
await expect(serverPrivate.handleMethod('unknown/method', {})).rejects.toMatchObject({
|
|
113
|
+
code: ErrorCode.METHOD_NOT_FOUND,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('routes parse errors from handleMessage', async () => {
|
|
118
|
+
const sendErrorSpy = vi
|
|
119
|
+
.spyOn(serverPrivate, 'sendError')
|
|
120
|
+
.mockImplementation(() => undefined);
|
|
121
|
+
|
|
122
|
+
await serverPrivate.handleMessage('{bad-json');
|
|
123
|
+
|
|
124
|
+
expect(sendErrorSpy).toHaveBeenCalledWith(
|
|
125
|
+
null,
|
|
126
|
+
ERROR_CODES.PARSE_ERROR,
|
|
127
|
+
'Parse error: Invalid JSON'
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('routes invalid request errors from handleMessage', async () => {
|
|
132
|
+
const sendErrorSpy = vi
|
|
133
|
+
.spyOn(serverPrivate, 'sendError')
|
|
134
|
+
.mockImplementation(() => undefined);
|
|
135
|
+
|
|
136
|
+
await serverPrivate.handleMessage(JSON.stringify({ jsonrpc: '2.0' }));
|
|
137
|
+
|
|
138
|
+
expect(sendErrorSpy).toHaveBeenCalledWith(
|
|
139
|
+
null,
|
|
140
|
+
ERROR_CODES.INVALID_REQUEST,
|
|
141
|
+
'Invalid Request'
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does not send response for notifications without id', async () => {
|
|
146
|
+
const sendResponseSpy = vi
|
|
147
|
+
.spyOn(serverPrivate, 'sendResponse')
|
|
148
|
+
.mockImplementation(() => undefined);
|
|
149
|
+
|
|
150
|
+
await serverPrivate.handleMessage(
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
jsonrpc: '2.0',
|
|
153
|
+
method: MCP_METHODS.INITIALIZED,
|
|
154
|
+
params: {},
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(sendResponseSpy).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns INVALID_PARAMS on schema validation errors', async () => {
|
|
162
|
+
const sendErrorSpy = vi
|
|
163
|
+
.spyOn(serverPrivate, 'sendError')
|
|
164
|
+
.mockImplementation(() => undefined);
|
|
165
|
+
|
|
166
|
+
await serverPrivate.handleMessage(
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
jsonrpc: '2.0',
|
|
169
|
+
id: 123,
|
|
170
|
+
method: MCP_METHODS.TOOLS_CALL,
|
|
171
|
+
params: {
|
|
172
|
+
name: 'memory_update',
|
|
173
|
+
arguments: { title: '' },
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(sendErrorSpy).toHaveBeenCalledWith(
|
|
179
|
+
123,
|
|
180
|
+
ERROR_CODES.INVALID_PARAMS,
|
|
181
|
+
expect.stringContaining('Invalid parameters')
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('handleError maps ServiceError, validation error, and generic error', () => {
|
|
186
|
+
const sendErrorSpy = vi
|
|
187
|
+
.spyOn(serverPrivate, 'sendError')
|
|
188
|
+
.mockImplementation(() => undefined);
|
|
189
|
+
|
|
190
|
+
serverPrivate.handleError(
|
|
191
|
+
1,
|
|
192
|
+
new ServiceError('boom', ErrorCode.STORAGE_ERROR, { foo: 'bar' })
|
|
193
|
+
);
|
|
194
|
+
const zodLikeError = new Error('invalid');
|
|
195
|
+
zodLikeError.name = 'ZodError';
|
|
196
|
+
serverPrivate.handleError(2, zodLikeError);
|
|
197
|
+
serverPrivate.handleError(3, new Error('oops'));
|
|
198
|
+
|
|
199
|
+
expect(sendErrorSpy).toHaveBeenNthCalledWith(
|
|
200
|
+
1,
|
|
201
|
+
1,
|
|
202
|
+
ErrorCode.STORAGE_ERROR,
|
|
203
|
+
'boom',
|
|
204
|
+
{ foo: 'bar' }
|
|
205
|
+
);
|
|
206
|
+
expect(sendErrorSpy).toHaveBeenNthCalledWith(
|
|
207
|
+
2,
|
|
208
|
+
2,
|
|
209
|
+
ERROR_CODES.INVALID_PARAMS,
|
|
210
|
+
'Invalid parameters: invalid'
|
|
211
|
+
);
|
|
212
|
+
expect(sendErrorSpy).toHaveBeenNthCalledWith(
|
|
213
|
+
3,
|
|
214
|
+
3,
|
|
215
|
+
ERROR_CODES.INTERNAL_ERROR,
|
|
216
|
+
'Internal error: oops'
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('sendResponse writes valid JSON-RPC response', () => {
|
|
221
|
+
const writeSpy = vi
|
|
222
|
+
.spyOn(process.stdout, 'write')
|
|
223
|
+
.mockReturnValue(true);
|
|
224
|
+
|
|
225
|
+
serverPrivate.sendResponse(7, { ok: true });
|
|
226
|
+
|
|
227
|
+
const output = String(writeSpy.mock.calls[0][0]).trim();
|
|
228
|
+
const parsed = JSON.parse(output) as { id: number; result: { ok: boolean } };
|
|
229
|
+
expect(parsed.id).toBe(7);
|
|
230
|
+
expect(parsed.result.ok).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('sendError writes valid JSON-RPC error response', () => {
|
|
234
|
+
const writeSpy = vi
|
|
235
|
+
.spyOn(process.stdout, 'write')
|
|
236
|
+
.mockReturnValue(true);
|
|
237
|
+
|
|
238
|
+
serverPrivate.sendError(null, ERROR_CODES.INTERNAL_ERROR, 'failure');
|
|
239
|
+
|
|
240
|
+
const output = String(writeSpy.mock.calls[0][0]).trim();
|
|
241
|
+
const parsed = JSON.parse(output) as { id: null; error: { code: number; message: string } };
|
|
242
|
+
expect(parsed.id).toBeNull();
|
|
243
|
+
expect(parsed.error.code).toBe(ERROR_CODES.INTERNAL_ERROR);
|
|
244
|
+
expect(parsed.error.message).toBe('failure');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('log respects configured log level', () => {
|
|
248
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
249
|
+
|
|
250
|
+
process.env.MEMHUB_LOG_LEVEL = 'error';
|
|
251
|
+
serverPrivate.log('info', 'ignore');
|
|
252
|
+
serverPrivate.log('error', 'report');
|
|
253
|
+
|
|
254
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
255
|
+
expect(errorSpy.mock.calls[0][0]).toContain('[ERROR] report');
|
|
256
|
+
});
|
|
257
|
+
});
|