@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.
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/test.yml +4 -0
- package/CHANGELOG.md +20 -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/beans-mcp-server.cjs +105 -4
- package/dist/beans-mcp-server.cjs.map +1 -1
- package/dist/index.cjs +105 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +105 -4
- package/dist/index.js.map +1 -1
- package/dist/package.json +2 -2
- package/package.json +3 -3
- package/src/server/BeansMcpServer.ts +23 -0
- package/src/server/backend.ts +6 -0
- package/src/test/handlers.unit.test.ts +27 -10
- package/src/test/utils.test.ts +44 -43
- 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.
|
|
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.
|
|
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": "
|
|
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
|
}
|
package/src/server/backend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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 () => {
|
package/src/test/utils.test.ts
CHANGED
|
@@ -1,80 +1,81 @@
|
|
|
1
|
-
import { describe, expect, it } from
|
|
2
|
-
import { isPathWithinRoot, makeTextAndStructured } from
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isPathWithinRoot, makeTextAndStructured } from '../utils';
|
|
3
3
|
|
|
4
|
-
describe(
|
|
5
|
-
it(
|
|
6
|
-
const root =
|
|
7
|
-
const target =
|
|
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(
|
|
12
|
-
const root =
|
|
13
|
-
const target =
|
|
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(
|
|
18
|
-
const root =
|
|
19
|
-
const target =
|
|
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(
|
|
24
|
-
const root =
|
|
25
|
-
const target =
|
|
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(
|
|
30
|
-
const root =
|
|
31
|
-
const target =
|
|
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(
|
|
36
|
-
const root =
|
|
37
|
-
const target =
|
|
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(
|
|
43
|
-
it(
|
|
44
|
-
const input = { name:
|
|
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(
|
|
49
|
-
expect(result.
|
|
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(
|
|
53
|
-
const input = {
|
|
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(
|
|
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(
|
|
69
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
70
|
+
expect(parsed.items).toEqual([1, 2, 3]);
|
|
72
71
|
});
|
|
73
72
|
|
|
74
|
-
it(
|
|
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(
|
|
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
|
}
|