@selfagency/beans-mcp 0.1.1 → 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 (29) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/test.yml +4 -0
  3. package/CHANGELOG.md +20 -0
  4. package/codeql/codeql-custom-queries-actions/README.md +14 -0
  5. package/codeql/codeql-custom-queries-actions/codeql-pack.lock.yml +32 -0
  6. package/codeql/codeql-custom-queries-actions/codeql-pack.yml +7 -0
  7. package/codeql/codeql-custom-queries-actions/qlpack.yml +6 -0
  8. package/codeql/codeql-custom-queries-actions/queries/github-script-without-tojson.ql +18 -0
  9. package/codeql/codeql-custom-queries-actions/queries/strict-external-action-pinning.ql +18 -0
  10. package/codeql/codeql-custom-queries-javascript/README.md +14 -0
  11. package/codeql/codeql-custom-queries-javascript/codeql-pack.lock.yml +30 -0
  12. package/codeql/codeql-custom-queries-javascript/codeql-pack.yml +7 -0
  13. package/codeql/codeql-custom-queries-javascript/qlpack.yml +6 -0
  14. package/codeql/codeql-custom-queries-javascript/queries/child-process-shell-apis.ql +26 -0
  15. package/codeql/codeql-custom-queries-javascript/queries/innerhtml-assignment.ql +24 -0
  16. package/dist/beans-mcp-server.cjs +105 -4
  17. package/dist/beans-mcp-server.cjs.map +1 -1
  18. package/dist/index.cjs +105 -4
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.js +105 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/package.json +2 -2
  24. package/package.json +3 -3
  25. package/src/server/BeansMcpServer.ts +23 -0
  26. package/src/server/backend.ts +6 -0
  27. package/src/test/handlers.unit.test.ts +27 -10
  28. package/src/test/utils.test.ts +44 -43
  29. package/src/utils.ts +1 -1
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selfagency/beans-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "MCP (Model Context Protocol) server for Beans issue tracker",
5
5
  "keywords": [
6
6
  "beans",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "https://github.com/selfagency/beans-mcp.git"
18
+ "url": "git+https://github.com/selfagency/beans-mcp.git"
19
19
  },
20
20
  "license": "MIT",
21
21
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selfagency/beans-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "MCP (Model Context Protocol) server for Beans issue tracker",
6
6
  "author": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "https://github.com/selfagency/beans-mcp.git"
17
+ "url": "git+https://github.com/selfagency/beans-mcp.git"
18
18
  },
19
19
  "keywords": [
20
20
  "beans",
@@ -36,7 +36,7 @@
36
36
  "module": "./dist/index.js",
37
37
  "types": "./dist/index.d.ts",
38
38
  "bin": {
39
- "beans-mcp": "./dist/beans-mcp-server.cjs"
39
+ "beans-mcp": "dist/beans-mcp-server.cjs"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -10,6 +10,10 @@ import {
10
10
  MAX_TITLE_LENGTH,
11
11
  } from '../types';
12
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' };
13
17
  import type { BackendInterface } from './backend';
14
18
 
15
19
  export { sortBeans };
@@ -97,6 +101,7 @@ export function updateHandler(backend: BackendInterface) {
97
101
  clearParent?: boolean;
98
102
  blocking?: string[];
99
103
  blockedBy?: string[];
104
+ body?: string;
100
105
  }) =>
101
106
  makeTextAndStructured({
102
107
  bean: await backend.update(input.beanId, {
@@ -107,6 +112,7 @@ export function updateHandler(backend: BackendInterface) {
107
112
  clearParent: input.clearParent,
108
113
  blocking: input.blocking,
109
114
  blockedBy: input.blockedBy,
115
+ body: input.body,
110
116
  }),
111
117
  });
112
118
  }
@@ -293,6 +299,7 @@ function registerTools(server: McpServer, backend: BackendInterface): void {
293
299
  clearParent: z.boolean().optional(),
294
300
  blocking: z.array(z.string().max(MAX_ID_LENGTH)).optional(),
295
301
  blockedBy: z.array(z.string().max(MAX_ID_LENGTH)).optional(),
302
+ body: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
296
303
  }),
