@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.
Files changed (44) hide show
  1. package/{dist/beans-mcp-server.cjs → beans-mcp-server.cjs} +8 -4
  2. package/{dist/index.cjs → index.cjs} +8 -4
  3. package/{dist/index.d.ts → index.d.ts} +2 -1
  4. package/{dist/index.js → index.js} +8 -4
  5. package/package.json +27 -64
  6. package/.beans.yml +0 -6
  7. package/.claude/settings.local.json +0 -18
  8. package/.editorconfig +0 -13
  9. package/.github/workflows/release.yml +0 -235
  10. package/.github/workflows/test.yml +0 -80
  11. package/.husky/pre-commit +0 -1
  12. package/.nvmrc +0 -1
  13. package/.oxfmtrc.json +0 -11
  14. package/.oxlintrc.json +0 -37
  15. package/.vscode/settings.json +0 -3
  16. package/CHANGELOG.md +0 -140
  17. package/CONTRIBUTING.md +0 -139
  18. package/dist/README.md +0 -307
  19. package/dist/beans-mcp-server.cjs.map +0 -1
  20. package/dist/index.cjs.map +0 -1
  21. package/dist/index.js.map +0 -1
  22. package/dist/package.json +0 -43
  23. package/pnpm-workspace.yaml +0 -2
  24. package/scripts/release.js +0 -433
  25. package/scripts/write-dist-package.js +0 -53
  26. package/src/cli.ts +0 -14
  27. package/src/index.ts +0 -21
  28. package/src/internal/graphql.ts +0 -33
  29. package/src/internal/queryHelpers.ts +0 -157
  30. package/src/server/BeansMcpServer.ts +0 -600
  31. package/src/server/backend.ts +0 -358
  32. package/src/test/BeansMcpServer.test.ts +0 -514
  33. package/src/test/handlers.unit.test.ts +0 -184
  34. package/src/test/parseCliArgs.test.ts +0 -69
  35. package/src/test/protocol.e2e.test.ts +0 -884
  36. package/src/test/queryHelpers.test.ts +0 -524
  37. package/src/test/startBeansMcpServer.test.ts +0 -146
  38. package/src/test/tools-integration.test.ts +0 -912
  39. package/src/test/utils.test.ts +0 -80
  40. package/src/types.ts +0 -46
  41. package/src/utils.ts +0 -20
  42. package/tsconfig.json +0 -24
  43. package/tsup.config.ts +0 -42
  44. package/vitest.config.ts +0 -18
