@selfagency/beans-mcp 0.1.0 → 0.1.1

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 (47) hide show
  1. package/.beans.yml +6 -0
  2. package/.claude/settings.local.json +18 -0
  3. package/.editorconfig +13 -0
  4. package/.github/workflows/release.yml +235 -0
  5. package/.github/workflows/test.yml +80 -0
  6. package/.husky/pre-commit +1 -0
  7. package/.nvmrc +1 -0
  8. package/.oxfmtrc.json +11 -0
  9. package/.oxlintrc.json +37 -0
  10. package/.vscode/settings.json +3 -0
  11. package/CHANGELOG.md +140 -0
  12. package/CONTRIBUTING.md +139 -0
  13. package/LICENSE.txt +21 -0
  14. package/README.md +4 -4
  15. package/dist/README.md +307 -0
  16. package/dist/beans-mcp-server.cjs.map +1 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.js +31676 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/package.json +43 -0
  21. package/package.json +64 -27
  22. package/pnpm-workspace.yaml +2 -0
  23. package/scripts/release.js +433 -0
  24. package/scripts/write-dist-package.js +53 -0
  25. package/src/cli.ts +14 -0
  26. package/src/index.ts +21 -0
  27. package/src/internal/graphql.ts +33 -0
  28. package/src/internal/queryHelpers.ts +157 -0
  29. package/src/server/BeansMcpServer.ts +600 -0
  30. package/src/server/backend.ts +358 -0
  31. package/src/test/BeansMcpServer.test.ts +514 -0
  32. package/src/test/handlers.unit.test.ts +184 -0
  33. package/src/test/parseCliArgs.test.ts +69 -0
  34. package/src/test/protocol.e2e.test.ts +884 -0
  35. package/src/test/queryHelpers.test.ts +524 -0
  36. package/src/test/startBeansMcpServer.test.ts +146 -0
  37. package/src/test/tools-integration.test.ts +912 -0
  38. package/src/test/utils.test.ts +80 -0
  39. package/src/types.ts +46 -0
  40. package/src/utils.ts +20 -0
  41. package/tsconfig.json +24 -0
  42. package/tsup.config.ts +42 -0
  43. package/vitest.config.ts +18 -0
  44. package/index.js +0 -15350
  45. /package/{beans-mcp-server.cjs → dist/beans-mcp-server.cjs} +0 -0
  46. /package/{index.cjs → dist/index.cjs} +0 -0
  47. /package/{index.d.ts → dist/index.d.ts} +0 -0
