@codescene/codehealth-mcp 0.2.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.
package/bin/cs-mcp.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../lib/index.js";
4
+
5
+ main();
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Downloads and caches the cs-mcp binary from GitHub releases.
3
+ *
4
+ * The binary is cached inside the package's own directory under
5
+ * `.cache/{version}/` so it persists across runs but is cleaned
6
+ * up naturally when the package is reinstalled or upgraded.
7
+ */
8
+
9
+ import { createWriteStream, existsSync, readdirSync, mkdirSync, chmodSync } from "node:fs";
10
+ import { rename, rm } from "node:fs/promises";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { execFileSync } from "node:child_process";
14
+ import https from "node:https";
15
+ import http from "node:http";
16
+ import { getPlatformInfo, getDownloadUrl } from "./platform.js";
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const PACKAGE_ROOT = join(__dirname, "..");
20
+
21
+ /**
22
+ * Returns the directory where binaries are cached for a given version.
23
+ *
24
+ * @param {string} version
25
+ * @returns {string}
26
+ */
27
+ function getCacheDir(version) {
28
+ return join(PACKAGE_ROOT, ".cache", version);
29
+ }
30
+
31
+ /**
32
+ * Returns the expected path of the cached binary for a given version.
33
+ *
34
+ * @param {string} version
35
+ * @returns {string}
36
+ */
37
+ export function getCachedBinaryPath(version) {
38
+ const { binary } = getPlatformInfo();
39
+ return join(getCacheDir(version), binary);
40
+ }
41
+
42
+ /**
43
+ * Checks whether an HTTP response is a redirect with a location header.
44
+ *
45
+ * @param {import("node:http").IncomingMessage} response
46
+ * @returns {boolean}
47
+ */
48
+ function isRedirect(response) {
49
+ const status = response.statusCode ?? 0;
50
+ return status >= 300 && status < 400 && Boolean(response.headers.location);
51
+ }
52
+
53
+ /**
54
+ * Reports download progress to stderr at 10% intervals.
55
+ *
56
+ * @param {number} downloadedBytes
57
+ * @param {number} totalBytes
58
+ * @param {number} lastPercent - The last reported percent value
59
+ * @returns {number} The updated lastPercent value
60
+ */
61
+ function reportProgress(downloadedBytes, totalBytes, lastPercent) {
62
+ if (!totalBytes) return lastPercent;
63
+
64
+ const percent = Math.floor((downloadedBytes / totalBytes) * 100);
65
+ if (percent !== lastPercent && percent % 10 === 0) {
66
+ const mb = (downloadedBytes / 1024 / 1024).toFixed(1);
67
+ process.stderr.write(`\r Downloading... ${percent}% (${mb} MB)`);
68
+ return percent;
69
+ }
70
+ return lastPercent;
71
+ }
72
+
73
+ /**
74
+ * Downloads a file from a URL, following redirects (GitHub releases use 302s).
75
+ *
76
+ * @param {string} url
77
+ * @param {string} destPath
78
+ * @returns {Promise<void>}
79
+ */
80
+ function downloadFile(url, destPath) {
81
+ return new Promise((resolve, reject) => {
82
+ const proto = url.startsWith("https") ? https : http;
83
+
84
+ proto
85
+ .get(url, (response) => {
86
+ if (isRedirect(response)) {
87
+ downloadFile(response.headers.location, destPath)
88
+ .then(resolve)
89
+ .catch(reject);
90
+ response.resume();
91
+ return;
92
+ }
93
+
94
+ if (response.statusCode !== 200) {
95
+ response.resume();
96
+ reject(
97
+ new Error(
98
+ `Download failed: HTTP ${response.statusCode} from ${url}`
99
+ )
100
+ );
101
+ return;
102
+ }
103
+
104
+ const totalBytes = parseInt(response.headers["content-length"], 10);
105
+ let downloadedBytes = 0;
106
+ let lastPercent = -1;
107
+
108
+ const file = createWriteStream(destPath);
109
+ response.on("data", (chunk) => {
110
+ downloadedBytes += chunk.length;
111
+ lastPercent = reportProgress(downloadedBytes, totalBytes, lastPercent);
112
+ });
113
+ response.pipe(file);
114
+ file.on("finish", () => {
115
+ file.close();
116
+ if (totalBytes) {
117
+ process.stderr.write("\n");
118
+ }
119
+ resolve();
120
+ });
121
+ file.on("error", (err) => {
122
+ file.close();
123
+ reject(err);
124
+ });
125
+ })
126
+ .on("error", reject);
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Extracts a zip file to a destination directory.
132
+ *
133
+ * Uses platform-native tools: `unzip` on Unix (available on macOS and
134
+ * most Linux distros) and PowerShell's Expand-Archive on Windows.
135
+ *
136
+ * @param {string} zipPath
137
+ * @param {string} destDir
138
+ * @returns {void}
139
+ */
140
+ function extractZip(zipPath, destDir) {
141
+ if (process.platform === "win32") {
142
+ execFileSync(
143
+ "powershell",
144
+ [
145
+ "-NoProfile",
146
+ "-Command",
147
+ `Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force`,
148
+ ],
149
+ { stdio: "pipe" }
150
+ );
151
+ } else {
152
+ execFileSync("unzip", ["-o", "-q", zipPath, "-d", destDir], {
153
+ stdio: "pipe",
154
+ });
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Downloads and extracts a compressed binary, or downloads a bare binary.
160
+ *
161
+ * @param {string} url - The download URL
162
+ * @param {{ asset: string, compressed: boolean }} platformInfo
163
+ * @param {string} cacheDir - Directory to download into
164
+ * @param {string} binaryPath - Final expected binary path (for uncompressed)
165
+ * @returns {Promise<void>}
166
+ */
167
+ async function downloadAsset(url, platformInfo, cacheDir, binaryPath) {
168
+ if (!platformInfo.compressed) {
169
+ await downloadFile(url, binaryPath);
170
+ return;
171
+ }
172
+
173
+ const zipPath = join(cacheDir, platformInfo.asset);
174
+ try {
175
+ await downloadFile(url, zipPath);
176
+ process.stderr.write(" Extracting...\n");
177
+ extractZip(zipPath, cacheDir);
178
+ } finally {
179
+ await rm(zipPath, { force: true }).catch(() => {});
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Finds and renames a platform-named binary to the canonical name.
185
+ *
186
+ * Zip files from GitHub releases contain binaries like "cs-mcp-linux-amd64"
187
+ * instead of plain "cs-mcp". This function locates such a candidate and
188
+ * renames it.
189
+ *
190
+ * @param {string} cacheDir
191
+ * @param {string} binaryPath - The expected canonical binary path
192
+ * @returns {Promise<void>}
193
+ */
194
+ async function renameExtractedBinary(cacheDir, binaryPath) {
195
+ if (existsSync(binaryPath)) return;
196
+
197
+ const files = readdirSync(cacheDir);
198
+ const candidate = files.find(
199
+ (f) => f.startsWith("cs-mcp") && !f.endsWith(".zip")
200
+ );
201
+
202
+ if (candidate) {
203
+ await rename(join(cacheDir, candidate), binaryPath);
204
+ } else {
205
+ throw new Error(
206
+ `Binary not found after download. Expected cs-mcp in ${cacheDir}. ` +
207
+ `Found: ${files.join(", ")}`
208
+ );
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Ensures the cs-mcp binary is available for the given version.
214
+ *
215
+ * If the binary is already cached, returns its path immediately.
216
+ * Otherwise, downloads it from GitHub releases, extracts if needed,
217
+ * and caches it for future use.
218
+ *
219
+ * @param {string} version - The package version (e.g. "0.2.1")
220
+ * @returns {Promise<string>} Path to the binary
221
+ */
222
+ export async function ensureBinary(version) {
223
+ const binaryPath = getCachedBinaryPath(version);
224
+
225
+ if (existsSync(binaryPath)) {
226
+ return binaryPath;
227
+ }
228
+
229
+ const platformInfo = getPlatformInfo();
230
+ const url = getDownloadUrl(version, platformInfo.asset);
231
+ const cacheDir = getCacheDir(version);
232
+
233
+ mkdirSync(cacheDir, { recursive: true });
234
+
235
+ process.stderr.write(
236
+ `CodeScene MCP Server v${version} - downloading for ${process.platform}/${process.arch}...\n`
237
+ );
238
+
239
+ await downloadAsset(url, platformInfo, cacheDir, binaryPath);
240
+ await renameExtractedBinary(cacheDir, binaryPath);
241
+
242
+ if (process.platform !== "win32") {
243
+ chmodSync(binaryPath, 0o755);
244
+ }
245
+
246
+ process.stderr.write(` Ready: ${binaryPath}\n`);
247
+ return binaryPath;
248
+ }
package/lib/index.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Main orchestrator for the npm wrapper.
3
+ *
4
+ * Resolves the binary path (from CS_MCP_BINARY_PATH or by downloading
5
+ * from GitHub releases) and launches it.
6
+ */
7
+
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { join, dirname, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { ensureBinary } from "./download.js";
12
+ import { runBinary } from "./run.js";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const PACKAGE_ROOT = join(__dirname, "..");
16
+
17
+ /**
18
+ * Reads the package version from package.json.
19
+ *
20
+ * @returns {string}
21
+ */
22
+ function getPackageVersion() {
23
+ const pkgPath = join(PACKAGE_ROOT, "package.json");
24
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
25
+ return pkg.version;
26
+ }
27
+
28
+ /**
29
+ * Main entry point.
30
+ *
31
+ * Resolution order:
32
+ * 1. CS_MCP_BINARY_PATH env var - use the specified binary directly
33
+ * 2. Cached binary for the current package version
34
+ * 3. Download from GitHub releases and cache
35
+ *
36
+ * All command-line arguments (except the node binary and script path)
37
+ * are forwarded to the cs-mcp binary.
38
+ */
39
+ export async function main() {
40
+ const args = process.argv.slice(2);
41
+
42
+ try {
43
+ const binaryPath = await resolveBinaryPath();
44
+ runBinary(binaryPath, args);
45
+ } catch (err) {
46
+ process.stderr.write(`Error: ${err.message}\n`);
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Resolves the path to the cs-mcp binary.
53
+ *
54
+ * @returns {Promise<string>}
55
+ */
56
+ async function resolveBinaryPath() {
57
+ // 1. Check for explicit binary path override
58
+ const envPath = process.env.CS_MCP_BINARY_PATH;
59
+ if (envPath) {
60
+ const resolved = resolve(envPath);
61
+ if (!existsSync(resolved)) {
62
+ throw new Error(
63
+ `CS_MCP_BINARY_PATH is set to "${envPath}" but the file does not exist.`
64
+ );
65
+ }
66
+ return resolved;
67
+ }
68
+
69
+ // 2. Download or use cached binary matching this package version
70
+ const version = getPackageVersion();
71
+ return ensureBinary(version);
72
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Platform and architecture detection for downloading the correct binary.
3
+ *
4
+ * Maps Node.js platform/arch identifiers to the GitHub release asset names
5
+ * used by the codescene-mcp-server build pipeline.
6
+ */
7
+
8
+ const PLATFORM_MAP = {
9
+ "darwin-arm64": {
10
+ asset: "cs-mcp-macos-aarch64.zip",
11
+ binary: "cs-mcp",
12
+ compressed: true,
13
+ },
14
+ "darwin-x64": {
15
+ asset: "cs-mcp-macos-amd64.zip",
16
+ binary: "cs-mcp",
17
+ compressed: true,
18
+ },
19
+ "linux-arm64": {
20
+ asset: "cs-mcp-linux-aarch64.zip",
21
+ binary: "cs-mcp",
22
+ compressed: true,
23
+ },
24
+ "linux-x64": {
25
+ asset: "cs-mcp-linux-amd64.zip",
26
+ binary: "cs-mcp",
27
+ compressed: true,
28
+ },
29
+ "win32-x64": {
30
+ asset: "cs-mcp-windows-amd64.exe",
31
+ binary: "cs-mcp.exe",
32
+ compressed: false,
33
+ },
34
+ };
35
+
36
+ /**
37
+ * Returns the platform info for the current OS and architecture.
38
+ *
39
+ * @returns {{ asset: string, binary: string, compressed: boolean }}
40
+ * @throws {Error} If the current platform/arch combination is unsupported.
41
+ */
42
+ export function getPlatformInfo() {
43
+ const key = `${process.platform}-${process.arch}`;
44
+ const info = PLATFORM_MAP[key];
45
+
46
+ if (!info) {
47
+ const supported = Object.keys(PLATFORM_MAP)
48
+ .map((k) => k.replace("-", "/"))
49
+ .join(", ");
50
+ throw new Error(
51
+ `Unsupported platform: ${process.platform}/${process.arch}. ` +
52
+ `Supported platforms: ${supported}`
53
+ );
54
+ }
55
+
56
+ return info;
57
+ }
58
+
59
+ /**
60
+ * Constructs the download URL for a given version and asset.
61
+ *
62
+ * By default, downloads from GitHub releases. Set CS_MCP_DOWNLOAD_BASE_URL
63
+ * to override the base URL (useful for testing, mirrors, or air-gapped
64
+ * environments). The env var should include the full base up to the tag
65
+ * directory, e.g. "http://localhost:8080/releases/download".
66
+ *
67
+ * @param {string} version - The package version (e.g. "0.2.1")
68
+ * @param {string} asset - The asset filename (e.g. "cs-mcp-macos-aarch64.zip")
69
+ * @returns {string} The full download URL
70
+ */
71
+ export function getDownloadUrl(version, asset) {
72
+ const tag = `MCP-${version}`;
73
+ const baseUrl =
74
+ process.env.CS_MCP_DOWNLOAD_BASE_URL ||
75
+ "https://github.com/codescene-oss/codescene-mcp-server/releases/download";
76
+ return `${baseUrl}/${tag}/${asset}`;
77
+ }
package/lib/run.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Spawns the cs-mcp binary as a child process.
3
+ *
4
+ * Uses `stdio: "inherit"` so that stdin, stdout, and stderr are passed
5
+ * directly through to the child process. This is critical for the MCP
6
+ * stdio transport where the MCP client communicates via JSON-RPC over
7
+ * the process's stdin/stdout.
8
+ *
9
+ * Signals are forwarded to the child process so that graceful shutdown
10
+ * works as expected.
11
+ */
12
+
13
+ import { spawnSync } from "node:child_process";
14
+
15
+ /**
16
+ * Runs the cs-mcp binary, forwarding all stdio and signals.
17
+ *
18
+ * This function does not return until the binary exits.
19
+ * The current process exits with the same exit code as the binary.
20
+ *
21
+ * @param {string} binaryPath - Absolute path to the cs-mcp binary
22
+ * @param {string[]} args - Command-line arguments to pass through
23
+ * @returns {never}
24
+ */
25
+ export function runBinary(binaryPath, args) {
26
+ const result = spawnSync(binaryPath, args, {
27
+ stdio: "inherit",
28
+ env: process.env,
29
+ windowsHide: true,
30
+ });
31
+
32
+ if (result.error) {
33
+ // Handle spawn errors (e.g. binary not found, permission denied)
34
+ if (result.error.code === "ENOENT") {
35
+ process.stderr.write(`Error: Binary not found at ${binaryPath}\n`);
36
+ process.exit(127);
37
+ }
38
+ if (result.error.code === "EACCES") {
39
+ process.stderr.write(
40
+ `Error: Permission denied executing ${binaryPath}\n`
41
+ );
42
+ process.exit(126);
43
+ }
44
+ throw result.error;
45
+ }
46
+
47
+ // Exit with the same code as the child process.
48
+ // If the child was killed by a signal, use 128 + signal number convention.
49
+ if (result.status !== null) {
50
+ process.exit(result.status);
51
+ }
52
+ if (result.signal) {
53
+ // Convert signal name to number (e.g. SIGTERM -> 15)
54
+ const signalNumbers = { SIGTERM: 15, SIGINT: 2, SIGKILL: 9, SIGHUP: 1 };
55
+ const sigNum = signalNumbers[result.signal] || 1;
56
+ process.exit(128 + sigNum);
57
+ }
58
+ process.exit(1);
59
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@codescene/codehealth-mcp",
3
+ "version": "0.2.1",
4
+ "description": "CodeScene MCP Server — Code Health analysis for AI coding agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "cs-mcp": "./bin/cs-mcp.js"
8
+ },
9
+ "scripts": {
10
+ "test": "c8 node --experimental-test-module-mocks --test tests/*.test.js",
11
+ "test:ci": "c8 --reporter=cobertura --reporter=text node --experimental-test-module-mocks --test tests/*.test.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "lib/"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "os": [
21
+ "darwin",
22
+ "linux",
23
+ "win32"
24
+ ],
25
+ "cpu": [
26
+ "x64",
27
+ "arm64"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/codescene-oss/codescene-mcp-server.git",
33
+ "directory": "npm"
34
+ },
35
+ "homepage": "https://codescene.com",
36
+ "keywords": [
37
+ "codescene",
38
+ "code-health",
39
+ "mcp",
40
+ "model-context-protocol",
41
+ "ai",
42
+ "code-quality"
43
+ ],
44
+ "devDependencies": {
45
+ "c8": "^11.0.0"
46
+ }
47
+ }