@@ -1,884 +0,0 @@
1
- /**
2
- * Protocol-level E2E tests for the MCP server.
3
- *
4
- * These tests spin up a real McpServer connected to a real MCP Client via
5
- * InMemoryTransport. Unlike the unit tests, which call backend methods or
6
- * handler functions directly, these tests exercise the full stack:
7
- *
8
- * Client → MCP JSON-RPC → Zod input validation → handler → backend mock
9
- * → MCP JSON-RPC response → Client
10
- *
11
- * This ensures:
12
- * - All tools are registered with the correct names and schemas
13
- * - Zod validation rejects invalid inputs before they reach the backend
14
- * - Responses conform to the MCP wire format (content array + isError flag)
15
- * - Tool handler errors surface as { isError: true } tool results
16
- * - Zod schema violations also surface as { isError: true } tool results
17
- * (the MCP SDK wraps -32602 validation errors as tool-level errors)
18
- */
19
-
20
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
21
- import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
22
- import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
23
- import { describe, expect, it, vi } from 'vitest';
24
- import { createBeansMcpServer } from '../server/BeansMcpServer';
25
- import type { BackendInterface } from '../server/backend';
26
- import type { BeanRecord } from '../types';
27
-
28
- // ---------------------------------------------------------------------------
29
- // Helpers
30
- // ---------------------------------------------------------------------------
31
-
32
- const BEAN: BeanRecord = {
33
- id: 'bean-1',
34
- slug: 'bean-1',
35
- path: 'bean-1.md',
36
- title: 'Fix the thing',
37
- body: '## Details\n\nSome content.',
38
- status: 'todo',
39
- type: 'task',
40
- priority: 'normal',
41
- tags: ['backend'],
42
- createdAt: '2025-01-01T00:00:00Z',
43
- updatedAt: '2025-01-02T00:00:00Z',
44
- };
45
-
46
- function makeBackend(overrides: Partial<BackendInterface> = {}): BackendInterface {
47
- return {
48
- init: vi.fn(async () => ({ initialized: true })),
49
- list: vi.fn(async () => [BEAN]),
50
- create: vi.fn(async input => ({ ...BEAN, id: 'new-bean', title: input.title, type: input.type })),
51
- update: vi.fn(async (id, updates) => ({ ...BEAN, id, ...updates })),
52
- delete: vi.fn(async () => ({ deleted: true, beanId: BEAN.id })),
53
- openConfig: vi.fn(async () => ({ configPath: '/ws/.beans.yml', content: 'prefix: proj' })),
54
- graphqlSchema: vi.fn(async () => 'type Query { beans: [Bean] }'),
55
- readOutputLog: vi.fn(async () => ({ path: '/log.txt', content: 'line1\nline2', linesReturned: 2 })),
56
- readBeanFile: vi.fn(async path => ({ path, content: '---\ntitle: Test\n---\n' })),
57
- editBeanFile: vi.fn(async (path, content) => ({ path, bytes: Buffer.byteLength(content, 'utf8') })),
58
- createBeanFile: vi.fn(async (path, content) => ({
59
- path,
60
- bytes: Buffer.byteLength(content, 'utf8'),
61
- created: true,
62
- })),
63
- deleteBeanFile: vi.fn(async path => ({ path, deleted: true })),
64
- ...overrides,
65
- };
66
- }
67
-
68
- /** Boot a real server + client pair over InMemoryTransport. */
69
- async function bootClient(backend: BackendInterface): Promise<{ client: Client; cleanup: () => Promise<void> }> {
70
- const { server } = await createBeansMcpServer({ workspaceRoot: '/ws', backend });
71
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
72
-
73
- await server.connect(serverTransport);
74
-
75
- const client = new Client({ name: 'test-client', version: '0.0.1' });
76
- await client.connect(clientTransport);
77
-
78
- return {
79
- client,
80
- cleanup: async () => {
81
- await client.close();
82
- },
83
- };
84
- }
85
-
86
- type TextContent = { type: 'text'; text: string };
87
-
88
- /** Assert a tool call succeeded and parse the first content item as JSON. */
89
- function parseResult(result: Awaited<ReturnType<Client['callTool']>>): unknown {
90
- expect(result.isError).toBeFalsy();
91
- const items = result.content as TextContent[];
92
- expect(items.length).toBeGreaterThan(0);
93
- expect(items[0].type).toBe('text');
94
- return JSON.parse(items[0].text);
95
- }
96
-
97
- /** Assert a tool call produced a validation/tool error (isError: true). */
98
- async function expectError(promise: Promise<Awaited<ReturnType<Client['callTool']>>>): Promise<void> {
99
- const result = await promise;
100
- expect(result.isError).toBe(true);
101
- const items = result.content as TextContent[];
102
- expect(items.length).toBeGreaterThan(0);
103
- expect(items[0].text.length).toBeGreaterThan(0);
104
- }
105
-
106
- // ---------------------------------------------------------------------------
107
- // Tool registration
108
- // ---------------------------------------------------------------------------
109
-
110
- describe('tool registration', () => {
111
- it('registers all expected tools', async () => {
112
- const { client, cleanup } = await bootClient(makeBackend());
113
- try {
114
- const { tools } = await client.listTools();
115
- const names = tools.map(t => t.name);
116
-
117
- expect(names).toContain('beans_init');
118
- expect(names).toContain('beans_view');
119
- expect(names).toContain('beans_create');
120
- expect(names).toContain('beans_edit');
121
- expect(names).toContain('beans_update');
122
- expect(names).toContain('beans_reopen');
123
- expect(names).toContain('beans_delete');
124
- expect(names).toContain('beans_query');
125
- expect(names).toContain('beans_bean_file');
126
- expect(names).toContain('beans_output');
127
- } finally {
128
- await cleanup();
129
- }
130
- });
131
-
132
- it('all tools have titles', async () => {
133
- const { client, cleanup } = await bootClient(makeBackend());
134
- try {
135
- const { tools } = await client.listTools();
136
- for (const tool of tools) {
137
- expect(tool.title, `${tool.name} should have a title`).toBeTruthy();
138
- }
139
- } finally {
140
- await cleanup();
141
- }
142
- });
143
-
144
- it('all tools have descriptions', async () => {
145
- const { client, cleanup } = await bootClient(makeBackend());
146
- try {
147
- const { tools } = await client.listTools();
148
- for (const tool of tools) {
149
- expect(tool.description, `${tool.name} should have a description`).toBeTruthy();
150
- }
151
- } finally {
152
- await cleanup();
153
- }
154
- });
155
- });
156
-
157
- // ---------------------------------------------------------------------------
158
- // beans_init
159
- // ---------------------------------------------------------------------------
160
-
161
- describe('beans_init', () => {
162
- it('calls backend.init and returns initialized: true', async () => {
163
- const backend = makeBackend();
164
- const { client, cleanup } = await bootClient(backend);
165
- try {
166
- const result = await client.callTool({ name: 'beans_init', arguments: {} });
167
- const data = parseResult(result) as { initialized: boolean };
168
- expect(data.initialized).toBe(true);
169
- expect(backend.init).toHaveBeenCalledWith(undefined);
170
- } finally {
171
- await cleanup();
172
- }
173
- });
174
-
175
- it('passes prefix to backend.init', async () => {
176
- const backend = makeBackend();
177
- const { client, cleanup } = await bootClient(backend);
178
- try {
179
- await client.callTool({ name: 'beans_init', arguments: { prefix: 'proj' } });
180
- expect(backend.init).toHaveBeenCalledWith('proj');
181
- } finally {
182
- await cleanup();
183
- }
184
- });
185
-
186
- it('rejects prefix longer than 32 characters', async () => {
187
- const { client, cleanup } = await bootClient(makeBackend());
188
- try {
189
- await expectError(client.callTool({ name: 'beans_init', arguments: { prefix: 'x'.repeat(33) } }));
190
- } finally {
191
- await cleanup();
192
- }
193
- });
194
- });
195
-
196
- // ---------------------------------------------------------------------------
197
- // beans_view
198
- // ---------------------------------------------------------------------------
199
-
200
- describe('beans_view', () => {
201
- it('returns full bean details', async () => {
202
- const { client, cleanup } = await bootClient(makeBackend());
203
- try {
204
- const result = await client.callTool({ name: 'beans_view', arguments: { beanId: 'bean-1' } });
205
- const data = parseResult(result) as { bean: BeanRecord };
206
- expect(data.bean.id).toBe('bean-1');
207
- expect(data.bean.title).toBe('Fix the thing');
208
- } finally {
209
- await cleanup();
210
- }
211
- });
212
-
213
- it('returns isError when bean not found', async () => {
214
- const backend = makeBackend({ list: vi.fn(async () => []) });
215
- const { client, cleanup } = await bootClient(backend);
216
- try {
217
- await expectError(client.callTool({ name: 'beans_view', arguments: { beanId: 'missing' } }));
218
- } finally {
219
- await cleanup();
220
- }
221
- });
222
-
223
- it('rejects empty beanId', async () => {
224
- const { client, cleanup } = await bootClient(makeBackend());
225
- try {
226
- await expectError(client.callTool({ name: 'beans_view', arguments: { beanId: '' } }));
227
- } finally {
228
- await cleanup();
229
- }
230
- });
231
-
232
- it('rejects beanId longer than MAX_ID_LENGTH (128)', async () => {
233
- const { client, cleanup } = await bootClient(makeBackend());
234
- try {
235
- await expectError(client.callTool({ name: 'beans_view', arguments: { beanId: 'x'.repeat(129) } }));
236
- } finally {
237
- await cleanup();
238
- }
239
- });
240
- });
241
-
242
- // ---------------------------------------------------------------------------
243
- // beans_create
244
- // ---------------------------------------------------------------------------
245
-
246
- describe('beans_create', () => {
247
- it('creates a bean with required fields', async () => {
248
- const backend = makeBackend();
249
- const { client, cleanup } = await bootClient(backend);
250
- try {
251
- const result = await client.callTool({
252
- name: 'beans_create',
253
- arguments: { title: 'New task', type: 'task' },
254
- });
255
- const data = parseResult(result) as { bean: BeanRecord };
256
- expect(data.bean.title).toBe('New task');
257
- expect(data.bean.type).toBe('task');
258
- expect(backend.create).toHaveBeenCalledWith(expect.objectContaining({ title: 'New task', type: 'task' }));
259
- } finally {
260
- await cleanup();
261
- }
262
- });
263
-
264
- it('passes optional fields to backend', async () => {
265
- const backend = makeBackend();
266
- const { client, cleanup } = await bootClient(backend);
267
- try {
268
- await client.callTool({
269
- name: 'beans_create',
270
- arguments: { title: 'T', type: 'bug', status: 'todo', priority: 'high', description: 'desc' },
271
- });
272
- expect(backend.create).toHaveBeenCalledWith(
273
- expect.objectContaining({ status: 'todo', priority: 'high', description: 'desc' }),
274
- );
275
- } finally {
276
- await cleanup();
277
- }
278
- });
279
-
280
- it('rejects missing title', async () => {
281
- const { client, cleanup } = await bootClient(makeBackend());
282
- try {
283
- await expectError(client.callTool({ name: 'beans_create', arguments: { type: 'task' } }));
284
- } finally {
285
- await cleanup();
286
- }
287
- });
288
-
289
- it('rejects empty title', async () => {
290
- const { client, cleanup } = await bootClient(makeBackend());
291
- try {
292
- await expectError(client.callTool({ name: 'beans_create', arguments: { title: '', type: 'task' } }));
293
- } finally {
294
- await cleanup();
295
- }
296
- });
297
-
298
- it('rejects missing type', async () => {
299
- const { client, cleanup } = await bootClient(makeBackend());
300
- try {
301
- await expectError(client.callTool({ name: 'beans_create', arguments: { title: 'T' } }));
302
- } finally {
303
- await cleanup();
304
- }
305
- });
306
-
307
- it('rejects title exceeding MAX_TITLE_LENGTH (1024)', async () => {
308
- const { client, cleanup } = await bootClient(makeBackend());
309
- try {
310
- await expectError(
311
- client.callTool({ name: 'beans_create', arguments: { title: 'x'.repeat(1025), type: 'task' } }),
312
- );
313
- } finally {
314
- await cleanup();
315
- }
316
- });
317
- });
318
-
319
- // ---------------------------------------------------------------------------
320
- // beans_update / beans_edit
321
- // ---------------------------------------------------------------------------
322
-
323
- describe('beans_update', () => {
324
- it('updates a bean status', async () => {
325
- const backend = makeBackend();
326
- const { client, cleanup } = await bootClient(backend);
327
- try {
328
- const result = await client.callTool({
329
- name: 'beans_update',
330
- arguments: { beanId: 'bean-1', status: 'in-progress' },
331
- });
332
- const data = parseResult(result) as { bean: BeanRecord };
333
- expect(data.bean.id).toBe('bean-1');
334
- expect(backend.update).toHaveBeenCalledWith('bean-1', expect.objectContaining({ status: 'in-progress' }));
335
- } finally {
336
- await cleanup();
337
- }
338
- });
339
-
340
- it('passes blocking and blockedBy arrays', async () => {
341
- const backend = makeBackend();
342
- const { client, cleanup } = await bootClient(backend);
343
- try {
344
- await client.callTool({
345
- name: 'beans_update',
346
- arguments: { beanId: 'bean-1', blocking: ['bean-2'], blockedBy: ['bean-3'] },
347
- });
348
- expect(backend.update).toHaveBeenCalledWith(
349
- 'bean-1',
350
- expect.objectContaining({ blocking: ['bean-2'], blockedBy: ['bean-3'] }),
351
- );
352
- } finally {
353
- await cleanup();
354
- }
355
- });
356
-
357
- it('rejects missing beanId', async () => {
358
- const { client, cleanup } = await bootClient(makeBackend());
359
- try {
360
- await expectError(client.callTool({ name: 'beans_update', arguments: { status: 'todo' } }));
361
- } finally {
362
- await cleanup();
363
- }
364
- });
365
-
366
- it('surfaces backend errors as isError result', async () => {
367
- const backend = makeBackend({
368
- update: vi.fn(async () => {
369
- throw new Error('update failed');
370
- }),
371
- });
372
- const { client, cleanup } = await bootClient(backend);
373
- try {
374
- await expectError(client.callTool({ name: 'beans_update', arguments: { beanId: 'bean-1', status: 'todo' } }));
375
- } finally {
376
- await cleanup();
377
- }
378
- });
379
- });
380
-
381
- describe('beans_edit', () => {
382
- it('is registered and works identically to beans_update', async () => {
383
- const backend = makeBackend();
384
- const { client, cleanup } = await bootClient(backend);
385
- try {
386
- const result = await client.callTool({
387
- name: 'beans_edit',
388
- arguments: { beanId: 'bean-1', type: 'feature' },
389
- });
390
- const data = parseResult(result) as { bean: BeanRecord };
391
- expect(data.bean.id).toBe('bean-1');
392
- } finally {
393
- await cleanup();
394
- }
395
- });
396
- });
397
-
398
- // ---------------------------------------------------------------------------
399
- // beans_reopen
400
- // ---------------------------------------------------------------------------
401
-
402
- describe('beans_reopen', () => {
403
- it('reopens a completed bean to todo', async () => {
404
- const completedBean = { ...BEAN, status: 'completed' };
405
- const backend = makeBackend({ list: vi.fn(async () => [completedBean]) });
406
- const { client, cleanup } = await bootClient(backend);
407
- try {
408
- const result = await client.callTool({
409
- name: 'beans_reopen',
410
- arguments: { beanId: 'bean-1', requiredCurrentStatus: 'completed', targetStatus: 'todo' },
411
- });
412
- expect(result.isError).toBeFalsy();
413
- expect(backend.update).toHaveBeenCalledWith('bean-1', { status: 'todo' });
414
- } finally {
415
- await cleanup();
416
- }
417
- });
418
-
419
- it('returns isError if bean status does not match requiredCurrentStatus', async () => {
420
- // BEAN.status is 'todo', not 'completed'
421
- const { client, cleanup } = await bootClient(makeBackend());
422
- try {
423
- await expectError(
424
- client.callTool({
425
- name: 'beans_reopen',
426
- arguments: { beanId: 'bean-1', requiredCurrentStatus: 'completed', targetStatus: 'todo' },
427
- }),
428
- );
429
- } finally {
430
- await cleanup();
431
- }
432
- });
433
-
434
- it('rejects unknown requiredCurrentStatus values', async () => {
435
- const { client, cleanup } = await bootClient(makeBackend());
436
- try {
437
- await expectError(
438
- client.callTool({
439
- name: 'beans_reopen',
440
- arguments: { beanId: 'bean-1', requiredCurrentStatus: 'todo', targetStatus: 'draft' },
441
- }),
442
- );
443
- } finally {
444
- await cleanup();
445
- }
446
- });
447
- });
448
-
449
- // ---------------------------------------------------------------------------
450
- // beans_delete
451
- // ---------------------------------------------------------------------------
452
-
453
- describe('beans_delete', () => {
454
- it('deletes a draft bean without force', async () => {
455
- const draftBean = { ...BEAN, status: 'draft' };
456
- const backend = makeBackend({ list: vi.fn(async () => [draftBean]) });
457
- const { client, cleanup } = await bootClient(backend);
458
- try {
459
- const result = await client.callTool({
460
- name: 'beans_delete',
461
- arguments: { beanId: 'bean-1', force: false },
462
- });
463
- const data = parseResult(result) as { deleted: boolean };
464
- expect(data.deleted).toBe(true);
465
- } finally {
466
- await cleanup();
467
- }
468
- });
469
-
470
- it('refuses to delete a non-draft/non-scrapped bean without force', async () => {
471
- // BEAN.status is 'todo'
472
- const { client, cleanup } = await bootClient(makeBackend());
473
- try {
474
- await expectError(
475
- client.callTool({
476
- name: 'beans_delete',
477
- arguments: { beanId: 'bean-1', force: false },
478
- }),
479
- );
480
- } finally {
481
- await cleanup();
482
- }
483
- });
484
-
485
- it('deletes any bean with force=true', async () => {
486
- const backend = makeBackend(); // BEAN.status = 'todo'
487
- const { client, cleanup } = await bootClient(backend);
488
- try {
489
- const result = await client.callTool({
490
- name: 'beans_delete',
491
- arguments: { beanId: 'bean-1', force: true },
492
- });
493
- const data = parseResult(result) as { deleted: boolean };
494
- expect(data.deleted).toBe(true);
495
- expect(backend.delete).toHaveBeenCalledWith('bean-1');
496
- } finally {
497
- await cleanup();
498
- }
499
- });
500
-
501
- it('defaults force to false — refuses non-draft bean when force omitted', async () => {
502
- // BEAN is 'todo'; force defaults to false
503
- const { client, cleanup } = await bootClient(makeBackend());
504
- try {
505
- await expectError(client.callTool({ name: 'beans_delete', arguments: { beanId: 'bean-1' } }));
506
- } finally {
507
- await cleanup();
508
- }
509
- });
510
- });
511
-
512
- // ---------------------------------------------------------------------------
513
- // beans_query
514
- // ---------------------------------------------------------------------------
515
-
516
- describe('beans_query', () => {
517
- it('refresh returns all beans', async () => {
518
- const backend = makeBackend();
519
- const { client, cleanup } = await bootClient(backend);
520
- try {
521
- const result = await client.callTool({ name: 'beans_query', arguments: { operation: 'refresh' } });
522
- const data = parseResult(result) as { count: number; beans: BeanRecord[] };
523
- expect(data.count).toBe(1);
524
- expect(data.beans[0].id).toBe('bean-1');
525
- } finally {
526
- await cleanup();
527
- }
528
- });
529
-
530
- it('filter passes statuses and types to backend.list', async () => {
531
- const backend = makeBackend();
532
- const { client, cleanup } = await bootClient(backend);
533
- try {
534
- await client.callTool({
535
- name: 'beans_query',
536
- arguments: { operation: 'filter', statuses: ['todo'], types: ['task'] },
537
- });
538
- expect(backend.list).toHaveBeenCalledWith(expect.objectContaining({ status: ['todo'], type: ['task'] }));
539
- } finally {
540
- await cleanup();
541
- }
542
- });
543
-
544
- it('search passes query to backend.list and filters client-side', async () => {
545
- const backend = makeBackend();
546
- const { client, cleanup } = await bootClient(backend);
547
- try {
548
- const result = await client.callTool({
549
- name: 'beans_query',
550
- arguments: { operation: 'search', search: 'fix' },
551
- });
552
- const data = parseResult(result) as { query: string; count: number };
553
- expect(data.query).toBe('fix');
554
- expect(data.count).toBe(1);
555
- } finally {
556
- await cleanup();
557
- }
558
- });
559
-
560
- it('search with includeClosed=false excludes completed/scrapped beans', async () => {
561
- const beans = [BEAN, { ...BEAN, id: 'bean-2', title: 'fix closed', status: 'completed' }];
562
- const backend = makeBackend({ list: vi.fn(async () => beans) });
563
- const { client, cleanup } = await bootClient(backend);
564
- try {
565
- const result = await client.callTool({
566
- name: 'beans_query',
567
- arguments: { operation: 'search', search: 'fix', includeClosed: false },
568
- });
569
- const data = parseResult(result) as { count: number; beans: BeanRecord[] };
570
- expect(data.beans.every(b => b.status !== 'completed' && b.status !== 'scrapped')).toBe(true);
571
- } finally {
572
- await cleanup();
573
- }
574
- });
575
-
576
- it('sort returns beans in the requested order', async () => {
577
- const beans = [
578
- { ...BEAN, id: 'bean-a', status: 'completed', updatedAt: '2025-01-01T00:00:00Z' },
579
- { ...BEAN, id: 'bean-b', status: 'todo', updatedAt: '2025-01-03T00:00:00Z' },
580
- ];
581
- const backend = makeBackend({ list: vi.fn(async () => beans) });
582
- const { client, cleanup } = await bootClient(backend);
583
- try {
584
- const result = await client.callTool({
585
- name: 'beans_query',
586
- arguments: { operation: 'sort', mode: 'updated' },
587
- });
588
- const data = parseResult(result) as { beans: BeanRecord[] };
589
- expect(data.beans[0].id).toBe('bean-b'); // most recently updated first
590
- } finally {
591
- await cleanup();
592
- }
593
- });
594
-
595
- it('defaults operation to refresh', async () => {
596
- const backend = makeBackend();
597
- const { client, cleanup } = await bootClient(backend);
598
- try {
599
- const result = await client.callTool({ name: 'beans_query', arguments: {} });
600
- const data = parseResult(result) as { beans: BeanRecord[] };
601
- expect(Array.isArray(data.beans)).toBe(true);
602
- } finally {
603
- await cleanup();
604
- }
605
- });
606
-
607
- it('rejects unknown operation value', async () => {
608
- const { client, cleanup } = await bootClient(makeBackend());
609
- try {
610
- await expectError(client.callTool({ name: 'beans_query', arguments: { operation: 'noop' } }));
611
- } finally {
612
- await cleanup();
613
- }
614
- });
615
- });
616
-
617
- // ---------------------------------------------------------------------------
618
- // beans_bean_file
619
- // ---------------------------------------------------------------------------
620
-
621
- describe('beans_bean_file', () => {
622
- it('read returns file content', async () => {
623
- const backend = makeBackend();
624
- const { client, cleanup } = await bootClient(backend);
625
- try {
626
- const result = await client.callTool({
627
- name: 'beans_bean_file',
628
- arguments: { operation: 'read', path: 'bean-1.md' },
629
- });
630
- const data = parseResult(result) as { path: string; content: string };
631
- expect(data.content).toContain('title');
632
- expect(backend.readBeanFile).toHaveBeenCalledWith('bean-1.md');
633
- } finally {
634
- await cleanup();
635
- }
636
- });
637
-
638
- it('edit calls editBeanFile with content', async () => {
639
- const backend = makeBackend();
640
- const { client, cleanup } = await bootClient(backend);
641
- try {
642
- const result = await client.callTool({
643
- name: 'beans_bean_file',
644
- arguments: { operation: 'edit', path: 'bean-1.md', content: 'new body' },
645
- });
646
- const data = parseResult(result) as { bytes: number };
647
- expect(data.bytes).toBeGreaterThan(0);
648
- expect(backend.editBeanFile).toHaveBeenCalledWith('bean-1.md', 'new body');
649
- } finally {
650
- await cleanup();
651
- }
652
- });
653
-
654
- it('create calls createBeanFile', async () => {
655
- const backend = makeBackend();
656
- const { client, cleanup } = await bootClient(backend);
657
- try {
658
- const result = await client.callTool({
659
- name: 'beans_bean_file',
660
- arguments: { operation: 'create', path: 'new-bean.md', content: '# New' },
661
- });
662
- const data = parseResult(result) as { created: boolean };
663
- expect(data.created).toBe(true);
664
- expect(backend.createBeanFile).toHaveBeenCalledWith('new-bean.md', '# New', { overwrite: undefined });
665
- } finally {
666
- await cleanup();
667
- }
668
- });
669
-
670
- it('delete calls deleteBeanFile', async () => {
671
- const backend = makeBackend();
672
- const { client, cleanup } = await bootClient(backend);
673
- try {
674
- const result = await client.callTool({
675
- name: 'beans_bean_file',
676
- arguments: { operation: 'delete', path: 'bean-1.md' },
677
- });
678
- const data = parseResult(result) as { deleted: boolean };
679
- expect(data.deleted).toBe(true);
680
- expect(backend.deleteBeanFile).toHaveBeenCalledWith('bean-1.md');
681
- } finally {
682
- await cleanup();
683
- }
684
- });
685
-
686
- it('rejects empty path', async () => {
687
- const { client, cleanup } = await bootClient(makeBackend());
688
- try {
689
- await expectError(client.callTool({ name: 'beans_bean_file', arguments: { operation: 'read', path: '' } }));
690
- } finally {
691
- await cleanup();
692
- }
693
- });
694
-
695
- it('surfaces backend errors as isError', async () => {
696
- const backend = makeBackend({
697
- readBeanFile: vi.fn(async () => {
698
- throw new Error('file not found');
699
- }),
700
- });
701
- const { client, cleanup } = await bootClient(backend);
702
- try {
703
- await expectError(
704
- client.callTool({
705
- name: 'beans_bean_file',
706
- arguments: { operation: 'read', path: 'missing.md' },
707
- }),
708
- );
709
- } finally {
710
- await cleanup();
711
- }
712
- });
713
- });
714
-
715
- // ---------------------------------------------------------------------------
716
- // beans_output
717
- // ---------------------------------------------------------------------------
718
-
719
- describe('beans_output', () => {
720
- it('read returns log content', async () => {
721
- const backend = makeBackend();
722
- const { client, cleanup } = await bootClient(backend);
723
- try {
724
- const result = await client.callTool({ name: 'beans_output', arguments: { operation: 'read' } });
725
- const data = parseResult(result) as { content: string; linesReturned: number };
726
- expect(data.content).toContain('line');
727
- expect(data.linesReturned).toBeGreaterThan(0);
728
- } finally {
729
- await cleanup();
730
- }
731
- });
732
-
733
- it('show returns a guidance message', async () => {
734
- const { client, cleanup } = await bootClient(makeBackend());
735
- try {
736
- const result = await client.callTool({ name: 'beans_output', arguments: { operation: 'show' } });
737
- const data = parseResult(result) as { message: string };
738
- expect(typeof data.message).toBe('string');
739
- expect(data.message.length).toBeGreaterThan(0);
740
- } finally {
741
- await cleanup();
742
- }
743
- });
744
-
745
- it('rejects lines value out of range', async () => {
746
- const { client, cleanup } = await bootClient(makeBackend());
747
- try {
748
- await expectError(client.callTool({ name: 'beans_output', arguments: { operation: 'read', lines: 0 } }));
749
- } finally {
750
- await cleanup();
751
- }
752
- });
753
-
754
- it('defaults operation to read', async () => {
755
- const backend = makeBackend();
756
- const { client, cleanup } = await bootClient(backend);
757
- try {
758
- const result = await client.callTool({ name: 'beans_output', arguments: {} });
759
- expect(result.isError).toBeFalsy();
760
- } finally {
761
- await cleanup();
762
- }
763
- });
764
- });
765
-
766
- // ---------------------------------------------------------------------------
767
- // Response shape invariants
768
- // ---------------------------------------------------------------------------
769
-
770
- describe('response shape', () => {
771
- it('every successful response has at least one text content item', async () => {
772
- const { client, cleanup } = await bootClient(makeBackend());
773
- try {
774
- const result = await client.callTool({ name: 'beans_query', arguments: { operation: 'refresh' } });
775
- const items = result.content as TextContent[];
776
- expect(items.length).toBeGreaterThan(0);
777
- expect(items[0].type).toBe('text');
778
- } finally {
779
- await cleanup();
780
- }
781
- });
782
-
783
- it('tool result text is valid JSON', async () => {
784
- const { client, cleanup } = await bootClient(makeBackend());
785
- try {
786
- const result = await client.callTool({ name: 'beans_query', arguments: { operation: 'refresh' } });
787
- const items = result.content as TextContent[];
788
- expect(() => JSON.parse(items[0].text)).not.toThrow();
789
- } finally {
790
- await cleanup();
791
- }
792
- });
793
-
794
- it('isError result contains a non-empty error description', async () => {
795
- const backend = makeBackend({ list: vi.fn(async () => []) });
796
- const { client, cleanup } = await bootClient(backend);
797
- try {
798
- const result = await client.callTool({ name: 'beans_view', arguments: { beanId: 'nope' } });
799
- expect(result.isError).toBe(true);
800
- const items = result.content as TextContent[];
801
- expect(items[0].text.length).toBeGreaterThan(0);
802
- } finally {
803
- await cleanup();
804
- }
805
- });
806
- });
807
-
808
- // ---------------------------------------------------------------------------
809
- // MCP roots — workspace discovery
810
- // ---------------------------------------------------------------------------
811
-
812
- describe('MCP roots', () => {
813
- /**
814
- * Boot a server+client pair where the client declares roots capability and
815
- * responds to roots/list with a specific filesystem path. Used to verify
816
- * the mechanism that startBeansMcpServer relies on for workspace discovery.
817
- */
818
- async function bootWithRoots(
819
- rootPaths: string[],
820
- ): Promise<{ server: Awaited<ReturnType<typeof createBeansMcpServer>>['server']; cleanup: () => Promise<void> }> {
821
- const { server } = await createBeansMcpServer({ workspaceRoot: '/fallback', backend: makeBackend() });
822
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
823
-
824
- await server.connect(serverTransport);
825
-
826
- // Create a client that advertises and responds to roots/list.
827
- const client = new Client({ name: 'roots-test-client', version: '0.0.1' }, { capabilities: { roots: {} } });
828
- client.setRequestHandler(ListRootsRequestSchema, async () => ({
829
- roots: rootPaths.map(p => ({ uri: `file://${p}`, name: p })),
830
- }));
831
-
832
- await client.connect(clientTransport);
833
-
834
- return {
835
- server,
836
- cleanup: async () => {
837
- await client.close();
838
- },
839
- };
840
- }
841
-
842
- it('server can request roots from a client that declares them', async () => {
843
- const { server, cleanup } = await bootWithRoots(['/my/project']);
844
- try {
845
- const { roots } = await server.server.listRoots();
846
- expect(roots).toHaveLength(1);
847
- expect(roots[0].uri).toBe('file:///my/project');
848
- } finally {
849
- await cleanup();
850
- }
851
- });
852
-
853
- it('server receives multiple roots in declaration order', async () => {
854
- const { server, cleanup } = await bootWithRoots(['/project-a', '/project-b']);
855
- try {
856
- const { roots } = await server.server.listRoots();
857
- expect(roots[0].uri).toBe('file:///project-a');
858
- expect(roots[1].uri).toBe('file:///project-b');
859
- } finally {
860
- await cleanup();
861
- }
862
- });
863
-
864
- it('server receives empty roots list when client declares none', async () => {
865
- const { server, cleanup } = await bootWithRoots([]);
866
- try {
867
- const { roots } = await server.server.listRoots();
868
- expect(roots).toHaveLength(0);
869
- } finally {
870
- await cleanup();
871
- }
872
- });
873
-
874
- it('file:// URIs can be parsed to local paths', async () => {
875
- const { server, cleanup } = await bootWithRoots(['/Users/daniel/myproject']);
876
- try {
877
- const { roots } = await server.server.listRoots();
878
- const localPath = new URL(roots[0].uri).pathname;
879
- expect(localPath).toBe('/Users/daniel/myproject');
880
- } finally {
881
- await cleanup();
882
- }
883
- });
884
- });