297
304
  annotations: {
298
305
  readOnlyHint: false,
@@ -574,6 +581,18 @@ export async function startBeansMcpServer(
574
581
  process.env.BEANS_VSCODE_MCP_PORT = String(port);
575
582
  process.env.BEANS_MCP_PORT = String(port);
576
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
+
577
596
  // Use a mutable delegate so we can hot-swap the workspace after roots discovery
578
597
  // without re-registering the MCP tools.
579
598
  const mutable = new MutableBackend(new BeansCliBackend(workspaceRoot, cliPath, logDir));
@@ -595,6 +614,10 @@ export async function startBeansMcpServer(
595
614
  const rootPath = await resolver(server);
596
615
  if (rootPath) {
597
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 {}
598
621
  }
599
622
  }
600
623
  }
@@ -35,6 +35,7 @@ export interface BackendInterface {
35
35
  clearParent?: boolean;
36
36
  blocking?: string[];
37
37
  blockedBy?: string[];
38
+ body?: string;
38
39
  },
39
40
  ): Promise<BeanRecord>;
40
41
  delete(beanId: string): Promise<Record<string, unknown>>;
@@ -214,6 +215,7 @@ export class BeansCliBackend implements BackendInterface {
214
215
  clearParent?: boolean;
215
216
  blocking?: string[];
216
217
  blockedBy?: string[];
218
+ body?: string;
217
219
  },
