Skip to content

Commit 3863102

Browse files
AddonoCopilot
andauthored
fix: report active package version in MCP startup (#88)
Resolve MCP version labels from the running gh-attach package metadata so packaged npx installs report the active tool version instead of the development fallback. Add regression coverage for source and dist version resolution, the HTTP health endpoint, and the built CLI MCP startup log. Also format README to satisfy the repo's existing format check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 710a477 commit 3863102

7 files changed

Lines changed: 172 additions & 43 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,11 @@ Commits attachments to an orphan branch. Works with any token.
145145

146146
Choose the MCP command that matches how you installed `gh-attach`:
147147

148-
| Install method | MCP command |
149-
| ------------------------- | ------------------------------------- |
150-
| Standalone npm install | `gh-attach mcp --transport stdio` |
151-
| Standalone release binary | `gh-attach mcp --transport stdio` |
152-
| `gh` extension | `gh attach mcp --transport stdio` |
148+
| Install method | MCP command |
149+
| ------------------------- | ----------------------------------------------- |
150+
| Standalone npm install | `gh-attach mcp --transport stdio` |
151+
| Standalone release binary | `gh-attach mcp --transport stdio` |
152+
| `gh` extension | `gh attach mcp --transport stdio` |
153153
| `npx` | `npx -y gh-attach@latest mcp --transport stdio` |
154154

155155
When the MCP client supports elicitation, `upload_image` can prompt for a GitHub token during the same tool call and continue the upload without requiring a separate `login` step first.

src/cli/index.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ process.on("warning", (warning) => {
1212
process.stderr.write(`${warning.stack ?? warning.message}\n`);
1313
});
1414

15-
import { readFileSync } from "fs";
16-
import { dirname, resolve } from "path";
17-
import { fileURLToPath } from "url";
1815
import { Command } from "commander";
16+
import { resolvePackageVersion } from "../core/version.js";
1917
import {
2018
AuthenticationError,
2119
ValidationError,
@@ -49,25 +47,10 @@ export function getExitCode(err: unknown): number {
4947
}
5048

5149
/**
52-
* Resolves the package version by reading the nearest `package.json`.
53-
*
54-
* Works in both source (`src/cli/`) and dist (`dist/`) layouts.
50+
* Resolves the version for the currently running gh-attach CLI.
5551
*/
5652
export function resolveVersion(): string {
57-
// In pkg binary builds, version is injected at build time
58-
if (process.env.__PKG_VERSION__) {
59-
return process.env.__PKG_VERSION__;
60-
}
61-
const dir = dirname(fileURLToPath(import.meta.url));
62-
const pkgPath = resolve(
63-
dir,
64-
dir.endsWith("/src/cli") ? "../.." : "..",
65-
"package.json",
66-
);
67-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
68-
version: string;
69-
};
70-
return pkg.version;
53+
return resolvePackageVersion(import.meta.url);
7154
}
7255

7356
/**

src/core/version.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { existsSync, readFileSync } from "fs";
2+
import { dirname, resolve } from "path";
3+
import { fileURLToPath } from "url";
4+
5+
/**
6+
* Fallback version used when package metadata cannot be resolved.
7+
*/
8+
export const DEVELOPMENT_VERSION = "0.0.0-development";
9+
10+
const MAX_PACKAGE_SEARCH_DEPTH = 3;
11+
12+
function readPackageVersion(pkgPath: string): string | undefined {
13+
if (!existsSync(pkgPath)) {
14+
return undefined;
15+
}
16+
17+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
18+
version?: unknown;
19+
};
20+
21+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
22+
return pkg.version;
23+
}
24+
25+
return undefined;
26+
}
27+
28+
/**
29+
* Resolves the package version for the currently running module.
30+
*
31+
* Walks upward from the provided module URL so the same helper works from
32+
* source files under `src/`, built files under `dist/`, and packaged npm
33+
* installs used by `npx`.
34+
*/
35+
export function resolvePackageVersion(
36+
moduleUrl: string,
37+
fallback = DEVELOPMENT_VERSION,
38+
): string {
39+
if (process.env.__PKG_VERSION__) {
40+
return process.env.__PKG_VERSION__;
41+
}
42+
43+
try {
44+
let currentDir = dirname(fileURLToPath(moduleUrl));
45+
46+
for (let depth = 0; depth <= MAX_PACKAGE_SEARCH_DEPTH; depth += 1) {
47+
const version = readPackageVersion(resolve(currentDir, "package.json"));
48+
if (version) {
49+
return version;
50+
}
51+
52+
const parentDir = resolve(currentDir, "..");
53+
if (parentDir === currentDir) {
54+
break;
55+
}
56+
currentDir = parentDir;
57+
}
58+
} catch {
59+
// Fall through to the development version below.
60+
}
61+
62+
return fallback;
63+
}

src/mcp/index.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { randomUUID } from "crypto";
8-
import { writeFileSync, unlinkSync, readFileSync, existsSync } from "fs";
8+
import { writeFileSync, unlinkSync, existsSync } from "fs";
99
import { tmpdir } from "os";
1010
import { join } from "path";
1111
import { createServer } from "http";
@@ -42,21 +42,9 @@ import {
4242
type UploadStrategy,
4343
type UploadTarget,
4444
} from "../core/types.js";
45+
import { resolvePackageVersion } from "../core/version.js";
4546

