Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ Commits attachments to an orphan branch. Works with any token.

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

| Install method | MCP command |
| ------------------------- | ------------------------------------- |
| Standalone npm install | `gh-attach mcp --transport stdio` |
| Standalone release binary | `gh-attach mcp --transport stdio` |
| `gh` extension | `gh attach mcp --transport stdio` |
| Install method | MCP command |
| ------------------------- | ----------------------------------------------- |
| Standalone npm install | `gh-attach mcp --transport stdio` |
| Standalone release binary | `gh-attach mcp --transport stdio` |
| `gh` extension | `gh attach mcp --transport stdio` |
| `npx` | `npx -y gh-attach@latest mcp --transport stdio` |

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.
Expand Down
23 changes: 3 additions & 20 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ process.on("warning", (warning) => {
process.stderr.write(`${warning.stack ?? warning.message}\n`);
});

import { readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { Command } from "commander";
import { resolvePackageVersion } from "../core/version.js";
import {
AuthenticationError,
ValidationError,
Expand Down Expand Up @@ -49,25 +47,10 @@ export function getExitCode(err: unknown): number {
}

/**
* Resolves the package version by reading the nearest `package.json`.
*
* Works in both source (`src/cli/`) and dist (`dist/`) layouts.
* Resolves the version for the currently running gh-attach CLI.
*/
export function resolveVersion(): string {
// In pkg binary builds, version is injected at build time
if (process.env.__PKG_VERSION__) {
return process.env.__PKG_VERSION__;
}
const dir = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(
dir,
dir.endsWith("/src/cli") ? "../.." : "..",
"package.json",
);
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
version: string;
};
return pkg.version;
return resolvePackageVersion(import.meta.url);
}

/**
Expand Down
63 changes: 63 additions & 0 deletions src/core/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { existsSync, readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";

/**
* Fallback version used when package metadata cannot be resolved.
*/
export const DEVELOPMENT_VERSION = "0.0.0-development";

const MAX_PACKAGE_SEARCH_DEPTH = 3;

function readPackageVersion(pkgPath: string): string | undefined {
if (!existsSync(pkgPath)) {
return undefined;
}

const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
version?: unknown;
};

if (typeof pkg.version === "string" && pkg.version.length > 0) {
return pkg.version;
}

return undefined;
}

/**
* Resolves the package version for the currently running module.
*
* Walks upward from the provided module URL so the same helper works from
* source files under `src/`, built files under `dist/`, and packaged npm
* installs used by `npx`.
*/
export function resolvePackageVersion(
moduleUrl: string,
fallback = DEVELOPMENT_VERSION,
): string {
if (process.env.__PKG_VERSION__) {
return process.env.__PKG_VERSION__;
}

try {
let currentDir = dirname(fileURLToPath(moduleUrl));

for (let depth = 0; depth <= MAX_PACKAGE_SEARCH_DEPTH; depth += 1) {
const version = readPackageVersion(resolve(currentDir, "package.json"));
if (version) {
return version;
}

const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
} catch {
// Fall through to the development version below.
}

return fallback;
}
18 changes: 3 additions & 15 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { randomUUID } from "crypto";
import { writeFileSync, unlinkSync, readFileSync, existsSync } from "fs";
import { writeFileSync, unlinkSync, existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { createServer } from "http";
Expand Down Expand Up @@ -42,21 +42,9 @@ import {
type UploadStrategy,
type UploadTarget,
} from "../core/types.js";
import { resolvePackageVersion } from "../core/version.js";

// Get package version
function getPackageVersion(): string {
try {
const pkgPath = new URL("../../package.json", import.meta.url).pathname;
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
version: string;
};
return pkg.version;
} catch {
return "0.0.0-development";
}
}

const VERSION = getPackageVersion();
const VERSION = resolvePackageVersion(import.meta.url);
const AUTH_GUIDANCE =
"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.";

Expand Down
18 changes: 17 additions & 1 deletion test/integration/cli/binary.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { execSync } from "child_process";
import { execSync, spawnSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { resolve } from "path";

Expand Down Expand Up @@ -78,6 +78,22 @@ describe("ESM Bundle", () => {
const cjsVersion = runCjs("--version");
expect(esmVersion).toBe(cjsVersion);
});

it("should report the active version when starting MCP over stdio", () => {
const result = spawnSync(
"node",
[ESM_PATH, "mcp", "--transport", "stdio"],
{
encoding: "utf8",
cwd: ROOT,
timeout: 1500,
},
);

expect(result.stderr).toContain(
`[gh-attach MCP] Server started (stdio mode, version ${EXPECTED_VERSION})`,
);
});
});

describe("Binary Artifacts", () => {
Expand Down
10 changes: 8 additions & 2 deletions test/integration/mcp/http-transport.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { mcpInternals } from "../../../src/mcp/index.js";

const ROOT = resolve(import.meta.dirname, "../../..");
const EXPECTED_VERSION =
process.env.GH_ATTACH_BUILD_VERSION ??
JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version;

describe("MCP Streamable HTTP transport", () => {
let baseUrl: URL;
let closeServer: (() => Promise<void>) | undefined;
Expand All @@ -25,8 +32,7 @@ describe("MCP Streamable HTTP transport", () => {

const body = (await res.json()) as { status: string; version: string };
expect(body.status).toBe("ok");
expect(typeof body.version).toBe("string");
expect(body.version.length).toBeGreaterThan(0);
expect(body.version).toBe(EXPECTED_VERSION);
});

it("supports initialize + tools/list + tools/call over Streamable HTTP", async () => {
Expand Down
73 changes: 73 additions & 0 deletions test/unit/core/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { pathToFileURL } from "url";
import {
DEVELOPMENT_VERSION,
resolvePackageVersion,
} from "../../../src/core/version.js";

describe("resolvePackageVersion", () => {
let tempRoot = "";

beforeEach(() => {
delete process.env.__PKG_VERSION__;

tempRoot = mkdtempSync(join(tmpdir(), "gh-attach-version-"));
writeFileSync(
join(tempRoot, "package.json"),
JSON.stringify({ version: "9.9.9" }),
);
mkdirSync(join(tempRoot, "src", "mcp"), { recursive: true });
mkdirSync(join(tempRoot, "dist"), { recursive: true });
});

afterEach(() => {
delete process.env.__PKG_VERSION__;
rmSync(tempRoot, { recursive: true, force: true });
});

it("resolves the package version from source layout paths", () => {
const version = resolvePackageVersion(
pathToFileURL(join(tempRoot, "src", "mcp", "index.ts")).href,
);

expect(version).toBe("9.9.9");
});

it("resolves the package version from dist layout paths", () => {
const version = resolvePackageVersion(
pathToFileURL(join(tempRoot, "dist", "mcp.js")).href,
);

expect(version).toBe("9.9.9");
});

it("prefers the injected build version when present", () => {
process.env.__PKG_VERSION__ = "2.3.4";

const version = resolvePackageVersion(
pathToFileURL(join(tempRoot, "dist", "mcp.js")).href,
);

expect(version).toBe("2.3.4");
});

it("falls back to the development version when package metadata is missing", () => {
const orphanRoot = mkdtempSync(
join(tmpdir(), "gh-attach-version-missing-"),
);
mkdirSync(join(orphanRoot, "dist"), { recursive: true });

try {
const version = resolvePackageVersion(
pathToFileURL(join(orphanRoot, "dist", "mcp.js")).href,
);

expect(version).toBe(DEVELOPMENT_VERSION);
} finally {
rmSync(orphanRoot, { recursive: true, force: true });
}
});
});
Loading