@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.
Files changed (57) hide show
  1. package/.beans.yml +6 -0
  2. package/.claude/settings.local.json +18 -0
  3. package/.editorconfig +13 -0
  4. package/.github/dependabot.yml +11 -0
  5. package/.github/workflows/release.yml +235 -0
  6. package/.github/workflows/test.yml +84 -0
  7. package/.husky/pre-commit +1 -0
  8. package/.nvmrc +1 -0
  9. package/.oxfmtrc.json +11 -0
  10. package/.oxlintrc.json +37 -0
  11. package/.vscode/settings.json +3 -0
  12. package/CHANGELOG.md +160 -0
  13. package/CONTRIBUTING.md +139 -0
  14. package/codeql/codeql-custom-queries-actions/README.md +14 -0
  15. package/codeql/codeql-custom-queries-actions/codeql-pack.lock.yml +32 -0
  16. package/codeql/codeql-custom-queries-actions/codeql-pack.yml +7 -0
  17. package/codeql/codeql-custom-queries-actions/qlpack.yml +6 -0
  18. package/codeql/codeql-custom-queries-actions/queries/github-script-without-tojson.ql +18 -0
  19. package/codeql/codeql-custom-queries-actions/queries/strict-external-action-pinning.ql +18 -0
  20. package/codeql/codeql-custom-queries-javascript/README.md +14 -0
  21. package/codeql/codeql-custom-queries-javascript/codeql-pack.lock.yml +30 -0
  22. package/codeql/codeql-custom-queries-javascript/codeql-pack.yml +7 -0
  23. package/codeql/codeql-custom-queries-javascript/qlpack.yml +6 -0
  24. package/codeql/codeql-custom-queries-javascript/queries/child-process-shell-apis.ql +26 -0
  25. package/codeql/codeql-custom-queries-javascript/queries/innerhtml-assignment.ql +24 -0
  26. package/dist/README.md +307 -0
  27. package/{beans-mcp-server.cjs → dist/beans-mcp-server.cjs} +97 -0
  28. package/dist/beans-mcp-server.cjs.map +1 -0
  29. package/{index.cjs → dist/index.cjs} +97 -0
  30. package/dist/index.cjs.map +1 -0
  31. package/{index.js → dist/index.js} +97 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/package.json +43 -0
  34. package/package.json +63 -26
  35. package/pnpm-workspace.yaml +2 -0
  36. package/scripts/release.js +433 -0
  37. package/scripts/write-dist-package.js +53 -0
  38. package/src/cli.ts +14 -0
  39. package/src/index.ts +21 -0
  40. package/src/internal/graphql.ts +33 -0
  41. package/src/internal/queryHelpers.ts +157 -0
  42. package/src/server/BeansMcpServer.ts +623 -0
  43. package/src/server/backend.ts +364 -0
  44. package/src/test/BeansMcpServer.test.ts +514 -0
  45. package/src/test/handlers.unit.test.ts +201 -0
  46. package/src/test/parseCliArgs.test.ts +69 -0
  47. package/src/test/protocol.e2e.test.ts +884 -0
  48. package/src/test/queryHelpers.test.ts +524 -0
  49. package/src/test/startBeansMcpServer.test.ts +146 -0
  50. package/src/test/tools-integration.test.ts +912 -0
  51. package/src/test/utils.test.ts +81 -0
  52. package/src/types.ts +46 -0
  53. package/src/utils.ts +20 -0
  54. package/tsconfig.json +24 -0
  55. package/tsup.config.ts +42 -0
  56. package/vitest.config.ts +18 -0
  57. /package/{index.d.ts → dist/index.d.ts} +0 -0
