@side-quest/bun-runner 1.0.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @side-quest/bun-runner
2
+
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - Initial release of MCP server runner packages extracted from side-quest-marketplace.
8
+
9
+ - @side-quest/bun-runner: Test execution with bun_runTests, bun_testFile, bun_testCoverage
10
+ - @side-quest/biome-runner: Lint & format with biome_lintCheck, biome_lintFix, biome_formatCheck
11
+ - @side-quest/tsc-runner: Type checking with tsc_check
12
+
13
+ ## 0.0.0
14
+
15
+ Initial development version. See [README](./README.md) for details.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nathan Vale
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @side-quest/bun-runner
2
+
3
+ Bun test runner MCP server for Claude Code. Runs tests with structured, token-efficient output.
4
+
5
+ ## Tools
6
+
7
+ - `bun_runTests` — Run tests with optional pattern filter
8
+ - `bun_testFile` — Run specific test file
9
+ - `bun_testCoverage` — Run tests with coverage report
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ bunx --bun @side-quest/bun-runner
15
+ ```
16
+
17
+ Or in `.mcp.json`:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "bun-runner": {
23
+ "command": "bunx",
24
+ "args": ["--bun", "@side-quest/bun-runner"]
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ ## License
31
+
32
+ MIT
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Bun test output parsing utilities
3
+ *
4
+ * Extracted to a separate file to allow testing without importing mcpez
5
+ */
6
+ interface TestFailure {
7
+ file: string;
8
+ message: string;
9
+ line?: number;
10
+ stack?: string;
11
+ }
12
+ interface TestSummary {
13
+ passed: number;
14
+ failed: number;
15
+ total: number;
16
+ failures: TestFailure[];
17
+ }
18
+ /**
19
+ * Parse bun test output to extract test results
20
+ */
21
+ declare function parseBunTestOutput(output: string): TestSummary;
22
+ export { parseBunTestOutput, TestSummary, TestFailure };
package/dist/index.js ADDED
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // mcp/index.ts
5
+ import {
6
+ createCorrelationId,
7
+ createPluginLogger
8
+ } from "@side-quest/core/logging";
9
+ import { startServer, tool, z } from "@side-quest/core/mcp";
10
+ import {
11
+ createLoggerAdapter,
12
+ ResponseFormat,
13
+ wrapToolHandler
14
+ } from "@side-quest/core/mcp-response";
15
+ import { spawnWithTimeout } from "@side-quest/core/spawn";
16
+ import {
17
+ validatePath,
18
+ validateShellSafePattern
19
+ } from "@side-quest/core/validation";
20
+
21
+ // mcp/parse-utils.ts
22
+ function parseBunTestOutput(output) {
23
+ const failures = [];
24
+ const lines = output.split(`
25
+ `);
26
+ let currentFailure = null;
27
+ let currentTestName;
28
+ for (const line of lines) {
29
+ if (!line)
30
+ continue;
31
+ const failMatch = line.match(/\(fail\)\s+(.+?)\s+\[/);
32
+ if (failMatch) {
33
+ if (currentFailure) {
34
+ currentTestName = failMatch[1];
35
+ currentFailure.message = `${currentTestName}: ${currentFailure.message}`;
36
+ failures.push(currentFailure);
37
+ currentFailure = null;
38
+ }
39
+ continue;
40
+ }
41
+ if (line.includes("✗") || line.startsWith("FAIL ")) {
42
+ if (currentFailure)
43
+ failures.push(currentFailure);
44
+ currentFailure = {
45
+ file: "unknown",
46
+ message: line.trim()
47
+ };
48
+ continue;
49
+ }
50
+ if (line.trim().startsWith("error:")) {
51
+ if (currentFailure) {
52
+ currentFailure.message += `
53
+ ${line.trim()}`;
54
+ } else {
55
+ currentFailure = {
56
+ file: "unknown",
57
+ message: line.trim()
58
+ };
59
+ }
60
+ continue;
61
+ }
62
+ if (currentFailure) {
63
+ if (line.trim().startsWith("at ")) {
64
+ const match = line.match(/\((.+):(\d+):(\d+)\)/) || line.match(/at (.+):(\d+):(\d+)/);
65
+ if (match?.[1] && match[2]) {
66
+ currentFailure.file = match[1];
67
+ currentFailure.line = Number.parseInt(match[2], 10);
68
+ }
69
+ currentFailure.stack = `${currentFailure.stack || ""}${line}
70
+ `;
71
+ } else if (line.trim() && !line.match(/^\d+ \| /)) {
72
+ currentFailure.message += `
73
+ ${line.trim()}`;
74
+ }
75
+ }
76
+ }
77
+ if (currentFailure)
78
+ failures.push(currentFailure);
79
+ const passMatch = output.match(/(\d+) pass/);
80
+ const failMatchNum = output.match(/(\d+) fail/);
81
+ const passed = passMatch?.[1] ? Number.parseInt(passMatch[1], 10) : 0;
82
+ const failed = failMatchNum?.[1] ? Number.parseInt(failMatchNum[1], 10) : failures.length;
83
+ return {
84
+ passed,
85
+ failed,
86
+ total: passed + failed,
87
+ failures
88
+ };
89
+ }
90
+
91
+ // mcp/index.ts
92
+ var { initLogger, getSubsystemLogger } = createPluginLogger({
93
+ name: "bun-runner",
94
+ subsystems: ["mcp"]
95
+ });
96
+ initLogger().catch(console.error);
97
+ var mcpLogger = getSubsystemLogger("mcp");
98
+ function parseBunTestOutputImpl(output) {
99
+ const failures = [];
100
+ const lines = output.split(`
101
+ `);
102
+ let currentFailure = null;
103
+ let currentTestName;
104
+ for (const line of lines) {
105
+ if (!line)
106
+ continue;
107
+ const failMatch = line.match(/\(fail\)\s+(.+?)\s+\[/);
108
+ if (failMatch) {
109
+ if (currentFailure) {
110
+ currentTestName = failMatch[1];
111
+ currentFailure.message = `${currentTestName}: ${currentFailure.message}`;
112
+ failures.push(currentFailure);
113
+ currentFailure = null;
114
+ }
115
+ continue;
116
+ }
117
+ if (line.includes("\u2717") || line.startsWith("FAIL ")) {
118
+ if (currentFailure)
119
+ failures.push(currentFailure);
120
+ currentFailure = {
121
+ file: "unknown",
122
+ message: line.trim()
123
+ };
124
+ continue;
125
+ }
126
+ if (line.trim().startsWith("error:")) {
127
+ if (currentFailure) {
128
+ currentFailure.message += `
129
+ ${line.trim()}`;
130
+ } else {
131
+ currentFailure = {
132
+ file: "unknown",
133
+ message: line.trim()
134
+ };
135
+ }
136
+ continue;
137
+ }
138
+ if (currentFailure) {
139
+ if (line.trim().startsWith("at ")) {
140
+ const match = line.match(/\((.+):(\d+):(\d+)\)/) || line.match(/at (.+):(\d+):(\d+)/);
141
+ if (match?.[1] && match[2]) {
142
+ currentFailure.file = match[1];
143
+ currentFailure.line = Number.parseInt(match[2], 10);
144
+ }
145
+ currentFailure.stack = `${currentFailure.stack || ""}${line}
146
+ `;
147
+ } else if (line.trim() && !line.match(/^\d+ \| /)) {
148
+ currentFailure.message += `
149
+ ${line.trim()}`;
150
+ }
151
+ }
152
+ }
153
+ if (currentFailure)
154
+ failures.push(currentFailure);
155
+ const passMatch = output.match(/(\d+) pass/);
156
+ const failMatchNum = output.match(/(\d+) fail/);
157
+ const passed = passMatch?.[1] ? Number.parseInt(passMatch[1], 10) : 0;
158
+ const failed = failMatchNum?.[1] ? Number.parseInt(failMatchNum[1], 10) : failures.length;
159
+ return {
160
+ passed,
161
+ failed,
162
+ total: passed + failed,
163
+ failures
164
+ };
165
+ }
166
+ async function runBunTests(pattern) {
167
+ const cmd = pattern ? ["bun", "test", pattern] : ["bun", "test"];
168
+ const TIMEOUT_MS = 30000;
169
+ const { stdout, stderr, exitCode, timedOut } = await spawnWithTimeout(cmd, TIMEOUT_MS, { env: { CI: "true" } });
170
+ if (timedOut) {
171
+ return {
172
+ passed: 0,
173
+ failed: 1,
174
+ total: 1,
175
+ failures: [
176
+ {
177
+ file: "timeout",
178
+ message: "Tests timed out after 30 seconds. Possible causes: open handles, infinite loops, or watch mode accidentally enabled."
179
+ }
180
+ ]
181
+ };
182
+ }
183
+ const output = `${stdout}
184
+ ${stderr}`;
185
+ if (exitCode === 0) {
186
+ const passMatch = output.match(/(\d+) pass/);
187
+ const passed = passMatch?.[1] ? Number.parseInt(passMatch[1], 10) : 0;
188
+ return {
189
+ passed,
190
+ failed: 0,
191
+ total: passed,
192
+ failures: []
193
+ };
194
+ }
195
+ return parseBunTestOutputImpl(output);
196
+ }
197
+ async function runBunTestCoverage() {
198
+ const TIMEOUT_MS = 60000;
199
+ const cmd = ["bun", "test", "--coverage"];
200
+ const { stdout, stderr, exitCode, timedOut } = await spawnWithTimeout(cmd, TIMEOUT_MS, { env: { CI: "true" } });
201
+ const output = `${stdout}
202
+ ${stderr}`;
203
+ if (timedOut) {
204
+ return {
205
+ summary: {
206
+ passed: 0,
207
+ failed: 1,
208
+ total: 1,
209
+ failures: [
210
+ {
211
+ file: "timeout",
212
+ message: "Tests timed out after 60 seconds."
213
+ }
214
+ ]
215
+ },
216
+ coverage: { percent: 0, uncovered: [] }
217
+ };
218
+ }
219
+ const summary = exitCode === 0 ? parseBunTestOutputImpl(stdout) : parseBunTestOutputImpl(output);
220
+ const coverageMatch = output.match(/(\d+(?:\.\d+)?)\s*%/);
221
+ const percent = coverageMatch?.[1] ? Number.parseFloat(coverageMatch[1]) : 0;
222
+ const uncovered = [];
223
+ const lines = output.split(`
224
+ `);
225
+ for (const line of lines) {
226
+ const match = line.match(/^([^\s|]+)\s*\|\s*(\d+(?:\.\d+)?)\s*%/);
227
+ if (match?.[1] && match[2]) {
228
+ const file = match[1].trim();
229
+ const fileCoverage = Number.parseFloat(match[2]);
230
+ if (fileCoverage < 50 && file.endsWith(".ts")) {
231
+ uncovered.push(`${file} (${fileCoverage}%)`);
232
+ }
233
+ }
234
+ }
235
+ return {
236
+ summary,
237
+ coverage: { percent, uncovered }
238
+ };
239
+ }
240
+ function formatTestSummary(summary, format = ResponseFormat.MARKDOWN, context) {
241
+ if (format === ResponseFormat.JSON) {
242
+ return JSON.stringify({ ...summary, context }, null, 2);
243
+ }
244
+ if (summary.failed === 0) {
245
+ const ctx = context ? ` in ${context}` : "";
246
+ return `All ${summary.passed} tests passed${ctx}.`;
247
+ }
248
+ let output = `${summary.failed} tests failed${context ? ` in ${context}` : ""} (${summary.passed} passed)
249
+
250
+ `;
251
+ summary.failures.forEach((f, i) => {
252
+ output += `${i + 1}. ${f.file}:${f.line || "?"}
253
+ `;
254
+ output += ` ${f.message.split(`
255
+ `)[0]}
256
+ `;
257
+ if (f.stack) {
258
+ output += `${f.stack.split(`
259
+ `).map((l) => ` ${l}`).join(`
260
+ `)}
261
+ `;
262
+ }
263
+ output += `
264
+ `;
265
+ });
266
+ return output.trim();
267
+ }
268
+ function formatCoverageResult(summary, coverage, format = ResponseFormat.MARKDOWN) {
269
+ if (format === ResponseFormat.JSON) {
270
+ return JSON.stringify({ summary, coverage }, null, 2);
271
+ }
272
+ let output = "";
273
+ if (summary.failed === 0) {
274
+ output += `All ${summary.passed} tests passed.
275
+
276
+ `;
277
+ } else {
278
+ output += `${summary.failed} tests failed (${summary.passed} passed)
279
+
280
+ `;
281
+ }
282
+ output += `Coverage: ${coverage.percent}%
283
+ `;
284
+ if (coverage.uncovered.length > 0) {
285
+ output += `
286
+ Files with low coverage (<50%):
287
+ `;
288
+ coverage.uncovered.forEach((f) => {
289
+ output += ` - ${f}
290
+ `;
291
+ });
292
+ }
293
+ return output.trim();
294
+ }
295
+ tool("bun_runTests", {
296
+ description: "Run tests using Bun and return a concise summary of failures. Use this instead of 'bun test' to save tokens and get structured error reports.",
297
+ inputSchema: {
298
+ pattern: z.string().optional().describe("File pattern or test name to filter tests (e.g., 'auth' or 'login.test.ts')"),
299
+ response_format: z.enum(["markdown", "json"]).optional().default("json").describe("Output format: 'markdown' or 'json' (default)")
300
+ },
301
+ annotations: {
302
+ readOnlyHint: true,
303
+ destructiveHint: false,
304
+ idempotentHint: true,
305
+ openWorldHint: false
306
+ }
307
+ }, wrapToolHandler(async (args, format) => {
308
+ const { pattern } = args;
309
+ if (pattern) {
310
+ validateShellSafePattern(pattern);
311
+ if (pattern.includes("/") || pattern.includes("..")) {
312
+ await validatePath(pattern);
313
+ }
314
+ }
315
+ const summary = await runBunTests(pattern);
316
+ const text = formatTestSummary(summary, format);
317
+ if (summary.failed > 0) {
318
+ const error = new Error(text);
319
+ error.summary = summary;
320
+ throw error;
321
+ }
322
+ return text;
323
+ }, {
324
+ toolName: "bun_runTests",
325
+ logger: createLoggerAdapter(mcpLogger),
326
+ createCid: createCorrelationId
327
+ }));
328
+ tool("bun_testFile", {
329
+ description: "Run tests for a specific file only. More targeted than bun_runTests with a pattern.",
330
+ inputSchema: {
331
+ file: z.string().describe("Path to the test file to run (e.g., 'src/utils.test.ts')"),
332
+ response_format: z.enum(["markdown", "json"]).optional().default("json").describe("Output format: 'markdown' or 'json' (default)")
333
+ },
334
+ annotations: {
335
+ readOnlyHint: true,
336
+ destructiveHint: false,
337
+ idempotentHint: true,
338
+ openWorldHint: false
339
+ }
340
+ }, wrapToolHandler(async (args, format) => {
341
+ const { file } = args;
342
+ const validatedFile = await validatePath(file);
343
+ const summary = await runBunTests(validatedFile);
344
+ const text = formatTestSummary(summary, format, file);
345
+ if (summary.failed > 0) {
346
+ const error = new Error(text);
347
+ error.summary = summary;
348
+ throw error;
349
+ }
350
+ return text;
351
+ }, {
352
+ toolName: "bun_testFile",
353
+ logger: createLoggerAdapter(mcpLogger),
354
+ createCid: createCorrelationId
355
+ }));
356
+ tool("bun_testCoverage", {
357
+ description: "Run tests with code coverage and return a summary. Shows overall coverage percentage and files with low coverage.",
358
+ inputSchema: {
359
+ response_format: z.enum(["markdown", "json"]).optional().default("json").describe("Output format: 'markdown' or 'json' (default)")
360
+ },
361
+ annotations: {
362
+ readOnlyHint: true,
363
+ destructiveHint: false,
364
+ idempotentHint: true,
365
+ openWorldHint: false
366
+ }
367
+ }, wrapToolHandler(async (_args, format) => {
368
+ const { summary, coverage } = await runBunTestCoverage();
369
+ const text = formatCoverageResult(summary, coverage, format);
370
+ if (summary.failed > 0) {
371
+ const error = new Error(text);
372
+ error.summary = summary;
373
+ error.coverage = coverage;
374
+ throw error;
375
+ }
376
+ return text;
377
+ }, {
378
+ toolName: "bun_testCoverage",
379
+ logger: createLoggerAdapter(mcpLogger),
380
+ createCid: createCorrelationId
381
+ }));
382
+ if (__require.main == __require.module) {
383
+ startServer("bun-runner", {
384
+ version: "1.0.0",
385
+ fileLogging: {
386
+ enabled: true,
387
+ subsystems: ["mcp"],
388
+ level: "info"
389
+ }
390
+ });
391
+ }
392
+ export {
393
+ parseBunTestOutput
394
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@side-quest/bun-runner",
3
+ "version": "1.0.0",
4
+ "description": "Bun test runner MCP server — structured, token-efficient test output for Claude Code",
5
+ "author": {
6
+ "name": "Nathan Vale",
7
+ "url": "https://github.com/nathanvale"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "bin": "./dist/index.js",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist/**",
23
+ "README.md",
24
+ "LICENSE",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/nathanvale/side-quest-runners.git",
30
+ "directory": "packages/bun-runner"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/nathanvale/side-quest-runners/issues"
34
+ },
35
+ "homepage": "https://github.com/nathanvale/side-quest-runners/tree/main/packages/bun-runner#readme",
36
+ "keywords": [
37
+ "mcp",
38
+ "claude-code",
39
+ "bun",
40
+ "testing",
41
+ "test-runner"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "provenance": true
46
+ },
47
+ "scripts": {
48
+ "build": "bunx bunup",
49
+ "clean": "rimraf dist 2>/dev/null || true",
50
+ "test": "bun test",
51
+ "test:ci": "TF_BUILD=true bun test",
52
+ "typecheck": "tsc --noEmit"
53
+ },
54
+ "dependencies": {
55
+ "@side-quest/core": "^0.1.1"
56
+ }
57
+ }