@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,514 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
MutableBackend,
|
|
5
|
+
createBeansMcpServer,
|
|
6
|
+
parseCliArgs,
|
|
7
|
+
resolveWorkspaceFromRoots,
|
|
8
|
+
} from '../server/BeansMcpServer';
|
|
9
|
+
import type { BackendInterface } from '../server/backend';
|
|
10
|
+
import type { BeanRecord } from '../types';
|
|
11
|
+
|
|
12
|
+
describe('parseCliArgs', () => {
|
|
13
|
+
it('should parse positional workspace root', () => {
|
|
14
|
+
const args = ['/path/to/workspace'];
|
|
15
|
+
const result = parseCliArgs(args);
|
|
16
|
+
expect(result.workspaceRoot).toBe('/path/to/workspace');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should parse --workspace-root flag', () => {
|
|
20
|
+
const args = ['--workspace-root', '/custom/path'];
|
|
21
|
+
const result = parseCliArgs(args);
|
|
22
|
+
expect(result.workspaceRoot).toBe('/custom/path');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should allow both positional and --workspace-root (flag overwrites positional)', () => {
|
|
26
|
+
const args = ['/positional', '--workspace-root', '/flag'];
|
|
27
|
+
const result = parseCliArgs(args);
|
|
28
|
+
// Flag comes after positional, so it overwrites
|
|
29
|
+
expect(result.workspaceRoot).toBe('/flag');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should parse --cli-path flag', () => {
|
|
33
|
+
const args = ['--cli-path', '/usr/bin/beans'];
|
|
34
|
+
const result = parseCliArgs(args);
|
|
35
|
+
expect(result.cliPath).toBe('/usr/bin/beans');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should use default cli-path', () => {
|
|
39
|
+
const args = [];
|
|
40
|
+
const result = parseCliArgs(args);
|
|
41
|
+
expect(result.cliPath).toBe('beans');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should parse --port flag', () => {
|
|
45
|
+
const args = ['--port', '8080'];
|
|
46
|
+
const result = parseCliArgs(args);
|
|
47
|
+
expect(result.port).toBe(8080);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should use default port', () => {
|
|
51
|
+
const args = [];
|
|
52
|
+
const result = parseCliArgs(args);
|
|
53
|
+
expect(result.port).toBe(39173);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should parse --log-dir flag', () => {
|
|
57
|
+
const args = ['--log-dir', '/var/log'];
|
|
58
|
+
const result = parseCliArgs(args);
|
|
59
|
+
expect(result.logDir).toBe('/var/log');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should handle combined flags', () => {
|
|
63
|
+
const args = ['/workspace', '--cli-path', '/usr/bin/beans', '--port', '9000', '--log-dir', '/tmp/logs'];
|
|
64
|
+
const result = parseCliArgs(args);
|
|
65
|
+
expect(result.workspaceRoot).toBe('/workspace');
|
|
66
|
+
expect(result.cliPath).toBe('/usr/bin/beans');
|
|
67
|
+
expect(result.port).toBe(9000);
|
|
68
|
+
expect(result.logDir).toBe('/tmp/logs');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should reject suspicious CLI paths with shell metacharacters', () => {
|
|
72
|
+
const dangerous = ['--cli-path', 'beans; rm -rf /'];
|
|
73
|
+
expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should reject CLI paths with pipes', () => {
|
|
77
|
+
const dangerous = ['--cli-path', 'beans | cat /etc/passwd'];
|
|
78
|
+
expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should reject CLI paths with redirects', () => {
|
|
82
|
+
const dangerous = ['--cli-path', 'beans > /etc/passwd'];
|
|
83
|
+
expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should reject CLI paths with backticks', () => {
|
|
87
|
+
const dangerous = ['--cli-path', '`rm -rf /`'];
|
|
88
|
+
expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should reject CLI paths with dollar expansion', () => {
|
|
92
|
+
const dangerous = ['--cli-path', '$(whoami)'];
|
|
93
|
+
expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should allow safe CLI paths with slashes and dashes', () => {
|
|
97
|
+
const safe = ['--cli-path', '/usr/local/bin/beans-cli'];
|
|
98
|
+
const result = parseCliArgs(safe);
|
|
99
|
+
expect(result.cliPath).toBe('/usr/local/bin/beans-cli');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('createBeansMcpServer', () => {
|
|
104
|
+
const mockBackend: BackendInterface = {
|
|
105
|
+
init: vi.fn(async () => ({ initialized: true })),
|
|
106
|
+
list: vi.fn(async () => []),
|
|
107
|
+
create: vi.fn(async input => ({
|
|
108
|
+
id: 'bean1',
|
|
109
|
+
slug: 'bean1',
|
|
110
|
+
path: 'bean1.md',
|
|
111
|
+
title: input.title,
|
|
112
|
+
body: '',
|
|
113
|
+
status: input.status || 'draft',
|
|
114
|
+
type: input.type,
|
|
115
|
+
})),
|
|
116
|
+
update: vi.fn(async () => ({
|
|
117
|
+
id: 'bean1',
|
|
118
|
+
slug: 'bean1',
|
|
119
|
+
path: 'bean1.md',
|
|
120
|
+
title: 'Updated',
|
|
121
|
+
body: '',
|
|
122
|
+
status: 'todo',
|
|
123
|
+
type: 'task',
|
|
124
|
+
})),
|
|
125
|
+
delete: vi.fn(async () => ({ deleted: true })),
|
|
126
|
+
openConfig: vi.fn(async () => ({ configPath: '/config', content: '{}' })),
|
|
127
|
+
graphqlSchema: vi.fn(async () => 'schema'),
|
|
128
|
+
readOutputLog: vi.fn(async () => ({
|
|
129
|
+
path: '/log',
|
|
130
|
+
content: 'log',
|
|
131
|
+
linesReturned: 0,
|
|
132
|
+
})),
|
|
133
|
+
readBeanFile: vi.fn(async () => ({ path: '/file', content: 'content' })),
|
|
134
|
+
editBeanFile: vi.fn(async () => ({ path: '/file', bytes: 10 })),
|
|
135
|
+
createBeanFile: vi.fn(async () => ({
|
|
136
|
+
path: '/file',
|
|
137
|
+
bytes: 10,
|
|
138
|
+
created: true,
|
|
139
|
+
})),
|
|
140
|
+
deleteBeanFile: vi.fn(async () => ({ path: '/file', deleted: true })),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
it('should create an MCP server instance', async () => {
|
|
144
|
+
const { server, backend } = await createBeansMcpServer({
|
|
145
|
+
workspaceRoot: '/test',
|
|
146
|
+
backend: mockBackend,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(server).toBeDefined();
|
|
150
|
+
expect(backend).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should use provided backend implementation', async () => {
|
|
154
|
+
const { backend } = await createBeansMcpServer({
|
|
155
|
+
workspaceRoot: '/test',
|
|
156
|
+
backend: mockBackend,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(backend).toBe(mockBackend);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should set server name from options', async () => {
|
|
163
|
+
const { server } = await createBeansMcpServer({
|
|
164
|
+
workspaceRoot: '/test',
|
|
165
|
+
name: 'custom-server',
|
|
166
|
+
backend: mockBackend,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(server).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should set server version from options', async () => {
|
|
173
|
+
const { server } = await createBeansMcpServer({
|
|
174
|
+
workspaceRoot: '/test',
|
|
175
|
+
version: '2.0.0',
|
|
176
|
+
backend: mockBackend,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(server).toBeDefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should accept logDir option', async () => {
|
|
183
|
+
const { server } = await createBeansMcpServer({
|
|
184
|
+
workspaceRoot: '/test',
|
|
185
|
+
logDir: '/var/log',
|
|
186
|
+
backend: mockBackend,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(server).toBeDefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should accept cliPath option', async () => {
|
|
193
|
+
const { server: _server } = await createBeansMcpServer({
|
|
194
|
+
workspaceRoot: '/test',
|
|
195
|
+
cliPath: '/usr/bin/beans',
|
|
196
|
+
backend: mockBackend,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(_server).toBeDefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should handle empty list operation', async () => {
|
|
203
|
+
const { backend } = await createBeansMcpServer({
|
|
204
|
+
workspaceRoot: '/test',
|
|
205
|
+
backend: mockBackend,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
(mockBackend.list as any).mockImplementationOnce(async () => []);
|
|
209
|
+
const result = await backend.list();
|
|
210
|
+
expect(result).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should handle list with beans', async () => {
|
|
214
|
+
const mockBeans: BeanRecord[] = [
|
|
215
|
+
{
|
|
216
|
+
id: 'bean1',
|
|
217
|
+
slug: 'bean1',
|
|
218
|
+
path: 'bean1.md',
|
|
219
|
+
title: 'Test Bean',
|
|
220
|
+
body: 'Content',
|
|
221
|
+
status: 'todo',
|
|
222
|
+
type: 'task',
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const { backend } = await createBeansMcpServer({
|
|
227
|
+
workspaceRoot: '/test',
|
|
228
|
+
backend: mockBackend,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
(mockBackend.list as any).mockImplementationOnce(async () => mockBeans);
|
|
232
|
+
const result = await backend.list();
|
|
233
|
+
expect(result).toHaveLength(1);
|
|
234
|
+
expect(result[0].id).toBe('bean1');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should call backend init with prefix', async () => {
|
|
238
|
+
const { backend } = await createBeansMcpServer({
|
|
239
|
+
workspaceRoot: '/test',
|
|
240
|
+
backend: mockBackend,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await backend.init('TEST');
|
|
244
|
+
expect((mockBackend.init as any).mock.calls.length).toBeGreaterThan(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should create beans with required fields', async () => {
|
|
248
|
+
const { backend } = await createBeansMcpServer({
|
|
249
|
+
workspaceRoot: '/test',
|
|
250
|
+
backend: mockBackend,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const result = await backend.create({
|
|
254
|
+
title: 'New Bean',
|
|
255
|
+
type: 'task',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.id).toBe('bean1');
|
|
259
|
+
expect(result.title).toBe('New Bean');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should create beans with optional status', async () => {
|
|
263
|
+
const { backend } = await createBeansMcpServer({
|
|
264
|
+
workspaceRoot: '/test',
|
|
265
|
+
backend: mockBackend,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await backend.create({
|
|
269
|
+
title: 'New Bean',
|
|
270
|
+
type: 'feature',
|
|
271
|
+
status: 'in-progress',
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect((mockBackend.create as any).mock.calls.length).toBeGreaterThan(0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should create beans with optional priority', async () => {
|
|
278
|
+
const { backend } = await createBeansMcpServer({
|
|
279
|
+
workspaceRoot: '/test',
|
|
280
|
+
backend: mockBackend,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await backend.create({
|
|
284
|
+
title: 'New Bean',
|
|
285
|
+
type: 'bug',
|
|
286
|
+
priority: 'high',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect((mockBackend.create as any).mock.calls.length).toBeGreaterThan(0);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should handle bean updates', async () => {
|
|
293
|
+
const { backend } = await createBeansMcpServer({
|
|
294
|
+
workspaceRoot: '/test',
|
|
295
|
+
backend: mockBackend,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const result = await backend.update('bean1', {
|
|
299
|
+
status: 'completed',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(result.status).toBe('todo');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should delete beans', async () => {
|
|
306
|
+
const { backend } = await createBeansMcpServer({
|
|
307
|
+
workspaceRoot: '/test',
|
|
308
|
+
backend: mockBackend,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const result = await backend.delete('bean1');
|
|
312
|
+
expect(result).toEqual({ deleted: true });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should open config', async () => {
|
|
316
|
+
const { backend } = await createBeansMcpServer({
|
|
317
|
+
workspaceRoot: '/test',
|
|
318
|
+
backend: mockBackend,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const result = await backend.openConfig();
|
|
322
|
+
expect(result).toEqual({ configPath: '/config', content: '{}' });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should get GraphQL schema', async () => {
|
|
326
|
+
const { backend } = await createBeansMcpServer({
|
|
327
|
+
workspaceRoot: '/test',
|
|
328
|
+
backend: mockBackend,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const result = await backend.graphqlSchema();
|
|
332
|
+
expect(result).toBe('schema');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should read output log', async () => {
|
|
336
|
+
const { backend } = await createBeansMcpServer({
|
|
337
|
+
workspaceRoot: '/test',
|
|
338
|
+
backend: mockBackend,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const result = await backend.readOutputLog();
|
|
342
|
+
expect(result.path).toBe('/log');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should read bean file', async () => {
|
|
346
|
+
const { backend } = await createBeansMcpServer({
|
|
347
|
+
workspaceRoot: '/test',
|
|
348
|
+
backend: mockBackend,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const result = await backend.readBeanFile('test.md');
|
|
352
|
+
expect(result.content).toBe('content');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should edit bean file', async () => {
|
|
356
|
+
const { backend } = await createBeansMcpServer({
|
|
357
|
+
workspaceRoot: '/test',
|
|
358
|
+
backend: mockBackend,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const result = await backend.editBeanFile('test.md', 'new content');
|
|
362
|
+
expect(result.bytes).toBe(10);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should create bean file', async () => {
|
|
366
|
+
const { backend } = await createBeansMcpServer({
|
|
367
|
+
workspaceRoot: '/test',
|
|
368
|
+
backend: mockBackend,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const result = await backend.createBeanFile('test.md', 'content');
|
|
372
|
+
expect(result.created).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should delete bean file', async () => {
|
|
376
|
+
const { backend } = await createBeansMcpServer({
|
|
377
|
+
workspaceRoot: '/test',
|
|
378
|
+
backend: mockBackend,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const result = await backend.deleteBeanFile('test.md');
|
|
382
|
+
expect(result.path).toBe('/file');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// MutableBackend
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
describe('MutableBackend', () => {
|
|
391
|
+
function makeInner(overrides: Partial<BackendInterface> = {}): BackendInterface {
|
|
392
|
+
return {
|
|
393
|
+
init: vi.fn(async () => ({ initialized: true })),
|
|
394
|
+
list: vi.fn(async () => []),
|
|
395
|
+
create: vi.fn(async input => ({
|
|
396
|
+
id: 'b1',
|
|
397
|
+
slug: 'b1',
|
|
398
|
+
path: 'b1.md',
|
|
399
|
+
title: input.title,
|
|
400
|
+
body: '',
|
|
401
|
+
status: 'draft',
|
|
402
|
+
type: input.type,
|
|
403
|
+
})),
|
|
404
|
+
update: vi.fn(async () => ({
|
|
405
|
+
id: 'b1',
|
|
406
|
+
slug: 'b1',
|
|
407
|
+
path: 'b1.md',
|
|
408
|
+
title: 'T',
|
|
409
|
+
body: '',
|
|
410
|
+
status: 'todo',
|
|
411
|
+
type: 'task',
|
|
412
|
+
})),
|
|
413
|
+
delete: vi.fn(async () => ({ deleted: true })),
|
|
414
|
+
openConfig: vi.fn(async () => ({ configPath: '/cfg', content: '{}' })),
|
|
415
|
+
graphqlSchema: vi.fn(async () => 'schema'),
|
|
416
|
+
readOutputLog: vi.fn(async () => ({ path: '/log', content: 'log', linesReturned: 0 })),
|
|
417
|
+
readBeanFile: vi.fn(async () => ({ path: '/f', content: 'c' })),
|
|
418
|
+
editBeanFile: vi.fn(async () => ({ path: '/f', bytes: 1 })),
|
|
419
|
+
createBeanFile: vi.fn(async () => ({ path: '/f', bytes: 1, created: true })),
|
|
420
|
+
deleteBeanFile: vi.fn(async () => ({ path: '/f', deleted: true })),
|
|
421
|
+
...overrides,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
it('delegates every method to the inner backend', async () => {
|
|
426
|
+
const inner = makeInner();
|
|
427
|
+
const m = new MutableBackend(inner);
|
|
428
|
+
|
|
429
|
+
await m.init('pfx');
|
|
430
|
+
expect(inner.init).toHaveBeenCalledWith('pfx');
|
|
431
|
+
|
|
432
|
+
await m.list({ status: ['todo'] });
|
|
433
|
+
expect(inner.list).toHaveBeenCalledWith({ status: ['todo'] });
|
|
434
|
+
|
|
435
|
+
await m.create({ title: 'T', type: 'task' });
|
|
436
|
+
expect(inner.create).toHaveBeenCalled();
|
|
437
|
+
|
|
438
|
+
await m.update('b1', { status: 'done' });
|
|
439
|
+
expect(inner.update).toHaveBeenCalledWith('b1', { status: 'done' });
|
|
440
|
+
|
|
441
|
+
await m.delete('b1');
|
|
442
|
+
expect(inner.delete).toHaveBeenCalledWith('b1');
|
|
443
|
+
|
|
444
|
+
await m.openConfig();
|
|
445
|
+
expect(inner.openConfig).toHaveBeenCalled();
|
|
446
|
+
|
|
447
|
+
await m.graphqlSchema();
|
|
448
|
+
expect(inner.graphqlSchema).toHaveBeenCalled();
|
|
449
|
+
|
|
450
|
+
await m.readOutputLog({ lines: 5 });
|
|
451
|
+
expect(inner.readOutputLog).toHaveBeenCalledWith({ lines: 5 });
|
|
452
|
+
|
|
453
|
+
await m.readBeanFile('a.md');
|
|
454
|
+
expect(inner.readBeanFile).toHaveBeenCalledWith('a.md');
|
|
455
|
+
|
|
456
|
+
await m.editBeanFile('a.md', 'content');
|
|
457
|
+
expect(inner.editBeanFile).toHaveBeenCalledWith('a.md', 'content');
|
|
458
|
+
|
|
459
|
+
await m.createBeanFile('a.md', 'content', { overwrite: true });
|
|
460
|
+
expect(inner.createBeanFile).toHaveBeenCalledWith('a.md', 'content', { overwrite: true });
|
|
461
|
+
|
|
462
|
+
await m.deleteBeanFile('a.md');
|
|
463
|
+
expect(inner.deleteBeanFile).toHaveBeenCalledWith('a.md');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('setInner swaps the delegate so subsequent calls go to the new backend', async () => {
|
|
467
|
+
const inner1 = makeInner();
|
|
468
|
+
const inner2 = makeInner();
|
|
469
|
+
const m = new MutableBackend(inner1);
|
|
470
|
+
|
|
471
|
+
await m.list();
|
|
472
|
+
expect(inner1.list).toHaveBeenCalledTimes(1);
|
|
473
|
+
expect(inner2.list).not.toHaveBeenCalled();
|
|
474
|
+
|
|
475
|
+
m.setInner(inner2);
|
|
476
|
+
await m.list();
|
|
477
|
+
expect(inner1.list).toHaveBeenCalledTimes(1); // not called again
|
|
478
|
+
expect(inner2.list).toHaveBeenCalledTimes(1);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// resolveWorkspaceFromRoots
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
describe('resolveWorkspaceFromRoots', () => {
|
|
487
|
+
function mockServer(listRootsImpl: () => Promise<{ roots: { uri: string }[] }>): McpServer {
|
|
488
|
+
return { server: { listRoots: listRootsImpl } } as unknown as McpServer;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
it('returns the pathname of the first file:// root', async () => {
|
|
492
|
+
const server = mockServer(async () => ({ roots: [{ uri: 'file:///my/project' }] }));
|
|
493
|
+
expect(await resolveWorkspaceFromRoots(server)).toBe('/my/project');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('skips non-file:// URIs and returns the first file:// one', async () => {
|
|
497
|
+
const server = mockServer(async () => ({
|
|
498
|
+
roots: [{ uri: 'https://example.com' }, { uri: 'file:///local/path' }],
|
|
499
|
+
}));
|
|
500
|
+
expect(await resolveWorkspaceFromRoots(server)).toBe('/local/path');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('returns null when no file:// roots are present', async () => {
|
|
504
|
+
const server = mockServer(async () => ({ roots: [] }));
|
|
505
|
+
expect(await resolveWorkspaceFromRoots(server)).toBeNull();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('returns null when listRoots throws', async () => {
|
|
509
|
+
const server = mockServer(async () => {
|
|
510
|
+
throw new Error('no roots capability');
|
|
511
|
+
});
|
|
512
|
+
expect(await resolveWorkspaceFromRoots(server)).toBeNull();
|
|
513
|
+
});
|
|
514
|
+
});
|