@@ -0,0 +1,623 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { handleQueryOperation, sortBeans } from '../internal/queryHelpers';
4
+ import {
5
+ DEFAULT_MCP_PORT,
6
+ MAX_DESCRIPTION_LENGTH,
7
+ MAX_ID_LENGTH,
8
+ MAX_METADATA_LENGTH,
9
+ MAX_PATH_LENGTH,
10
+ MAX_TITLE_LENGTH,
11
+ } from '../types';
12
+ import { makeTextAndStructured } from '../utils';
13
+ // Log package version on startup to help diagnose runtime package mismatches
14
+ // Note: resolveJsonModule is enabled in tsconfig, so we can import package.json safely.
15
+ // Always log to stderr to avoid interfering with MCP stdio transport on stdout.
16
+ import pkgJson from '../../package.json' assert { type: 'json' };
17
+ import type { BackendInterface } from './backend';
18
+
19
+ export { sortBeans };
20
+
21
+ // Exported test seam: get a bean by id with consistent error messages
22
+ export async function getBeanById(backend: BackendInterface, beanId: string) {
23
+ try {
24
+ const beans = await backend.list();
25
+ const found = beans.find(b => b.id === beanId);
26
+ if (!found) {
27
+ throw new Error(`Bean not found: ${beanId}`);
28
+ }
29
+ return found;
30
+ } catch (error) {
31
+ throw new Error(`Failed to fetch bean ${beanId}: ${(error as Error).message}`);
32
+ }
33
+ }
34
+
35
+ // Exported handler factories so unit tests can call handlers directly.
36
+ export function initHandler(backend: BackendInterface) {
37
+ return async ({ prefix }: { prefix?: string }) => {
38
+ const result = await backend.init(prefix);
39
+ return makeTextAndStructured(result);
40
+ };
41
+ }
42
+
43
+ export function viewHandler(backend: BackendInterface) {
44
+ return async ({ beanId }: { beanId: string }) => makeTextAndStructured({ bean: await getBeanById(backend, beanId) });
45
+ }
46
+
47
+ export function createHandler(backend: BackendInterface) {
48
+ return async (input: {
49
+ title: string;
50
+ type: string;
51
+ status?: string;
52
+ priority?: string;
53
+ description?: string;
54
+ parent?: string;
55
+ }) => makeTextAndStructured({ bean: await backend.create(input) });
56
+ }
57
+
58
+ export function editHandler(backend: BackendInterface) {
59
+ return async ({
60
+ beanId,
61
+ ...updates
62
+ }: {
63
+ beanId: string;
64
+ status?: string;
65
+ type?: string;
66
+ priority?: string;
67
+ parent?: string;
68
+ clearParent?: boolean;
69
+ blocking?: string[];
70
+ blockedBy?: string[];
71
+ }) => makeTextAndStructured({ bean: await backend.update(beanId, updates) });
72
+ }
73
+
74
+ export function reopenHandler(backend: BackendInterface) {
75
+ return async ({
76
+ beanId,
77
+ requiredCurrentStatus,
78
+ targetStatus,
79
+ }: {
80
+ beanId: string;
81
+ requiredCurrentStatus: 'completed' | 'scrapped';
82
+ targetStatus: string;
83
+ }) => {
84
+ const bean = await getBeanById(backend, beanId);
85
+ if (bean.status !== requiredCurrentStatus) {
86
+ throw new Error(`Bean ${beanId} is not ${requiredCurrentStatus}`);
87
+ }
88
+ return makeTextAndStructured({
89
+ bean: await backend.update(beanId, { status: targetStatus }),
90
+ });
91
+ };
92
+ }
93
+
94
+ export function updateHandler(backend: BackendInterface) {
95
+ return async (input: {
96
+ beanId: string;
97
+ status?: string;
98
+ type?: string;
99
+ priority?: string;
100
+ parent?: string;
101
+ clearParent?: boolean;
102
+ blocking?: string[];
103
+ blockedBy?: string[];
104
+ body?: string;
105
+ }) =>
106
+ makeTextAndStructured({
107
+ bean: await backend.update(input.beanId, {
108
+ status: input.status,
109
+ type: input.type,
110
+ priority: input.priority,
111
+ parent: input.parent,
112
+ clearParent: input.clearParent,
113
+ blocking: input.blocking,
114
+ blockedBy: input.blockedBy,
115
+ body: input.body,
116
+ }),
117
+ });
118
+ }
119
+
120
+ export function deleteHandler(backend: BackendInterface) {
121
+ return async ({ beanId, force }: { beanId: string; force: boolean }) => {
122
+ const bean = await getBeanById(backend, beanId);
123
+ if (!force && bean.status !== 'draft' && bean.status !== 'scrapped') {
124
+ throw new Error('Only draft and scrapped beans are deletable unless force=true');
125
+ }
126
+ return makeTextAndStructured(await backend.delete(beanId));
127
+ };
128
+ }
129
+
130
+ export function queryHandler(backend: BackendInterface) {
131
+ return async (opts: {
132
+ operation: 'refresh' | 'filter' | 'search' | 'sort' | 'llm_context' | 'open_config';
133
+ mode?: 'status-priority-type-title' | 'updated' | 'created' | 'id';
134
+ statuses?: string[] | null;
135
+ types?: string[] | null;
136
+ search?: string;
137
+ includeClosed?: boolean;
138
+ tags?: string[] | null;
139
+ writeToWorkspaceInstructions?: boolean;
140
+ }) => handleQueryOperation(backend, opts);
141
+ }
142
+
143
+ export function beanFileHandler(backend: BackendInterface) {
144
+ return async ({
145
+ operation,
146
+ path,
147
+ content,
148
+ overwrite,
149
+ }: {
150
+ operation: 'read' | 'edit' | 'create' | 'delete';
151
+ path: string;
152
+ content?: string;
153
+ overwrite?: boolean;
154
+ }) => {
155
+ if (operation === 'read') {
156
+ return makeTextAndStructured(await backend.readBeanFile(path));
157
+ }
158
+ if (operation === 'edit') {
159
+ return makeTextAndStructured(await backend.editBeanFile(path, content || ''));
160
+ }
161
+ if (operation === 'create') {
162
+ return makeTextAndStructured(await backend.createBeanFile(path, content || '', { overwrite }));
163
+ }
164
+ if (operation === 'delete') {
165
+ return makeTextAndStructured(await backend.deleteBeanFile(path));
166
+ }
167
+ throw new Error('Unsupported operation');
168
+ };
169
+ }
170
+
171
+ export function outputHandler(backend: BackendInterface) {
172
+ return async ({ operation, lines }: { operation: 'read' | 'show'; lines?: number }) => {
173
+ if (operation === 'read') {
174
+ return makeTextAndStructured(await backend.readOutputLog({ lines }));
175
+ }
176
+ return makeTextAndStructured({
177
+ message:
178
+ 'When using VS Code UI, run command `Beans: Show Output` to open extension logs. In MCP mode, rely on tool error outputs and host logs.',
179
+ });
180
+ };
181
+ }
182
+ function registerTools(server: McpServer, backend: BackendInterface): void {
183
+ // register exported handlers bound to this backend
184
+
185
+ server.registerTool(
186
+ 'beans_init',
187
+ {
188
+ title: 'Initialize Beans Workspace',
189
+ description: 'Initialize Beans in the current workspace, equivalent to the extension init command.',
190
+ inputSchema: z.object({
191
+ prefix: z.string().max(32).optional().describe('Optional workspace prefix for bean IDs'),
192
+ }),
193
+ annotations: {
194
+ readOnlyHint: false,
195
+ destructiveHint: false,
196
+ idempotentHint: true,
197
+ openWorldHint: false,
198
+ },
199
+ },
200
+ initHandler(backend),
201
+ );
202
+
203
+ server.registerTool(
204
+ 'beans_view',
205
+ {
206
+ title: 'View Bean',
207
+ description: 'Fetch full bean details by ID.',
208
+ inputSchema: z.object({ beanId: z.string().min(1).max(MAX_ID_LENGTH) }),
209
+ annotations: {
210
+ readOnlyHint: true,
211
+ destructiveHint: false,
212
+ idempotentHint: true,
213
+ openWorldHint: false,
214
+ },
215
+ },
216
+ viewHandler(backend),
217
+ );
218
+
219
+ server.registerTool(
220
+ 'beans_create',
221
+ {
222
+ title: 'Create Bean',
223
+ description: 'Create a new bean.',
224
+ inputSchema: z.object({
225
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
226
+ type: z.string().min(1).max(MAX_METADATA_LENGTH),
227
+ status: z.string().max(MAX_METADATA_LENGTH).optional(),
228
+ priority: z.string().max(MAX_METADATA_LENGTH).optional(),
229
+ description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
230
+ parent: z.string().max(MAX_ID_LENGTH).optional(),
231
+ }),
232
+ annotations: {
233
+ readOnlyHint: false,
234
+ destructiveHint: false,
235
+ idempotentHint: false,
236
+ openWorldHint: false,
237
+ },
238
+ },
239
+ createHandler(backend),
240
+ );
241
+
242
+ server.registerTool(
243
+ 'beans_edit',
244
+ {
245
+ title: 'Edit Bean Metadata',
246
+ description: 'Update bean metadata fields (status/type/priority/parent/blocking).',
247
+ inputSchema: z.object({
248
+ beanId: z.string().min(1).max(MAX_ID_LENGTH),
249
+ status: z.string().max(MAX_METADATA_LENGTH).optional(),
250
+ type: z.string().max(MAX_METADATA_LENGTH).optional(),
251
+ priority: z.string().max(MAX_METADATA_LENGTH).optional(),
252
+ parent: z.string().max(MAX_ID_LENGTH).optional(),
253
+ clearParent: z.boolean().optional(),
254
+ blocking: z.array(z.string().max(MAX_ID_LENGTH)).optional(),
255
+ blockedBy: z.array(z.string().max(MAX_ID_LENGTH)).optional(),
256
+ }),
257
+ annotations: {
258
+ readOnlyHint: false,
259
+ destructiveHint: false,
260
+ idempotentHint: false,
261
+ openWorldHint: false,
262
+ },
263
+ },
264
+ editHandler(backend),
265
+ );
266
+
267
+ server.registerTool(
268
+ 'beans_reopen',
269
+ {
270
+ title: 'Reopen Bean',
271
+ description: 'Reopen a completed or scrapped bean into a non-closed status.',
272
+ inputSchema: z.object({
273
+ beanId: z.string().min(1).max(MAX_ID_LENGTH),
274
+ requiredCurrentStatus: z.enum(['completed', 'scrapped']),
275
+ targetStatus: z.string().max(MAX_METADATA_LENGTH).default('todo'),
276
+ }),
277
+ annotations: {
278
+ readOnlyHint: false,
279
+ destructiveHint: false,
280
+ idempotentHint: false,
281
+ openWorldHint: false,
282
+ },
283
+ },
284
+ reopenHandler(backend),
285
+ );
286
+
287
+ server.registerTool(
288
+ 'beans_update',
289
+ {
290
+ title: 'Update Bean',
291
+ description:
292
+ 'Update bean metadata fields (status/type/priority/parent/blocking). Consolidated replacement for per-field update tools.',
293
+ inputSchema: z.object({
294
+ beanId: z.string().min(1).max(MAX_ID_LENGTH),
295
+ status: z.string().max(MAX_METADATA_LENGTH).optional(),
296
+ type: z.string().max(MAX_METADATA_LENGTH).optional(),
297
+ priority: z.string().max(MAX_METADATA_LENGTH).optional(),
298
+ parent: z.string().max(MAX_ID_LENGTH).optional(),
299
+ clearParent: z.boolean().optional(),
300
+ blocking: z.array(z.string().max(MAX_ID_LENGTH)).optional(),
301
+ blockedBy: z.array(z.string().max(MAX_ID_LENGTH)).optional(),
302
+ body: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
303
+ }),
304
+ annotations: {
305
+ readOnlyHint: false,
306
+ destructiveHint: false,
307
+ idempotentHint: false,
308
+ openWorldHint: false,
309
+ },
310
+ },
311
+ updateHandler(backend),
312
+ );
313
+
314
+ server.registerTool(
315
+ 'beans_delete',
316
+ {
317
+ title: 'Delete Bean',
318
+ description: 'Delete a bean (intended for draft/scrapped beans).',
319
+ inputSchema: z.object({
320
+ beanId: z.string().min(1).max(MAX_ID_LENGTH),
321
+ force: z.boolean().default(false),
322
+ }),
323
+ annotations: {
324
+ readOnlyHint: false,
325
+ destructiveHint: true,
326
+ idempotentHint: false,
327
+ openWorldHint: false,
328
+ },
329
+ },
330
+ deleteHandler(backend),
331
+ );
332
+
333
+ server.registerTool(
334
+ 'beans_query',
335
+ {
336
+ title: 'Query Beans',
337
+ description: 'Unified query tool for refresh, filter, search, and sort operations.',
338
+ inputSchema: z.object({
339
+ operation: z.enum(['refresh', 'filter', 'search', 'sort', 'llm_context', 'open_config']).default('refresh'),
340
+ mode: z.enum(['status-priority-type-title', 'updated', 'created', 'id']).optional(),
341
+ statuses: z.array(z.string().max(MAX_METADATA_LENGTH)).nullable().optional(),
342
+ types: z.array(z.string().max(MAX_METADATA_LENGTH)).nullable().optional(),
343
+ search: z.string().max(MAX_TITLE_LENGTH).optional(),
344
+ includeClosed: z.boolean().optional(),
345
+ tags: z.array(z.string().max(MAX_METADATA_LENGTH)).nullable().optional(),
346
+ writeToWorkspaceInstructions: z.boolean().optional(),
347
+ }),
348
+ annotations: {
349
+ readOnlyHint: true,
350
+ destructiveHint: false,
351
+ idempotentHint: true,
352
+ openWorldHint: false,
353
+ },
354
+ },
355
+ queryHandler(backend),
356
+ );
357
+
358
+ server.registerTool(
359
+ 'beans_bean_file',
360
+ {
361
+ title: 'Bean File Operations',
362
+ description: 'Read, create, edit, or delete files under .beans (operation param).',
363
+ inputSchema: z.object({
364
+ operation: z.enum(['read', 'edit', 'create', 'delete']),
365
+ path: z.string().min(1).max(MAX_PATH_LENGTH),
366
+ content: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
367
+ overwrite: z.boolean().optional(),
368
+ }),
369
+ annotations: {
370
+ readOnlyHint: false,
371
+ destructiveHint: false,
372
+ idempotentHint: false,
373
+ openWorldHint: false,
374
+ },
375
+ },
376
+ beanFileHandler(backend),
377
+ );
378
+
379
+ server.registerTool(
380
+ 'beans_output',
381
+ {
382
+ title: 'Beans Output Tools',
383
+ description: 'Read extension output log or show guidance (operation param).',
384
+ inputSchema: z.object({
385
+ operation: z.enum(['read', 'show']).default('read'),
386
+ lines: z.number().int().min(1).max(5000).optional(),
387
+ }),
388
+ annotations: {
389
+ readOnlyHint: true,
390
+ destructiveHint: false,
391
+ idempotentHint: true,
392
+ openWorldHint: false,
393
+ },
394
+ },
395
+ outputHandler(backend),
396
+ );
397
+ }
398
+
399
+ /**
400
+ * Thin delegation wrapper whose inner backend can be hot-swapped without
401
+ * re-registering tools. Used by startBeansMcpServer to update the workspace
402
+ * after MCP roots are discovered from the connected client.
403
+ */
404
+ export class MutableBackend implements BackendInterface {
405
+ constructor(private inner: BackendInterface) {}
406
+
407
+ setInner(b: BackendInterface) {
408
+ this.inner = b;
409
+ }
410
+
411
+ init(prefix?: string) {
412
+ return this.inner.init(prefix);
413
+ }
414
+ list(opts?: Parameters<BackendInterface['list']>[0]) {
415
+ return this.inner.list(opts);
416
+ }
417
+ create(input: Parameters<BackendInterface['create']>[0]) {
418
+ return this.inner.create(input);
419
+ }
420
+ update(id: string, updates: Parameters<BackendInterface['update']>[1]) {
421
+ return this.inner.update(id, updates);
422
+ }
423
+ delete(id: string) {
424
+ return this.inner.delete(id);
425
+ }
426
+ openConfig() {
427
+ return this.inner.openConfig();
428
+ }
429
+ graphqlSchema() {
430
+ return this.inner.graphqlSchema();
431
+ }
432
+ readOutputLog(opts?: Parameters<BackendInterface['readOutputLog']>[0]) {
433
+ return this.inner.readOutputLog(opts);
434
+ }
435
+ readBeanFile(path: string) {
436
+ return this.inner.readBeanFile(path);
437
+ }
438
+ editBeanFile(path: string, content: string) {
439
+ return this.inner.editBeanFile(path, content);
440
+ }
441
+ createBeanFile(path: string, content: string, opts?: Parameters<BackendInterface['createBeanFile']>[2]) {
442
+ return this.inner.createBeanFile(path, content, opts);
443
+ }
444
+ deleteBeanFile(path: string) {
445
+ return this.inner.deleteBeanFile(path);
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Ask the connected client for its MCP roots and return the first local
451
+ * filesystem path, or null if the client declares no roots or does not
452
+ * support the roots capability.
453
+ */
454
+ export async function resolveWorkspaceFromRoots(server: McpServer): Promise<string | null> {
455
+ try {
456
+ const { roots } = await server.server.listRoots();
457
+ for (const root of roots) {
458
+ if (root.uri.startsWith('file://')) {
459
+ return new URL(root.uri).pathname;
460
+ }
461
+ }
462
+ return null;
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+
468
+ export async function createBeansMcpServer(opts: {
469
+ workspaceRoot: string;
470
+ cliPath?: string;
471
+ name?: string;
472
+ version?: string;
473
+ logDir?: string;
474
+ backend?: BackendInterface;
475
+ }): Promise<{ server: McpServer; backend: BackendInterface }> {
476
+ const { BeansCliBackend } = await import('./backend');
477
+
478
+ const backend = opts.backend || new BeansCliBackend(opts.workspaceRoot, opts.cliPath || 'beans', opts.logDir);
479
+
480
+ const server = new McpServer({
481
+ name: opts.name || 'beans-mcp-server',
482
+ version: opts.version || '0.1.0',
483
+ });
484
+
485
+ registerTools(server, backend);
486
+
487
+ return { server, backend };
488
+ }
489
+
490
+ const HELP_TEXT = `Usage: beans-mcp-server [workspace-root] [options]
491
+
492
+ Arguments:
493
+ workspace-root Path to workspace root.
494
+ Optional: if omitted, the server first asks the
495
+ connected MCP client for its declared roots and
496
+ falls back to the current directory.
497
+
498
+ Options:
499
+ --workspace <path> Alias for workspace-root positional argument
500
+ --workspace-root <p> Alias for workspace-root positional argument
501
+ --cli-path <path> Path to the beans CLI executable (default: beans)
502
+ --port <number> MCP server port (default: ${DEFAULT_MCP_PORT})
503
+ --log-dir <path> Directory for log output (default: workspace root)
504
+ -h, --help Show this help message
505
+
506
+ Workspace resolution order (highest to lowest priority):
507
+ 1. --workspace-root CLI argument (or positional)
508
+ 2. MCP roots declared by the connected client
509
+ 3. Current working directory
510
+
511
+ Environment variables:
512
+ BEANS_MCP_PORT Override the default MCP port
513
+ BEANS_VSCODE_MCP_PORT Override the default MCP port (VS Code extension)
514
+ `;
515
+
516
+ export function parseCliArgs(argv: string[]): {
517
+ workspaceRoot: string;
518
+ /** True when the caller explicitly supplied --workspace-root (or the positional arg). */
519
+ workspaceExplicit: boolean;
520
+ cliPath: string;
521
+ port: number;
522
+ logDir?: string;
523
+ } {
524
+ if (argv.includes('--help') || argv.includes('-h')) {
525
+ process.stdout.write(HELP_TEXT);
526
+ process.exit(0);
527
+ }
528
+
529
+ let workspaceRoot = process.cwd();
530
+ let workspaceExplicit = false;
531
+ let cliPath = 'beans';
532
+ const envPort = Number.parseInt(process.env.BEANS_VSCODE_MCP_PORT || process.env.BEANS_MCP_PORT || '', 10);
533
+ let port = Number.isInteger(envPort) && envPort > 0 ? envPort : DEFAULT_MCP_PORT;
534
+ let logDir: string | undefined;
535
+
536
+ for (let i = 0; i < argv.length; i += 1) {
537
+ const arg = argv[i];
538
+ if ((arg === '--workspace' || arg === '--workspace-root') && argv[i + 1]) {
539
+ workspaceRoot = argv[i + 1]!;
540
+ workspaceExplicit = true;
541
+ i += 1;
542
+ } else if (arg === '--cli-path' && argv[i + 1]) {
543
+ cliPath = argv[i + 1]!;
544
+ if (/[\s;&|><$(){}[\]`]/.test(cliPath)) {
545
+ throw new Error('Invalid CLI path');
546
+ }
547
+ i += 1;
548
+ } else if (arg === '--port' && argv[i + 1]) {
549
+ const parsedPort = Number.parseInt(argv[i + 1]!, 10);
550
+ if (Number.isInteger(parsedPort) && parsedPort > 0) {
551
+ port = parsedPort;
552
+ }
553
+ i += 1;
554
+ } else if (arg === '--log-dir' && argv[i + 1]) {
555
+ logDir = argv[i + 1]!;
556
+ i += 1;
557
+ } else if (!arg.startsWith('-') && i === 0) {
558
+ // positional workspace root
559
+ workspaceRoot = arg;
560
+ workspaceExplicit = true;
561
+ }
562
+ }
563
+
564
+ // default logDir to the workspace root when not provided
565
+ if (!logDir) {
566
+ logDir = workspaceRoot;
567
+ }
568
+
569
+ return { workspaceRoot, workspaceExplicit, cliPath, port, logDir };
570
+ }
571
+
572
+ export async function startBeansMcpServer(
573
+ argv: string[],
574
+ /** For testing only: override the roots resolver so tests can cover the setInner branch. */
575
+ _resolveRoots?: (server: McpServer) => Promise<string | null>,
576
+ ): Promise<void> {
577
+ const { BeansCliBackend } = await import('./backend');
578
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
579
+
580
+ const { workspaceRoot, workspaceExplicit, cliPath, port, logDir } = parseCliArgs(argv);
581
+ process.env.BEANS_VSCODE_MCP_PORT = String(port);
582
+ process.env.BEANS_MCP_PORT = String(port);
583
+
584
+ // Emit a single-line startup banner with package version and key settings.
585
+ try {
586
+ const version = (pkgJson as { version?: string }).version ?? '0.0.0-dev';
587
+ const workspaceLabel = workspaceExplicit ? workspaceRoot : '(auto from roots)';
588
+ // stderr only – stdout is reserved for JSON-RPC traffic
589
+ console.error(
590
+ `[beans-mcp] v${version} starting (port=${port}, workspace=${workspaceLabel}, cli=${cliPath}, logDir=${logDir})`,
591
+ );
592
+ } catch {
593
+ // Best-effort only; never fail startup on logging
594
+ }
595
+
596
+ // Use a mutable delegate so we can hot-swap the workspace after roots discovery
597
+ // without re-registering the MCP tools.
598
+ const mutable = new MutableBackend(new BeansCliBackend(workspaceRoot, cliPath, logDir));
599
+
600
+ const { server } = await createBeansMcpServer({
601
+ workspaceRoot,
602
+ cliPath,
603
+ logDir,
604
+ backend: mutable,
605
+ });
606
+
607
+ const transport = new StdioServerTransport();
608
+ await server.connect(transport);
609
+
610
+ // If the caller did not supply an explicit workspace, ask the connected client
611
+ // for its declared MCP roots and use the first local filesystem path.
612
+ if (!workspaceExplicit) {
613
+ const resolver = _resolveRoots ?? resolveWorkspaceFromRoots;
614
+ const rootPath = await resolver(server);
615
+ if (rootPath) {
616
+ mutable.setInner(new BeansCliBackend(rootPath, cliPath));
617
+ // Log the resolved workspace for traceability (stderr to avoid stdout noise)
618
+ try {
619
+ console.error(`[beans-mcp] workspace resolved from roots: ${rootPath}`);
620
+ } catch {}
621
+ }
622
+ }
623
+ }