218
220
  ): Promise<BeanRecord> {
219
221
  const updateInput: Record<string, unknown> = {
@@ -236,6 +238,10 @@ export class BeansCliBackend implements BackendInterface {
236
238
  updateInput.addBlockedBy = updates.blockedBy;
237
239
  }
238
240
 
241
+ if (updates.body !== undefined) {
242
+ updateInput.body = updates.body;
243
+ }
244
+
239
245
  const { data, errors } = await this.executeGraphQL<{ updateBean: BeanRecord }>(graphql.UPDATE_BEAN_MUTATION, {
240
246
  id: beanId,
241
247
  input: updateInput,
@@ -9,6 +9,7 @@ import {
9
9
  outputHandler,
10
10
  queryHandler,
11
11
  reopenHandler,
12
+ updateHandler,
12
13
  viewHandler,
13
14
  } from '../server/BeansMcpServer';
14
15
 
@@ -75,27 +76,45 @@ describe('Handlers (unit)', () => {
75
76
  const backend = makeBackend();
76
77
  const res = await initHandler(backend)({ prefix: 'pfx' });
77
78
  expect(backend.init).toHaveBeenCalledWith('pfx');
78
- expect(res.structuredContent).toBeDefined();
79
+ const data = JSON.parse(res.content?.[0]?.text ?? '{}');
80
+ expect(data).toBeDefined();
79
81
  });
80
82
 
81
83
  it('viewHandler returns bean structured content', async () => {
82
84
  const backend = makeBackend();
83
85
  const res = await viewHandler(backend)({ beanId: 'b1' });
84
- expect(res.structuredContent.bean.id).toBe('b1');
86
+ const data = JSON.parse(res.content?.[0]?.text ?? '{}');
87
+ expect(data.bean.id).toBe('b1');
85
88
  });
86
89
 
87
90
  it('createHandler delegates to backend.create', async () => {
88
91
  const backend = makeBackend();
89
92
  const res = await createHandler(backend)({ title: 'T', type: 't' });
90
93
  expect(backend.create).toHaveBeenCalled();
91
- expect(res.structuredContent.bean.id).toBe('new');
94
+ const data = JSON.parse(res.content?.[0]?.text ?? '{}');
95
+ expect(data.bean.id).toBe('new');
92
96
  });
93
97
 
94
98
  it('editHandler delegates to backend.update', async () => {
95
99
  const backend = makeBackend();
96
100
  const res = await editHandler(backend)({ beanId: 'b1', status: 'todo' });
97
101
  expect(backend.update).toHaveBeenCalledWith('b1', { status: 'todo' });
98
- expect(res.structuredContent.bean.status).toBe('todo');
102
+ const data = JSON.parse(res.content?.[0]?.text ?? '{}');
103
+ expect(data.bean.status).toBe('todo');
104
+ });
105
+
106
+ it('updateHandler delegates body updates to backend.update', async () => {
107
+ const backend = makeBackend({
108
+ update: vi.fn(async (id: string, updates: any) => ({
109
+ ...sampleBean,
110
+ id,
111
+ ...updates,
112
+ })),
113
+ });
114
+ const res = await updateHandler(backend)({ beanId: 'b1', body: 'new body text' } as any);
115
+ expect(backend.update).toHaveBeenCalledWith('b1', expect.objectContaining({ body: 'new body text' }));
116
+ const data = JSON.parse(res.content?.[0]?.text ?? '{}');
117
+ expect(data.bean.body).toBe('new body text');
99
118
  });
100
119
 
101
120
  it('reopenHandler throws if current status mismatches', async () => {
@@ -117,7 +136,8 @@ describe('Handlers (unit)', () => {
117
136
  targetStatus: 'todo',
118
137
  });
119
138
  expect(backend.update).toHaveBeenCalled();
120
- expect(res.structuredContent.bean.status).toBe('todo');
139
+ const data = JSON.parse(res.content?.[0]?.text ?? '{}');
140
+ expect(data.bean.status).toBe('todo');
121
141
  });
122
142
 
123
143
  it('deleteHandler enforces draft/scrapped unless force', async () => {
@@ -168,11 +188,8 @@ describe('Handlers (unit)', () => {
168
188
  const _r = await outputHandler(backend)({ operation: 'read', lines: 10 });
169
189
  expect(backend.readOutputLog).toHaveBeenCalled();
170
190
  const s = await outputHandler(backend)({ operation: 'show' });
171
- if ('message' in s.structuredContent) {
172
- expect(s.structuredContent.message).toMatch(/When using VS Code UI/);
173
- } else {
174
- throw new Error('expected message in structuredContent');
175
- }
191
+ const data = JSON.parse(s.content?.[0]?.text ?? '{}');
192
+ expect(data.message).toMatch(/When using VS Code UI/);
176
193
  });
177
194
 
178
195
  it('queryHandler delegates to handleQueryOperation', async () => {
@@ -1,80 +1,81 @@
1
- import { describe, expect, it } from "vitest";
2
- import { isPathWithinRoot, makeTextAndStructured } from "../utils";
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isPathWithinRoot, makeTextAndStructured } from '../utils';
3
3
 
4
- describe("isPathWithinRoot", () => {
5
- it("should return true for paths within root", () => {
6
- const root = "/workspace/.beans";
7
- const target = "/workspace/.beans/file.md";
4
+ describe('isPathWithinRoot', () => {
5
+ it('should return true for paths within root', () => {
6
+ const root = '/workspace/.beans';
7
+ const target = '/workspace/.beans/file.md';
8
8
  expect(isPathWithinRoot(root, target)).toBe(true);
9
9
  });
10
10
 
11
- it("should return false for paths outside root", () => {
12
- const root = "/workspace/.beans";
13
- const target = "/workspace/outside.md";
11
+ it('should return false for paths outside root', () => {
12
+ const root = '/workspace/.beans';
13
+ const target = '/workspace/outside.md';
14
14
  expect(isPathWithinRoot(root, target)).toBe(false);
15
15
  });
16
16
 
17
- it("should return false for paths with parent traversal", () => {
18
- const root = "/workspace/.beans";
19
- const target = "/workspace/.beans/../../etc/passwd";
17
+ it('should return false for paths with parent traversal', () => {
18
+ const root = '/workspace/.beans';
19
+ const target = '/workspace/.beans/../../etc/passwd';
20
20
  expect(isPathWithinRoot(root, target)).toBe(false);
21
21
  });
22
22
 
23
- it("should handle relative path normalization", () => {
24
- const root = "/workspace/.beans/";
25
- const target = "/workspace/.beans/subdir/file.md";
23
+ it('should handle relative path normalization', () => {
24
+ const root = '/workspace/.beans/';
25
+ const target = '/workspace/.beans/subdir/file.md';
26
26
  expect(isPathWithinRoot(root, target)).toBe(true);
27
27
  });
28
28
 
29
- it("should return false when root and target are the same", () => {
30
- const root = "/workspace/.beans";
31
- const target = "/workspace/.beans";
29
+ it('should return false when root and target are the same', () => {
30
+ const root = '/workspace/.beans';
31
+ const target = '/workspace/.beans';
32
32
  expect(isPathWithinRoot(root, target)).toBe(false);
33
33
  });
34
34
 
35
- it("should handle paths with trailing slashes", () => {
36
- const root = "/workspace/.beans/";
37
- const target = "/workspace/.beans/file.md";
35
+ it('should handle paths with trailing slashes', () => {
36
+ const root = '/workspace/.beans/';
37
+ const target = '/workspace/.beans/file.md';
38
38
  expect(isPathWithinRoot(root, target)).toBe(true);
39
39
  });
40
40
  });
41
41
 
42
- describe("makeTextAndStructured", () => {
43
- it("should create text and structured content from object", () => {
44
- const input = { name: "test", value: 42 };
42
+ describe('makeTextAndStructured', () => {
43
+ it('should serialize object as JSON text', () => {
44
+ const input = { name: 'test', value: 42 } as const;
45
45
  const result = makeTextAndStructured(input);
46
46
 
47
47
  expect(result.content).toHaveLength(1);
48
- expect(result.content[0].type).toBe("text");
49
- expect(result.structuredContent).toEqual(input);
48
+ expect(result.content[0].type).toBe('text');
49
+ expect(() => JSON.parse(result.content[0].text)).not.toThrow();
50
+ expect(JSON.parse(result.content[0].text)).toEqual(input);
50
51
  });
51
52
 
52
- it("should serialize content as JSON", () => {
53
- const input = { key: "value", nested: { deep: true } };
54
- const result = makeTextAndStructured(input);
53
+ it('should preserve arbitrary fields', () => {
54
+ const input = { message: 'Operation completed', extra: { ok: true } } as const;
55
+ const result = makeTextAndStructured(input as any);
56
+ expect(JSON.parse(result.content[0].text)).toEqual(input);
57
+ });
55
58
 
59
+ it('should include nested bean objects in JSON text', () => {
60
+ const input = { bean: { id: 'b1', title: 'My Bean' } } as const;
61
+ const result = makeTextAndStructured(input as any);
56
62
  const parsed = JSON.parse(result.content[0].text);
57
63
  expect(parsed).toEqual(input);
58
64
  });
59
65
 
60
- it("should format JSON with proper indentation", () => {
61
- const input = { a: 1 };
62
- const result = makeTextAndStructured(input);
63
-
64
- expect(result.content[0].text).toContain("\n");
65
- });
66
-
67
- it("should handle arrays in structured content", () => {
66
+ it('should handle arrays in JSON text', () => {
68
67
  const input = { items: [1, 2, 3] };
69
68
  const result = makeTextAndStructured(input);
70
-
71
- expect(result.structuredContent.items).toEqual([1, 2, 3]);
69
+ const parsed = JSON.parse(result.content[0].text);
70
+ expect(parsed.items).toEqual([1, 2, 3]);
72
71
  });
73
72
 
74
- it("should handle null and undefined values", () => {
75
- const input = { nullValue: null, undefinedValue: undefined };
73
+ it('should handle null and undefined values', () => {
74
+ const input = { nullValue: null, undefinedValue: undefined } as any;
76
75
  const result = makeTextAndStructured(input);
77
-
78
- expect(result.structuredContent.nullValue).toBeNull();
76
+ const parsed = JSON.parse(result.content[0].text);
77
+ expect(parsed.nullValue).toBeNull();
78
+ // Note: undefined properties are omitted by JSON.stringify
79
+ expect(Object.prototype.hasOwnProperty.call(parsed, 'undefinedValue')).toBe(false);
79
80
  });
80
81
  });
package/src/utils.ts CHANGED
@@ -13,8 +13,8 @@ export function isPathWithinRoot(root: string, target: string): boolean {
13
13
  }
14
14
 
15
15
  export function makeTextAndStructured<T extends Record<string, unknown>>(value: T) {
16
+ // Return JSON in text content only to avoid duplicate rendering in some clients.
16
17
  return {
17
18
  content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }],
18
- structuredContent: value,
19
19
  };
20
20
  }