@@ -0,0 +1,358 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { promisify } from 'node:util';
7
+ import * as graphql from '../internal/graphql';
8
+ import { BeanRecord, GraphQLError } from '../types';
9
+ import { isPathWithinRoot } from '../utils';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /**
14
+ * Interface for backend implementations.
15
+ * Allows for alternative implementations (e.g., test harnesses).
16
+ */
17
+ export interface BackendInterface {
18
+ init(prefix?: string): Promise<Record<string, unknown>>;
19
+ list(options?: { status?: string[]; type?: string[]; search?: string }): Promise<BeanRecord[]>;
20
+ create(input: {
21
+ title: string;
22
+ type: string;
23
+ status?: string;
24
+ priority?: string;
25
+ description?: string;
26
+ parent?: string;
27
+ }): Promise<BeanRecord>;
28
+ update(
29
+ beanId: string,
30
+ updates: {
31
+ status?: string;
32
+ type?: string;
33
+ priority?: string;
34
+ parent?: string;
35
+ clearParent?: boolean;
36
+ blocking?: string[];
37
+ blockedBy?: string[];
38
+ },
39
+ ): Promise<BeanRecord>;
40
+ delete(beanId: string): Promise<Record<string, unknown>>;
41
+ openConfig(): Promise<{ configPath: string; content: string }>;
42
+ graphqlSchema(): Promise<string>;
43
+ readOutputLog(options?: { lines?: number }): Promise<{ path: string; content: string; linesReturned: number }>;
44
+ readBeanFile(relativePath: string): Promise<{ path: string; content: string }>;
45
+ editBeanFile(relativePath: string, content: string): Promise<{ path: string; bytes: number }>;
46
+ createBeanFile(
47
+ relativePath: string,
48
+ content: string,
49
+ options?: { overwrite?: boolean },
50
+ ): Promise<{ path: string; bytes: number; created: boolean }>;
51
+ deleteBeanFile(relativePath: string): Promise<{ path: string; deleted: boolean }>;
52
+ }
53
+
54
+ /**
55
+ * Beans CLI backend implementation.
56
+ * Wraps the Beans CLI and provides a typed interface for MCP tools.
57
+ */
58
+ export class BeansCliBackend implements BackendInterface {
59
+ constructor(
60
+ private readonly workspaceRoot: string,
61
+ private readonly cliPath: string,
62
+ private readonly logDir?: string,
63
+ ) {}
64
+
65
+ /**
66
+ * Returns a safe environment for executing the Beans CLI,
67
+ * whitelisting only necessary variables.
68
+ */
69
+ private getSafeEnv(): NodeJS.ProcessEnv {
70
+ const whitelist = ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'SHELL'];
71
+ const env: NodeJS.ProcessEnv = {};
72
+
73
+ for (const key of whitelist) {
74
+ if (process.env[key]) {
75
+ env[key] = process.env[key];
76
+ }
77
+ }
78
+
79
+ // Include BEANS_ variables
80
+ for (const key in process.env) {
81
+ if (key.startsWith('BEANS_')) {
82
+ env[key] = process.env[key];
83
+ }
84
+ }
85
+
86
+ return env;
87
+ }
88
+
89
+ private getBeansRoot(): string {
90
+ return resolve(this.workspaceRoot, '.beans');
91
+ }
92
+
93
+ private resolveBeanFilePath(relativePath: string): string {
94
+ const cleaned = relativePath.trim().replace(/^\/+/, '');
95
+ if (!cleaned) {
96
+ throw new Error('Path is required');
97
+ }
98
+
99
+ const beansRoot = this.getBeansRoot();
100
+ const target = resolve(beansRoot, cleaned);
101
+
102
+ if (!isPathWithinRoot(beansRoot, target)) {
103
+ throw new Error('Path must stay within .beans directory');
104
+ }
105
+
106
+ return target;
107
+ }
108
+
109
+ /**
110
+ * Execute a GraphQL query via the Beans CLI.
111
+ */
112
+ private async executeGraphQL<T>(
113
+ query: string,
114
+ variables?: Record<string, unknown>,
115
+ ): Promise<{ data: T; errors?: GraphQLError[] }> {
116
+ const args = ['graphql', '--json', query];
117
+
118
+ if (variables) {
119
+ args.push('--variables', JSON.stringify(variables));
120
+ }
121
+
122
+ const { stdout } = await execFileAsync(this.cliPath, args, {
123
+ cwd: this.workspaceRoot,
124
+ env: this.getSafeEnv(),
125
+ maxBuffer: 10 * 1024 * 1024,
126
+ timeout: 30000,
127
+ });
128
+
129
+ try {
130
+ // CLI outputs the data portion directly (e.g. {"beans": [...]})
131
+ // without a {"data": ...} envelope.
132
+ return { data: JSON.parse(stdout) as T };
133
+ } catch (error) {
134
+ throw new Error(
135
+ `Failed to parse Beans CLI GraphQL output: ${(error as Error).message}\nOutput: ${stdout.slice(0, 1000)}`,
136
+ );
137
+ }
138
+ }
139
+
140
+ async init(prefix?: string): Promise<Record<string, unknown>> {
141
+ const args = ['init'];
142
+ if (prefix) {
143
+ args.push('--prefix', prefix);
144
+ }
145
+ await execFileAsync(this.cliPath, args, {
146
+ cwd: this.workspaceRoot,
147
+ env: this.getSafeEnv(),
148
+ maxBuffer: 10 * 1024 * 1024,
149
+ timeout: 30000,
150
+ });
151
+
152
+ return { initialized: true };
153
+ }
154
+
155
+ async list(options?: { status?: string[]; type?: string[]; search?: string }): Promise<BeanRecord[]> {
156
+ const filter: { status?: string[]; type?: string[]; search?: string } = {};
157
+
158
+ if (options?.status && options.status.length > 0) {
159
+ filter.status = options.status;
160
+ }
161
+
162
+ if (options?.type && options.type.length > 0) {
163
+ filter.type = options.type;
164
+ }
165
+
166
+ if (options?.search) {
167
+ filter.search = options.search;
168
+ }
169
+
170
+ const { data, errors } = await this.executeGraphQL<{ beans: BeanRecord[] }>(graphql.LIST_BEANS_QUERY, { filter });
171
+
172
+ if (errors && errors.length > 0) {
173
+ throw new Error(`GraphQL error: ${errors.map(e => e.message).join(', ')}`);
174
+ }
175
+
176
+ return data.beans;
177
+ }
178
+
179
+ async create(input: {
180
+ title: string;
181
+ type: string;
182
+ status?: string;
183
+ priority?: string;
184
+ description?: string;
185
+ parent?: string;
186
+ }): Promise<BeanRecord> {
187
+ const createInput: Record<string, unknown> = {
188
+ title: input.title,
189
+ type: input.type,
190
+ status: input.status,
191
+ priority: input.priority,
192
+ body: input.description,
193
+ parent: input.parent,
194
+ };
195
+
196
+ const { data, errors } = await this.executeGraphQL<{ createBean: BeanRecord }>(graphql.CREATE_BEAN_MUTATION, {
197
+ input: createInput,
198
+ });
199
+
200
+ if (errors && errors.length > 0) {
201
+ throw new Error(`GraphQL error: ${errors.map(e => e.message).join(', ')}`);
202
+ }
203
+
204
+ return data.createBean;
205
+ }
206
+
207
+ async update(
208
+ beanId: string,
209
+ updates: {
210
+ status?: string;
211
+ type?: string;
212
+ priority?: string;
213
+ parent?: string;
214
+ clearParent?: boolean;
215
+ blocking?: string[];
216
+ blockedBy?: string[];
217
+ },
218
+ ): Promise<BeanRecord> {
219
+ const updateInput: Record<string, unknown> = {
220
+ status: updates.status,
221
+ type: updates.type,
222
+ priority: updates.priority,
223
+ };
224
+
225
+ if (updates.parent !== undefined) {
226
+ updateInput.parent = updates.parent;
227
+ } else if (updates.clearParent) {
228
+ updateInput.parent = '';
229
+ }
230
+
231
+ if (updates.blocking) {
232
+ updateInput.addBlocking = updates.blocking;
233
+ }
234
+
235
+ if (updates.blockedBy) {
236
+ updateInput.addBlockedBy = updates.blockedBy;
237
+ }
238
+
239
+ const { data, errors } = await this.executeGraphQL<{ updateBean: BeanRecord }>(graphql.UPDATE_BEAN_MUTATION, {
240
+ id: beanId,
241
+ input: updateInput,
242
+ });
243
+
244
+ if (errors && errors.length > 0) {
245
+ throw new Error(`GraphQL error: ${errors.map(e => e.message).join(', ')}`);
246
+ }
247
+
248
+ return data.updateBean;
249
+ }
250
+
251
+ async delete(beanId: string): Promise<Record<string, unknown>> {
252
+ const { errors } = await this.executeGraphQL<{ deleteBean: boolean }>(graphql.DELETE_BEAN_MUTATION, {
253
+ id: beanId,
254
+ });
255
+
256
+ if (errors && errors.length > 0) {
257
+ throw new Error(`GraphQL error: ${errors.map(e => e.message).join(', ')}`);
258
+ }
259
+
260
+ return { deleted: true, beanId };
261
+ }
262
+
263
+ async openConfig(): Promise<{ configPath: string; content: string }> {
264
+ const configPath = join(this.workspaceRoot, '.beans.yml');
265
+ const content = await readFile(configPath, 'utf8');
266
+ return { configPath, content };
267
+ }
268
+
269
+ async graphqlSchema(): Promise<string> {
270
+ const { stdout } = await execFileAsync(this.cliPath, ['graphql', '--schema'], {
271
+ cwd: this.workspaceRoot,
272
+ env: this.getSafeEnv(),
273
+ maxBuffer: 10 * 1024 * 1024,
274
+ timeout: 30000,
275
+ });
276
+
277
+ return stdout.trim();
278
+ }
279
+
280
+ async readOutputLog(options?: { lines?: number }): Promise<{ path: string; content: string; linesReturned: number }> {
281
+ const outputPath = resolve(
282
+ process.env.BEANS_VSCODE_OUTPUT_LOG || join(this.workspaceRoot, '.vscode', 'logs', 'beans-output.log'),
283
+ );
284
+
285
+ const isWithinWorkspace = isPathWithinRoot(this.workspaceRoot, outputPath);
286
+ const vscodeLogDir =
287
+ process.env.BEANS_VSCODE_LOG_DIR || this.logDir
288
+ ? resolve(process.env.BEANS_VSCODE_LOG_DIR || this.logDir || '')
289
+ : undefined;
290
+ const isWithinVscodeLogDir = vscodeLogDir ? isPathWithinRoot(vscodeLogDir, outputPath) : false;
291
+
292
+ if (!isWithinWorkspace && !isWithinVscodeLogDir) {
293
+ throw new Error('Output log path must stay within the workspace or VS Code log directory');
294
+ }
295
+
296
+ const maxLines = options?.lines && options.lines > 0 ? options.lines : 500;
297
+ const ringBuffer: string[] = [];
298
+
299
+ const stream = createReadStream(outputPath, { encoding: 'utf8' });
300
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
301
+
302
+ for await (const line of rl) {
303
+ if (!line) {
304
+ continue;
305
+ }
306
+
307
+ ringBuffer.push(line);
308
+ if (ringBuffer.length > maxLines) {
309
+ ringBuffer.shift();
310
+ }
311
+ }
312
+
313
+ return {
314
+ path: outputPath,
315
+ content: ringBuffer.join('\n'),
316
+ linesReturned: ringBuffer.length,
317
+ };
318
+ }
319
+
320
+ async readBeanFile(relativePath: string): Promise<{ path: string; content: string }> {
321
+ const absolutePath = this.resolveBeanFilePath(relativePath);
322
+ const content = await readFile(absolutePath, 'utf8');
323
+ return { path: absolutePath, content };
324
+ }
325
+
326
+ async editBeanFile(relativePath: string, content: string): Promise<{ path: string; bytes: number }> {
327
+ const absolutePath = this.resolveBeanFilePath(relativePath);
328
+ await mkdir(dirname(absolutePath), { recursive: true });
329
+ await writeFile(absolutePath, content, 'utf8');
330
+ return { path: absolutePath, bytes: Buffer.byteLength(content, 'utf8') };
331
+ }
332
+
333
+ async createBeanFile(
334
+ relativePath: string,
335
+ content: string,
336
+ options?: { overwrite?: boolean },
337
+ ): Promise<{ path: string; bytes: number; created: boolean }> {
338
+ const absolutePath = this.resolveBeanFilePath(relativePath);
339
+ await mkdir(dirname(absolutePath), { recursive: true });
340
+
341
+ await writeFile(absolutePath, content, {
342
+ encoding: 'utf8',
343
+ flag: options?.overwrite ? 'w' : 'wx',
344
+ });
345
+
346
+ return {
347
+ path: absolutePath,
348
+ bytes: Buffer.byteLength(content, 'utf8'),
349
+ created: true,
350
+ };
351
+ }
352
+
353
+ async deleteBeanFile(relativePath: string): Promise<{ path: string; deleted: boolean }> {
354
+ const absolutePath = this.resolveBeanFilePath(relativePath);
355
+ await rm(absolutePath, { force: false });
356
+ return { path: absolutePath, deleted: true };
357
+ }
358
+ }