@selfagency/beans-mcp 0.1.2 → 0.1.3
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/.beans.yml +6 -0
- package/.claude/settings.local.json +18 -0
- package/.editorconfig +13 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/release.yml +235 -0
- package/.github/workflows/test.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.nvmrc +1 -0
- package/.oxfmtrc.json +11 -0
- package/.oxlintrc.json +37 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +160 -0
- package/CONTRIBUTING.md +139 -0
- package/codeql/codeql-custom-queries-actions/README.md +14 -0
- package/codeql/codeql-custom-queries-actions/codeql-pack.lock.yml +32 -0
- package/codeql/codeql-custom-queries-actions/codeql-pack.yml +7 -0
- package/codeql/codeql-custom-queries-actions/qlpack.yml +6 -0
- package/codeql/codeql-custom-queries-actions/queries/github-script-without-tojson.ql +18 -0
- package/codeql/codeql-custom-queries-actions/queries/strict-external-action-pinning.ql +18 -0
- package/codeql/codeql-custom-queries-javascript/README.md +14 -0
- package/codeql/codeql-custom-queries-javascript/codeql-pack.lock.yml +30 -0
- package/codeql/codeql-custom-queries-javascript/codeql-pack.yml +7 -0
- package/codeql/codeql-custom-queries-javascript/qlpack.yml +6 -0
- package/codeql/codeql-custom-queries-javascript/queries/child-process-shell-apis.ql +26 -0
- package/codeql/codeql-custom-queries-javascript/queries/innerhtml-assignment.ql +24 -0
- package/dist/README.md +307 -0
- package/{beans-mcp-server.cjs → dist/beans-mcp-server.cjs} +97 -0
- package/dist/beans-mcp-server.cjs.map +1 -0
- package/{index.cjs → dist/index.cjs} +97 -0
- package/dist/index.cjs.map +1 -0
- package/{index.js → dist/index.js} +97 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +43 -0
- package/package.json +63 -26
- package/pnpm-workspace.yaml +2 -0
- package/scripts/release.js +433 -0
- package/scripts/write-dist-package.js +53 -0
- package/src/cli.ts +14 -0
- package/src/index.ts +21 -0
- package/src/internal/graphql.ts +33 -0
- package/src/internal/queryHelpers.ts +157 -0
- package/src/server/BeansMcpServer.ts +623 -0
- package/src/server/backend.ts +364 -0
- package/src/test/BeansMcpServer.test.ts +514 -0
- package/src/test/handlers.unit.test.ts +201 -0
- package/src/test/parseCliArgs.test.ts +69 -0
- package/src/test/protocol.e2e.test.ts +884 -0
- package/src/test/queryHelpers.test.ts +524 -0
- package/src/test/startBeansMcpServer.test.ts +146 -0
- package/src/test/tools-integration.test.ts +912 -0
- package/src/test/utils.test.ts +81 -0
- package/src/types.ts +46 -0
- package/src/utils.ts +20 -0
- package/tsconfig.json +24 -0
- package/tsup.config.ts +42 -0
- package/vitest.config.ts +18 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
|
@@ -0,0 +1,884 @@
|
|
|
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
|
+
});
|