@selfagency/beans-mcp 0.1.1 → 0.1.2
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/beans-mcp-server.cjs → beans-mcp-server.cjs} +8 -4
- package/{dist/index.cjs → index.cjs} +8 -4
- package/{dist/index.d.ts → index.d.ts} +2 -1
- package/{dist/index.js → index.js} +8 -4
- package/package.json +27 -64
- package/.beans.yml +0 -6
- package/.claude/settings.local.json +0 -18
- package/.editorconfig +0 -13
- package/.github/workflows/release.yml +0 -235
- package/.github/workflows/test.yml +0 -80
- package/.husky/pre-commit +0 -1
- package/.nvmrc +0 -1
- package/.oxfmtrc.json +0 -11
- package/.oxlintrc.json +0 -37
- package/.vscode/settings.json +0 -3
- package/CHANGELOG.md +0 -140
- package/CONTRIBUTING.md +0 -139
- package/dist/README.md +0 -307
- package/dist/beans-mcp-server.cjs.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/package.json +0 -43
- package/pnpm-workspace.yaml +0 -2
- package/scripts/release.js +0 -433
- package/scripts/write-dist-package.js +0 -53
- package/src/cli.ts +0 -14
- package/src/index.ts +0 -21
- package/src/internal/graphql.ts +0 -33
- package/src/internal/queryHelpers.ts +0 -157
- package/src/server/BeansMcpServer.ts +0 -600
- package/src/server/backend.ts +0 -358
- package/src/test/BeansMcpServer.test.ts +0 -514
- package/src/test/handlers.unit.test.ts +0 -184
- package/src/test/parseCliArgs.test.ts +0 -69
- package/src/test/protocol.e2e.test.ts +0 -884
- package/src/test/queryHelpers.test.ts +0 -524
- package/src/test/startBeansMcpServer.test.ts +0 -146
- package/src/test/tools-integration.test.ts +0 -912
- package/src/test/utils.test.ts +0 -80
- package/src/types.ts +0 -46
- package/src/utils.ts +0 -20
- package/tsconfig.json +0 -24
- package/tsup.config.ts +0 -42
- package/vitest.config.ts +0 -18
|
@@ -1,524 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { handleQueryOperation, sortBeans } from '../internal/queryHelpers';
|
|
3
|
-
import type { BeanRecord } from '../types';
|
|
4
|
-
|
|
5
|
-
describe('sortBeans', () => {
|
|
6
|
-
const beans: BeanRecord[] = [
|
|
7
|
-
{
|
|
8
|
-
id: 'bean1',
|
|
9
|
-
slug: 'bean1',
|
|
10
|
-
path: 'bean1.md',
|
|
11
|
-
title: 'Alpha Task',
|
|
12
|
-
body: 'Content',
|
|
13
|
-
status: 'completed',
|
|
14
|
-
type: 'task',
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
id: 'bean2',
|
|
18
|
-
slug: 'bean2',
|
|
19
|
-
path: 'bean2.md',
|
|
20
|
-
title: 'Beta Feature',
|
|
21
|
-
body: 'Content',
|
|
22
|
-
status: 'in-progress',
|
|
23
|
-
type: 'feature',
|
|
24
|
-
priority: 'high',
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
id: 'bean3',
|
|
28
|
-
slug: 'bean3',
|
|
29
|
-
path: 'bean3.md',
|
|
30
|
-
title: 'Gamma Bug',
|
|
31
|
-
body: 'Content',
|
|
32
|
-
status: 'todo',
|
|
33
|
-
type: 'bug',
|
|
34
|
-
priority: 'critical',
|
|
35
|
-
},
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
it('should sort by status-priority-type-title (default)', () => {
|
|
39
|
-
const sorted = sortBeans(beans, 'status-priority-type-title');
|
|
40
|
-
// First bean should have in-progress status and high priority
|
|
41
|
-
expect(sorted[0].status).toBe('in-progress');
|
|
42
|
-
expect(sorted[0].priority).toBe('high');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('should sort by updated timestamp', () => {
|
|
46
|
-
const beansWithDates = beans.map((b, i) => ({
|
|
47
|
-
...b,
|
|
48
|
-
updatedAt: `2026-02-${20 - i}T10:00:00Z`,
|
|
49
|
-
}));
|
|
50
|
-
const sorted = sortBeans(beansWithDates, 'updated');
|
|
51
|
-
expect(sorted[0].updatedAt).toBe('2026-02-20T10:00:00Z');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should sort by created timestamp', () => {
|
|
55
|
-
const beansWithDates = beans.map((b, i) => ({
|
|
56
|
-
...b,
|
|
57
|
-
createdAt: `2026-02-${20 - i}T10:00:00Z`,
|
|
58
|
-
}));
|
|
59
|
-
const sorted = sortBeans(beansWithDates, 'created');
|
|
60
|
-
expect(sorted[0].createdAt).toBe('2026-02-20T10:00:00Z');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should sort by id alphabetically', () => {
|
|
64
|
-
const sorted = sortBeans(beans, 'id');
|
|
65
|
-
expect(sorted[0].id).toBe('bean1');
|
|
66
|
-
expect(sorted[1].id).toBe('bean2');
|
|
67
|
-
expect(sorted[2].id).toBe('bean3');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('should handle missing status in weight map', () => {
|
|
71
|
-
const customBeans = [
|
|
72
|
-
{ ...beans[0], status: 'unknown-status' },
|
|
73
|
-
{ ...beans[1], status: 'in-progress' },
|
|
74
|
-
];
|
|
75
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
76
|
-
expect(sorted).toHaveLength(2);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('should use default priority "normal" when missing', () => {
|
|
80
|
-
const customBeans = [{ ...beans[0], priority: undefined }];
|
|
81
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
82
|
-
expect(sorted[0].priority).toBeUndefined();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('should handle missing type in weight map', () => {
|
|
86
|
-
const customBeans = [{ ...beans[0], type: 'unknown-type' }];
|
|
87
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
88
|
-
expect(sorted).toHaveLength(1);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('should sort by title when other attributes are equal', () => {
|
|
92
|
-
const customBeans = [
|
|
93
|
-
{
|
|
94
|
-
...beans[0],
|
|
95
|
-
status: 'todo',
|
|
96
|
-
type: 'task',
|
|
97
|
-
priority: 'normal',
|
|
98
|
-
title: 'Zebra',
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
...beans[1],
|
|
102
|
-
status: 'todo',
|
|
103
|
-
type: 'task',
|
|
104
|
-
priority: 'normal',
|
|
105
|
-
title: 'Alpha',
|
|
106
|
-
},
|
|
107
|
-
];
|
|
108
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
109
|
-
expect(sorted[0].title).toBe('Alpha');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('should compare different priorities within same status', () => {
|
|
113
|
-
const customBeans = [
|
|
114
|
-
{ ...beans[0], status: 'todo', priority: 'low', title: 'Low' },
|
|
115
|
-
{ ...beans[1], status: 'todo', priority: 'high', title: 'High' },
|
|
116
|
-
];
|
|
117
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
118
|
-
expect(sorted[0].priority).toBe('high');
|
|
119
|
-
expect(sorted[1].priority).toBe('low');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('should compare different types within same status and priority', () => {
|
|
123
|
-
const customBeans = [
|
|
124
|
-
{
|
|
125
|
-
...beans[0],
|
|
126
|
-
status: 'todo',
|
|
127
|
-
priority: 'normal',
|
|
128
|
-
type: 'task',
|
|
129
|
-
title: 'Task',
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
...beans[1],
|
|
133
|
-
status: 'todo',
|
|
134
|
-
priority: 'normal',
|
|
135
|
-
type: 'feature',
|
|
136
|
-
title: 'Feature',
|
|
137
|
-
},
|
|
138
|
-
];
|
|
139
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
140
|
-
expect(sorted[0].type).toBe('feature');
|
|
141
|
-
expect(sorted[1].type).toBe('task');
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('should handle undefined updatedAt timestamps', () => {
|
|
145
|
-
const customBeans = [
|
|
146
|
-
{ ...beans[0], updatedAt: undefined },
|
|
147
|
-
{ ...beans[1], updatedAt: '2026-02-20T10:00:00Z' },
|
|
148
|
-
];
|
|
149
|
-
const sorted = sortBeans(customBeans, 'updated');
|
|
150
|
-
expect(sorted).toHaveLength(2);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('should handle both undefined updatedAt timestamps', () => {
|
|
154
|
-
const customBeans = [
|
|
155
|
-
{ ...beans[0], updatedAt: undefined },
|
|
156
|
-
{ ...beans[1], updatedAt: undefined },
|
|
157
|
-
];
|
|
158
|
-
const sorted = sortBeans(customBeans, 'updated');
|
|
159
|
-
expect(sorted).toHaveLength(2);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('should handle undefined createdAt timestamps', () => {
|
|
163
|
-
const customBeans = [
|
|
164
|
-
{ ...beans[0], createdAt: undefined },
|
|
165
|
-
{ ...beans[1], createdAt: '2026-02-20T10:00:00Z' },
|
|
166
|
-
];
|
|
167
|
-
const sorted = sortBeans(customBeans, 'created');
|
|
168
|
-
expect(sorted).toHaveLength(2);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('should handle both undefined createdAt timestamps', () => {
|
|
172
|
-
const customBeans = [
|
|
173
|
-
{ ...beans[0], createdAt: undefined },
|
|
174
|
-
{ ...beans[1], createdAt: undefined },
|
|
175
|
-
];
|
|
176
|
-
const sorted = sortBeans(customBeans, 'created');
|
|
177
|
-
expect(sorted).toHaveLength(2);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('should use 99 weight for unknown status in comparison', () => {
|
|
181
|
-
const customBeans = [
|
|
182
|
-
{ ...beans[0], status: 'unknown-status', priority: 'high' },
|
|
183
|
-
{ ...beans[1], status: 'todo', priority: 'low' },
|
|
184
|
-
];
|
|
185
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
186
|
-
// Unknown status should sort last (weight 99)
|
|
187
|
-
expect(sorted[1].status).toBe('unknown-status');
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('should use 99 weight for unknown priority in comparison', () => {
|
|
191
|
-
const customBeans = [
|
|
192
|
-
{ ...beans[0], status: 'todo', priority: 'unknown-priority' },
|
|
193
|
-
{ ...beans[1], status: 'todo', priority: 'high' },
|
|
194
|
-
];
|
|
195
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
196
|
-
// Unknown priority should sort last (weight 99)
|
|
197
|
-
expect(sorted[1].priority).toBe('unknown-priority');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('should use 99 weight for unknown type in comparison', () => {
|
|
201
|
-
const customBeans = [
|
|
202
|
-
{ ...beans[0], status: 'todo', priority: 'normal', type: 'unknown-type' },
|
|
203
|
-
{ ...beans[1], status: 'todo', priority: 'normal', type: 'task' },
|
|
204
|
-
];
|
|
205
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
206
|
-
// Unknown type should sort last (weight 99)
|
|
207
|
-
expect(sorted[1].type).toBe('unknown-type');
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('should sort by title when status, priority and type are equal', () => {
|
|
211
|
-
const customBeans = [
|
|
212
|
-
{
|
|
213
|
-
...beans[0],
|
|
214
|
-
status: 'todo',
|
|
215
|
-
priority: 'normal',
|
|
216
|
-
type: 'task',
|
|
217
|
-
title: 'Zebra',
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
...beans[1],
|
|
221
|
-
status: 'todo',
|
|
222
|
-
priority: 'normal',
|
|
223
|
-
type: 'task',
|
|
224
|
-
title: 'Alpha',
|
|
225
|
-
},
|
|
226
|
-
{
|
|
227
|
-
...beans[2],
|
|
228
|
-
status: 'todo',
|
|
229
|
-
priority: 'normal',
|
|
230
|
-
type: 'task',
|
|
231
|
-
title: 'Charlie',
|
|
232
|
-
},
|
|
233
|
-
];
|
|
234
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
235
|
-
expect(sorted[0].title).toBe('Alpha');
|
|
236
|
-
expect(sorted[1].title).toBe('Charlie');
|
|
237
|
-
expect(sorted[2].title).toBe('Zebra');
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('should maintain stable sort when all attributes equal', () => {
|
|
241
|
-
const customBeans = [
|
|
242
|
-
{
|
|
243
|
-
...beans[0],
|
|
244
|
-
status: 'todo',
|
|
245
|
-
priority: 'normal',
|
|
246
|
-
type: 'task',
|
|
247
|
-
title: 'Same',
|
|
248
|
-
id: 'bean-a',
|
|
249
|
-
},
|
|
250
|
-
{
|
|
251
|
-
...beans[1],
|
|
252
|
-
status: 'todo',
|
|
253
|
-
priority: 'normal',
|
|
254
|
-
type: 'task',
|
|
255
|
-
title: 'Same',
|
|
256
|
-
id: 'bean-b',
|
|
257
|
-
},
|
|
258
|
-
];
|
|
259
|
-
const sorted = sortBeans(customBeans, 'status-priority-type-title');
|
|
260
|
-
expect(sorted).toHaveLength(2);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('should handle comparison of same updatedAt timestamps', () => {
|
|
264
|
-
const sameDate = '2026-02-20T10:00:00Z';
|
|
265
|
-
const customBeans = [
|
|
266
|
-
{ ...beans[0], updatedAt: sameDate },
|
|
267
|
-
{ ...beans[1], updatedAt: sameDate },
|
|
268
|
-
];
|
|
269
|
-
const sorted = sortBeans(customBeans, 'updated');
|
|
270
|
-
expect(sorted).toHaveLength(2);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('should handle comparison of same createdAt timestamps', () => {
|
|
274
|
-
const sameDate = '2026-02-20T10:00:00Z';
|
|
275
|
-
const customBeans = [
|
|
276
|
-
{ ...beans[0], createdAt: sameDate },
|
|
277
|
-
{ ...beans[1], createdAt: sameDate },
|
|
278
|
-
];
|
|
279
|
-
const sorted = sortBeans(customBeans, 'created');
|
|
280
|
-
expect(sorted).toHaveLength(2);
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
describe('handleQueryOperation', () => {
|
|
285
|
-
const mockBeans: BeanRecord[] = [
|
|
286
|
-
{
|
|
287
|
-
id: 'bean1',
|
|
288
|
-
slug: 'bean1',
|
|
289
|
-
path: 'bean1.md',
|
|
290
|
-
title: 'Active Task',
|
|
291
|
-
body: 'Content',
|
|
292
|
-
status: 'in-progress',
|
|
293
|
-
type: 'task',
|
|
294
|
-
tags: ['urgent', 'backend'],
|
|
295
|
-
},
|
|
296
|
-
{
|
|
297
|
-
id: 'bean2',
|
|
298
|
-
slug: 'bean2',
|
|
299
|
-
path: 'bean2.md',
|
|
300
|
-
title: 'Completed Feature',
|
|
301
|
-
body: 'Content',
|
|
302
|
-
status: 'completed',
|
|
303
|
-
type: 'feature',
|
|
304
|
-
tags: ['frontend'],
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
id: 'bean3',
|
|
308
|
-
slug: 'bean3',
|
|
309
|
-
path: 'bean3.md',
|
|
310
|
-
title: 'Draft Docs',
|
|
311
|
-
body: 'Content',
|
|
312
|
-
status: 'draft',
|
|
313
|
-
type: 'task',
|
|
314
|
-
tags: ['docs'],
|
|
315
|
-
},
|
|
316
|
-
];
|
|
317
|
-
|
|
318
|
-
const mockBackend = {
|
|
319
|
-
list: vi.fn(async () => mockBeans),
|
|
320
|
-
graphqlSchema: vi.fn(async () => 'schema'),
|
|
321
|
-
openConfig: vi.fn(async () => ({ configPath: '/path/to/config' })),
|
|
322
|
-
writeInstructions: vi.fn(async () => '/path/to/instructions'),
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
it('should handle llm_context operation', async () => {
|
|
326
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
327
|
-
operation: 'llm_context',
|
|
328
|
-
});
|
|
329
|
-
expect(result.content[0].type).toBe('text');
|
|
330
|
-
expect(result.structuredContent.graphqlSchema).toBe('schema');
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('should handle llm_context with writeInstructions', async () => {
|
|
334
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
335
|
-
operation: 'llm_context',
|
|
336
|
-
writeToWorkspaceInstructions: true,
|
|
337
|
-
});
|
|
338
|
-
expect(result.structuredContent.instructionsPath).toBe('/path/to/instructions');
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it('should handle open_config operation', async () => {
|
|
342
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
343
|
-
operation: 'open_config',
|
|
344
|
-
});
|
|
345
|
-
expect(result.structuredContent.configPath).toBe('/path/to/config');
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
it('should handle refresh operation', async () => {
|
|
349
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
350
|
-
operation: 'refresh',
|
|
351
|
-
});
|
|
352
|
-
expect(result.structuredContent.count).toBe(3);
|
|
353
|
-
expect(result.structuredContent.beans).toHaveLength(3);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it('should handle filter operation with statuses', async () => {
|
|
357
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans.filter(b => b.status === 'in-progress'));
|
|
358
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
359
|
-
operation: 'filter',
|
|
360
|
-
statuses: ['in-progress'],
|
|
361
|
-
});
|
|
362
|
-
expect(result.structuredContent.count).toBe(1);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it('should handle filter operation with types', async () => {
|
|
366
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans.filter(b => b.type === 'feature'));
|
|
367
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
368
|
-
operation: 'filter',
|
|
369
|
-
types: ['feature'],
|
|
370
|
-
});
|
|
371
|
-
expect(result.structuredContent.count).toBe(1);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it('should handle filter operation with tags', async () => {
|
|
375
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
376
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
377
|
-
operation: 'filter',
|
|
378
|
-
tags: ['backend'],
|
|
379
|
-
});
|
|
380
|
-
expect(result.structuredContent.beans).toHaveLength(1);
|
|
381
|
-
expect(result.structuredContent.beans[0].id).toBe('bean1');
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it('should handle filter with multiple tags (OR logic)', async () => {
|
|
385
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
386
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
387
|
-
operation: 'filter',
|
|
388
|
-
tags: ['backend', 'frontend'],
|
|
389
|
-
});
|
|
390
|
-
expect(result.structuredContent.beans).toHaveLength(2);
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
it('should handle search operation', async () => {
|
|
394
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
395
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
396
|
-
operation: 'search',
|
|
397
|
-
search: 'active',
|
|
398
|
-
});
|
|
399
|
-
expect(result.structuredContent.beans).toHaveLength(1);
|
|
400
|
-
expect(result.structuredContent.beans[0].id).toBe('bean1');
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it('should handle search by id', async () => {
|
|
404
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
405
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
406
|
-
operation: 'search',
|
|
407
|
-
search: 'bean2',
|
|
408
|
-
});
|
|
409
|
-
expect(result.structuredContent.beans).toHaveLength(1);
|
|
410
|
-
expect(result.structuredContent.beans[0].id).toBe('bean2');
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it('should handle search by tags', async () => {
|
|
414
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
415
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
416
|
-
operation: 'search',
|
|
417
|
-
search: 'backend',
|
|
418
|
-
});
|
|
419
|
-
expect(result.structuredContent.beans).toHaveLength(1);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it('should filter out closed beans in search when includeClosed is false', async () => {
|
|
423
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
424
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
425
|
-
operation: 'search',
|
|
426
|
-
includeClosed: false,
|
|
427
|
-
});
|
|
428
|
-
expect(result.structuredContent.beans).toHaveLength(2);
|
|
429
|
-
expect(
|
|
430
|
-
(result.structuredContent.beans as BeanRecord[]).every(b => b.status !== 'completed' && b.status !== 'scrapped'),
|
|
431
|
-
).toBe(true);
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it('should handle sort operation with custom mode', async () => {
|
|
435
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
436
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
437
|
-
operation: 'sort',
|
|
438
|
-
mode: 'id',
|
|
439
|
-
});
|
|
440
|
-
expect(result.structuredContent.beans[0].id).toBe('bean1');
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
it('should use default sort mode when not specified', async () => {
|
|
444
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
445
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
446
|
-
operation: 'sort',
|
|
447
|
-
mode: 'status-priority-type-title',
|
|
448
|
-
});
|
|
449
|
-
expect(result.structuredContent.mode).toBe('status-priority-type-title');
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it('should handle empty search string', async () => {
|
|
453
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
454
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
455
|
-
operation: 'search',
|
|
456
|
-
search: '',
|
|
457
|
-
});
|
|
458
|
-
expect(result.structuredContent.beans).toHaveLength(3);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it('should handle case-insensitive search', async () => {
|
|
462
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
463
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
464
|
-
operation: 'search',
|
|
465
|
-
search: 'ACTIVE',
|
|
466
|
-
});
|
|
467
|
-
expect(result.structuredContent.beans).toHaveLength(1);
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
it('should handle null tags gracefully', async () => {
|
|
471
|
-
const beansWithoutTags = mockBeans.map(b => ({ ...b, tags: undefined }));
|
|
472
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => beansWithoutTags);
|
|
473
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
474
|
-
operation: 'filter',
|
|
475
|
-
tags: ['some-tag'],
|
|
476
|
-
});
|
|
477
|
-
expect(result.structuredContent.beans).toHaveLength(0);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it('should handle null statuses parameter', async () => {
|
|
481
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
482
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
483
|
-
operation: 'filter',
|
|
484
|
-
statuses: null,
|
|
485
|
-
});
|
|
486
|
-
expect(result.structuredContent.beans).toHaveLength(3);
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
it('should handle null types parameter', async () => {
|
|
490
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
491
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
492
|
-
operation: 'filter',
|
|
493
|
-
types: null,
|
|
494
|
-
});
|
|
495
|
-
expect(result.structuredContent.beans).toHaveLength(3);
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it('should handle search with non-string search parameter', async () => {
|
|
499
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
500
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
501
|
-
operation: 'search',
|
|
502
|
-
search: undefined as any,
|
|
503
|
-
});
|
|
504
|
-
expect(result.structuredContent.beans).toHaveLength(3);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
it('should include closed beans in search by default', async () => {
|
|
508
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
509
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
510
|
-
operation: 'search',
|
|
511
|
-
includeClosed: true,
|
|
512
|
-
});
|
|
513
|
-
expect(result.structuredContent.beans).toHaveLength(3);
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
it('should handle search without includeClosed filter', async () => {
|
|
517
|
-
vi.mocked(mockBackend.list).mockImplementationOnce(async () => mockBeans);
|
|
518
|
-
const result = await handleQueryOperation(mockBackend, {
|
|
519
|
-
operation: 'search',
|
|
520
|
-
search: 'task',
|
|
521
|
-
});
|
|
522
|
-
expect((result.structuredContent.beans as BeanRecord[]).length).toBeGreaterThan(0);
|
|
523
|
-
});
|
|
524
|
-
});
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for startBeansMcpServer.
|
|
3
|
-
*
|
|
4
|
-
* Dynamic imports of BeansCliBackend and StdioServerTransport are mocked so
|
|
5
|
-
* that the function can run without a real beans CLI or real stdio. A minimal
|
|
6
|
-
* mock transport satisfies server.connect(); the _resolveRoots test-seam
|
|
7
|
-
* parameter is used to exercise the setInner branch without needing a live
|
|
8
|
-
* MCP client to respond to roots/list.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Module mocks — hoisted by vitest before any imports
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
vi.mock('../server/backend', () => {
|
|
18
|
-
// Must be a regular function (not an arrow function) so `new BeansCliBackend()` works.
|
|
19
|
-
function BeansCliBackend(this: unknown) {
|
|
20
|
-
return {
|
|
21
|
-
init: vi.fn(async () => ({ initialized: true })),
|
|
22
|
-
list: vi.fn(async () => []),
|
|
23
|
-
create: vi.fn(async () => ({
|
|
24
|
-
id: 'b1',
|
|
25
|
-
slug: 'b1',
|
|
26
|
-
path: 'b1.md',
|
|
27
|
-
title: 'T',
|
|
28
|
-
body: '',
|
|
29
|
-
status: 'draft',
|
|
30
|
-
type: 'task',
|
|
31
|
-
})),
|
|
32
|
-
update: vi.fn(async () => ({
|
|
33
|
-
id: 'b1',
|
|
34
|
-
slug: 'b1',
|
|
35
|
-
path: 'b1.md',
|
|
36
|
-
title: 'T',
|
|
37
|
-
body: '',
|
|
38
|
-
status: 'todo',
|
|
39
|
-
type: 'task',
|
|
40
|
-
})),
|
|
41
|
-
delete: vi.fn(async () => ({ deleted: true })),
|
|
42
|
-
openConfig: vi.fn(async () => ({ configPath: '/cfg', content: '{}' })),
|
|
43
|
-
graphqlSchema: vi.fn(async () => ''),
|
|
44
|
-
readOutputLog: vi.fn(async () => ({ path: '/log', content: '', linesReturned: 0 })),
|
|
45
|
-
readBeanFile: vi.fn(async () => ({ path: '/f', content: '' })),
|
|
46
|
-
editBeanFile: vi.fn(async () => ({ path: '/f', bytes: 0 })),
|
|
47
|
-
createBeanFile: vi.fn(async () => ({ path: '/f', bytes: 0, created: true })),
|
|
48
|
-
deleteBeanFile: vi.fn(async () => ({ path: '/f', deleted: true })),
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
return { BeansCliBackend: vi.fn(BeansCliBackend) };
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => {
|
|
55
|
-
// Must be a regular function so `new StdioServerTransport()` works.
|
|
56
|
-
function StdioServerTransport(this: unknown) {
|
|
57
|
-
return {
|
|
58
|
-
start: vi.fn(async () => {}),
|
|
59
|
-
send: vi.fn(async () => {}),
|
|
60
|
-
close: vi.fn(async () => {}),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return { StdioServerTransport: vi.fn(StdioServerTransport) };
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Tests
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
describe('startBeansMcpServer', () => {
|
|
71
|
-
it('starts without error when an explicit workspace root is given', async () => {
|
|
72
|
-
const { startBeansMcpServer } = await import('../server/BeansMcpServer');
|
|
73
|
-
await expect(startBeansMcpServer(['--workspace-root', '/my/workspace'])).resolves.toBeUndefined();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('creates StdioServerTransport and connects', async () => {
|
|
77
|
-
const { startBeansMcpServer } = await import('../server/BeansMcpServer');
|
|
78
|
-
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
79
|
-
vi.mocked(StdioServerTransport).mockClear();
|
|
80
|
-
|
|
81
|
-
await startBeansMcpServer(['/some/workspace']);
|
|
82
|
-
|
|
83
|
-
expect(vi.mocked(StdioServerTransport)).toHaveBeenCalledTimes(1);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('sets BEANS_MCP_PORT env vars from --port', async () => {
|
|
87
|
-
const { startBeansMcpServer } = await import('../server/BeansMcpServer');
|
|
88
|
-
|
|
89
|
-
// Use an explicit workspace so resolveWorkspaceFromRoots is not called
|
|
90
|
-
// (the mock transport has no client and would hang waiting for a response).
|
|
91
|
-
await startBeansMcpServer(['--workspace-root', '/w', '--port', '12345']);
|
|
92
|
-
|
|
93
|
-
expect(process.env.BEANS_MCP_PORT).toBe('12345');
|
|
94
|
-
expect(process.env.BEANS_VSCODE_MCP_PORT).toBe('12345');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('initialises BeansCliBackend with the explicit workspace root', async () => {
|
|
98
|
-
const { startBeansMcpServer } = await import('../server/BeansMcpServer');
|
|
99
|
-
const { BeansCliBackend } = await import('../server/backend');
|
|
100
|
-
vi.mocked(BeansCliBackend).mockClear();
|
|
101
|
-
|
|
102
|
-
await startBeansMcpServer(['/explicit/root']);
|
|
103
|
-
|
|
104
|
-
// First call: initial backend; explicit flag prevents a second call for setInner.
|
|
105
|
-
expect(vi.mocked(BeansCliBackend)).toHaveBeenCalledWith('/explicit/root', 'beans', expect.any(String));
|
|
106
|
-
expect(vi.mocked(BeansCliBackend)).toHaveBeenCalledTimes(1);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('skips roots resolution when workspaceExplicit is true', async () => {
|
|
110
|
-
const { startBeansMcpServer } = await import('../server/BeansMcpServer');
|
|
111
|
-
const resolver = vi.fn(async () => '/should-not-be-used');
|
|
112
|
-
|
|
113
|
-
await startBeansMcpServer(['--workspace-root', '/explicit'], resolver);
|
|
114
|
-
|
|
115
|
-
expect(resolver).not.toHaveBeenCalled();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('calls resolver and calls setInner when resolver returns a path', async () => {
|
|
119
|
-
const { startBeansMcpServer } = await import('../server/BeansMcpServer');
|
|
120
|
-
const { BeansCliBackend } = await import('../server/backend');
|
|
121
|
-
vi.mocked(BeansCliBackend).mockClear();
|
|
122
|
-
|
|
123
|
-
const resolver = vi.fn(async () => '/roots/detected/workspace');
|
|
124
|
-
|
|
125
|
-
// No explicit workspace → resolver is invoked; non-null rootPath → setInner is called.
|
|
126
|
-
await startBeansMcpServer([], resolver);
|
|
127
|
-
|
|
128
|
-
expect(resolver).toHaveBeenCalledTimes(1);
|
|
129
|
-
// setInner creates a new BeansCliBackend with the discovered path.
|
|
130
|
-
expect(vi.mocked(BeansCliBackend)).toHaveBeenLastCalledWith('/roots/detected/workspace', 'beans');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('does not call setInner when resolver returns null', async () => {
|
|
134
|
-
const { startBeansMcpServer } = await import('../server/BeansMcpServer');
|
|
135
|
-
const { BeansCliBackend } = await import('../server/backend');
|
|
136
|
-
vi.mocked(BeansCliBackend).mockClear();
|
|
137
|
-
|
|
138
|
-
const resolver = vi.fn(async () => null);
|
|
139
|
-
|
|
140
|
-
await startBeansMcpServer([], resolver);
|
|
141
|
-
|
|
142
|
-
expect(resolver).toHaveBeenCalledTimes(1);
|
|
143
|
-
// Only the initial BeansCliBackend call; no second call from setInner.
|
|
144
|
-
expect(vi.mocked(BeansCliBackend)).toHaveBeenCalledTimes(1);
|
|
145
|
-
});
|
|
146
|
-
});
|