46-
// Get package version
47-
function getPackageVersion(): string {
48-
try {
49-
const pkgPath = new URL("../../package.json", import.meta.url).pathname;
50-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
51-
version: string;
52-
};
53-
return pkg.version;
54-
} catch {
55-
return "0.0.0-development";
56-
}
57-
}
58-
59-
const VERSION = getPackageVersion();
47+
const VERSION = resolvePackageVersion(import.meta.url);
6048
const AUTH_GUIDANCE =
6149
"No authentication available. Set GITHUB_TOKEN (or GH_TOKEN), run 'gh-attach login' to save a session token, provide GH_ATTACH_COOKIES, or authenticate with the GitHub CLI. If multiple gh accounts are signed in, use 'gh auth status' to inspect them and 'gh auth token --user <login>' to verify the right identity.";
6250

test/integration/cli/binary.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { execSync } from "child_process";
2+
import { execSync, spawnSync } from "child_process";
33
import { existsSync, readFileSync } from "fs";
44
import { resolve } from "path";
55

@@ -78,6 +78,22 @@ describe("ESM Bundle", () => {
7878
const cjsVersion = runCjs("--version");
7979
expect(esmVersion).toBe(cjsVersion);
8080
});
81+
82+
it("should report the active version when starting MCP over stdio", () => {
83+
const result = spawnSync(
84+
"node",
85+
[ESM_PATH, "mcp", "--transport", "stdio"],
86+
{
87+
encoding: "utf8",
88+
cwd: ROOT,
89+
timeout: 1500,
90+
},
91+
);
92+
93+
expect(result.stderr).toContain(
94+
`[gh-attach MCP] Server started (stdio mode, version ${EXPECTED_VERSION})`,
95+
);
96+
});
8197
});
8298

8399
describe("Binary Artifacts", () => {

test/integration/mcp/http-transport.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { readFileSync } from "fs";
3+
import { resolve } from "path";
24
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
35
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
46
import { mcpInternals } from "../../../src/mcp/index.js";
57

8+
const ROOT = resolve(import.meta.dirname, "../../..");
9+
const EXPECTED_VERSION =
10+
process.env.GH_ATTACH_BUILD_VERSION ??
11+
JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version;
12+
613
describe("MCP Streamable HTTP transport", () => {
714
let baseUrl: URL;
815
let closeServer: (() => Promise<void>) | undefined;
@@ -25,8 +32,7 @@ describe("MCP Streamable HTTP transport", () => {
2532

2633
const body = (await res.json()) as { status: string; version: string };
2734
expect(body.status).toBe("ok");
28-
expect(typeof body.version).toBe("string");
29-
expect(body.version.length).toBeGreaterThan(0);
35+
expect(body.version).toBe(EXPECTED_VERSION);
3036
});
3137

3238
it("supports initialize + tools/list + tools/call over Streamable HTTP", async () => {

test/unit/core/version.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, it, expect, afterEach, beforeEach } from "vitest";
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
3+
import { tmpdir } from "os";
4+
import { join } from "path";
5+
import { pathToFileURL } from "url";
6+
import {
7+
DEVELOPMENT_VERSION,
8+
resolvePackageVersion,
9+
} from "../../../src/core/version.js";
10+
11+
describe("resolvePackageVersion", () => {
12+
let tempRoot = "";
13+
14+
beforeEach(() => {
15+
delete process.env.__PKG_VERSION__;
16+
17+
tempRoot = mkdtempSync(join(tmpdir(), "gh-attach-version-"));
18+
writeFileSync(
19+
join(tempRoot, "package.json"),
20+
JSON.stringify({ version: "9.9.9" }),
21+
);
22+
mkdirSync(join(tempRoot, "src", "mcp"), { recursive: true });
23+
mkdirSync(join(tempRoot, "dist"), { recursive: true });
24+
});
25+
26+
afterEach(() => {
27+
delete process.env.__PKG_VERSION__;
28+
rmSync(tempRoot, { recursive: true, force: true });
29+
});
30+
31+
it("resolves the package version from source layout paths", () => {
32+
const version = resolvePackageVersion(
33+
pathToFileURL(join(tempRoot, "src", "mcp", "index.ts")).href,
34+
);
35+
36+
expect(version).toBe("9.9.9");
37+
});
38+
39+
it("resolves the package version from dist layout paths", () => {
40+
const version = resolvePackageVersion(
41+
pathToFileURL(join(tempRoot, "dist", "mcp.js")).href,
42+
);
43+
44+
expect(version).toBe("9.9.9");
45+
});
46+
47+
it("prefers the injected build version when present", () => {
48+
process.env.__PKG_VERSION__ = "2.3.4";
49+
50+
const version = resolvePackageVersion(
51+
pathToFileURL(join(tempRoot, "dist", "mcp.js")).href,
52+
);
53+
54+
expect(version).toBe("2.3.4");
55+
});
56+
57+
it("falls back to the development version when package metadata is missing", () => {
58+
const orphanRoot = mkdtempSync(
59+
join(tmpdir(), "gh-attach-version-missing-"),
60+
);
61+
mkdirSync(join(orphanRoot, "dist"), { recursive: true });
62+
63+
try {
64+
const version = resolvePackageVersion(
65+
pathToFileURL(join(orphanRoot, "dist", "mcp.js")).href,
66+
);
67+
68+
expect(version).toBe(DEVELOPMENT_VERSION);
69+
} finally {
70+
rmSync(orphanRoot, { recursive: true, force: true });
71+
}
72+
});
73+
});

0 commit comments

Comments
 (0)