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
9 changes: 7 additions & 2 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod";
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";

// Read name/version from this package's own package.json (always present in any install --
// global, npx, or local -- npm ships it regardless of the "files" allowlist) instead of hand-synced
// literals, so a release bump never has a second place to forget.
const ownPackageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));

const defaultApiUrl = "https://gittensory-api.aethereal.dev";
const legacyDefaultApiUrls = new Set(["https://gittensory-api.zeronode.workers.dev"]);
const packageName = "@jsonbored/gittensory-mcp";
const packageVersion = "0.7.0";
const packageName = ownPackageJson.name;
const packageVersion = ownPackageJson.version;
const npmRegistryUrl = (process.env.GITTENSORY_NPM_REGISTRY_URL ?? "https://registry.npmjs.org").replace(/\/+$/, "");
const upgradeCommand = `npm install -g ${packageName}@latest`;
const npxFallbackCommand = `npx ${packageName}@latest <command>`;
Expand Down
9 changes: 8 additions & 1 deletion src/services/mcp-compatibility.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// Derives the recommended version from the actual published package instead of a second hand-synced
// literal (src/server.ts:14 uses the same pattern for the root package.json) -- a release bump now has
// one fewer place to forget. Deploys with the Worker on merge, same as today's manual-sync timing: this
// advertises the new version to /health and /v1/mcp/compatibility as soon as the release PR merges,
// before the npm package itself is actually tagged/published.
import gittensoryMcpPackageJson from "../../packages/gittensory-mcp/package.json";

export const GITTENSORY_API_VERSION = "0.1.0";
export const GITTENSORY_MCP_PACKAGE_NAME = "@jsonbored/gittensory-mcp";
export const MINIMUM_SUPPORTED_MCP_VERSION = "0.5.0";
export const LATEST_RECOMMENDED_MCP_VERSION = "0.7.0";
export const LATEST_RECOMMENDED_MCP_VERSION: string = gittensoryMcpPackageJson.version;

export type McpCompatibilityStatus = "current" | "stale" | "incompatible" | "unknown";

Expand Down
5 changes: 3 additions & 2 deletions test/unit/mcp-cli-basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeFixtureServer, createPacketRepo, run, runAsync, startFixtureServer } from "./support/mcp-cli-harness";
import mcpPackageJson from "../../packages/gittensory-mcp/package.json";

describe("gittensory-mcp CLI — basics", () => {
let tempDir: string | null = null;
Expand Down Expand Up @@ -118,7 +119,7 @@ describe("gittensory-mcp CLI — basics", () => {
});

it("reports the package version via version, --version, and -v", () => {
const expected = "@jsonbored/gittensory-mcp/0.7.0";
const expected = `@jsonbored/gittensory-mcp/${mcpPackageJson.version}`;
for (const flag of ["version", "--version", "-v"]) {
const plain = run([flag]).trim();
expect(plain).toContain(expected);
Expand All @@ -131,7 +132,7 @@ describe("gittensory-mcp CLI — basics", () => {
it("emits machine-readable version output with --json", () => {
const payload = JSON.parse(run(["version", "--json"])) as { name: string; version: string; apiVersion: string; node: string };
expect(payload.name).toBe("@jsonbored/gittensory-mcp");
expect(payload.version).toBe("0.7.0");
expect(payload.version).toBe(mcpPackageJson.version);
expect(payload.apiVersion).toBe("0.1.0");
expect(payload.node).toBe(process.version);
});
Expand Down
23 changes: 16 additions & 7 deletions test/unit/mcp-cli-doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { bin, closeFixtureServer, createPacketRepo, git, runAsync, startFixtureServer } from "./support/mcp-cli-harness";
import mcpPackageJson from "../../packages/gittensory-mcp/package.json";

// A "higher-core prerelease" fixture (release outranks any prerelease of the same core, but a
// HIGHER-core prerelease still beats a lower-core release) needs a version strictly above the local
// package's own -- computed instead of hardcoded so it stays correct across every future release.
const oneMinorAboveLocal = (() => {
const [major, minor] = mcpPackageJson.version.split(".").map(Number) as [number, number, number];
return `${major}.${minor + 1}.0`;
})();

describe("gittensory-mcp CLI — doctor", () => {
let tempDir: string | null = null;
Expand Down Expand Up @@ -146,7 +155,7 @@ describe("gittensory-mcp CLI — doctor", () => {

it("reports a current install without upgrade guidance", async () => {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer({ latestVersion: "0.7.0", minMcpVersion: "0.5.0" });
const url = await startFixtureServer({ latestVersion: mcpPackageJson.version, minMcpVersion: "0.5.0" });
const payload = JSON.parse(
await runAsync(["status", "--json"], {
GITTENSORY_API_URL: url,
Expand All @@ -166,7 +175,7 @@ describe("gittensory-mcp CLI — doctor", () => {
status: "compatible",
source: "compatibility_endpoint",
minVersion: "0.5.0",
latestRecommendedVersion: "0.7.0",
latestRecommendedVersion: mcpPackageJson.version,
apiVersion: "0.1.0",
});
});
Expand All @@ -186,8 +195,8 @@ describe("gittensory-mcp CLI — doctor", () => {
expect(ahead.package).toMatchObject({ state: "ahead", updateAvailable: false });
await closeFixtureServer();

// Local 0.7.0 vs a higher-core prerelease 0.8.0-rc.1 -> stale.
const staleUrl = await startFixtureServer({ latestVersion: "0.8.0-rc.1" });
// Local (mcpPackageJson.version) vs a higher-core prerelease (one minor above) -> stale.
const staleUrl = await startFixtureServer({ latestVersion: `${oneMinorAboveLocal}-rc.1` });
const stale = JSON.parse(
await runAsync(["status", "--json"], {
GITTENSORY_API_URL: staleUrl,
Expand Down Expand Up @@ -227,7 +236,7 @@ describe("gittensory-mcp CLI — doctor", () => {

it("flags a stale install in doctor with upgrade remediation", async () => {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer({ latestVersion: "1.0.0" });
const url = await startFixtureServer({ latestVersion: oneMinorAboveLocal });
const payload = JSON.parse(
await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], {
GITTENSORY_API_URL: url,
Expand Down Expand Up @@ -282,7 +291,7 @@ describe("gittensory-mcp CLI — doctor", () => {

it("uses API recommended package metadata when the npm registry is unavailable", async () => {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer({ npmStatus: 500, latestRecommendedMcpVersion: "0.8.0" });
const url = await startFixtureServer({ npmStatus: 500, latestRecommendedMcpVersion: oneMinorAboveLocal });
const payload = JSON.parse(
await runAsync(["status", "--json"], {
GITTENSORY_API_URL: url,
Expand All @@ -294,7 +303,7 @@ describe("gittensory-mcp CLI — doctor", () => {
expect(payload.package).toMatchObject({
state: "stale",
latestStatus: "api",
latestVersion: "0.8.0",
latestVersion: oneMinorAboveLocal,
upgradeCommand: "npm install -g @jsonbored/gittensory-mcp@latest",
});
});
Expand Down
7 changes: 4 additions & 3 deletions test/unit/mcp-cli-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeFixtureServer, run, runAsync, startFixtureServer } from "./support/mcp-cli-harness";
import mcpPackageJson from "../../packages/gittensory-mcp/package.json";

describe("gittensory-mcp CLI — profiles", () => {
let tempDir: string | null = null;
Expand Down Expand Up @@ -157,12 +158,12 @@ describe("gittensory-mcp CLI — profiles", () => {
}),
) as { package: { name: string; version: string; latestStatus: string }; api: { status: string }; auth: { login: string } };

expect(status.package).toMatchObject({ name: "@jsonbored/gittensory-mcp", version: "0.7.0", latestStatus: "skipped" });
expect(status.package).toMatchObject({ name: "@jsonbored/gittensory-mcp", version: mcpPackageJson.version, latestStatus: "skipped" });
expect(status.api.status).toBe("ok");
expect(status.auth.login).toBe("JSONbored");

const changelog = JSON.parse(run(["changelog", "--json"])) as { package: { version: string }; changelog: string };
expect(changelog.package.version).toBe("0.7.0");
expect(changelog.package.version).toBe(mcpPackageJson.version);
expect(changelog.changelog).toContain("# Changelog");
});

Expand All @@ -180,7 +181,7 @@ describe("gittensory-mcp CLI — profiles", () => {

const sessionRequest = requests.find((request) => request.url === "/v1/auth/session");
expect(sessionRequest?.headers["x-gittensory-mcp-package"]).toBe("@jsonbored/gittensory-mcp");
expect(sessionRequest?.headers["x-gittensory-mcp-version"]).toBe("0.7.0");
expect(sessionRequest?.headers["x-gittensory-mcp-version"]).toBe(mcpPackageJson.version);
expect(sessionRequest?.headers["x-gittensory-mcp-client"]).toBe("gittensory-mcp-cli");
const telemetryHeaders = JSON.stringify({
package: sessionRequest?.headers["x-gittensory-mcp-package"],
Expand Down
36 changes: 25 additions & 11 deletions test/unit/mcp-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,48 @@
import { describe, expect, it } from "vitest";
import { buildMcpClientTelemetry } from "../../src/services/client-telemetry";
import { classifyMcpClientVersion, compareMcpSemver } from "../../src/services/mcp-compatibility";
import { classifyMcpClientVersion, compareMcpSemver, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../../src/services/mcp-compatibility";

// Derives values relative to the actual current recommended release instead of hardcoding a literal
// that only happens to be correct for whichever version is current when the test is written --
// LATEST_RECOMMENDED_MCP_VERSION now derives from packages/gittensory-mcp/package.json (no longer a
// second hand-synced literal), so these assertions stay correct across every future release too.
// MINIMUM_SUPPORTED_MCP_VERSION-relative literals ("0.5.9" etc.) stay hardcoded on purpose: unlike the
// recommended version, that constant is deliberately NOT bumped every release.
function bumpMinor(version: string): string {
const [major, minor] = version.split(".").map(Number) as [number, number, number];
return `${major}.${minor + 1}.0`;
}
function bumpPatch(version: string): string {
const [major, minor, patch] = version.split(".").map(Number) as [number, number, number];
return `${major}.${minor}.${patch + 1}`;
}

describe("MCP compatibility telemetry", () => {
it("classifies local MCP package versions against the advertised support window", () => {
expect(classifyMcpClientVersion("0.1.9")).toBe("incompatible");
expect(classifyMcpClientVersion("0.2.1")).toBe("incompatible");
expect(classifyMcpClientVersion("0.3.0")).toBe("incompatible");
expect(classifyMcpClientVersion("0.4.0")).toBe("incompatible");
expect(classifyMcpClientVersion("0.5.0")).toBe("stale");
expect(classifyMcpClientVersion(MINIMUM_SUPPORTED_MCP_VERSION)).toBe("stale");
expect(classifyMcpClientVersion("0.5.9")).toBe("stale");
expect(classifyMcpClientVersion("0.6.0")).toBe("stale");
expect(classifyMcpClientVersion("0.7.0")).toBe("current");
expect(classifyMcpClientVersion(LATEST_RECOMMENDED_MCP_VERSION)).toBe("current");
expect(classifyMcpClientVersion("not-a-version")).toBe("unknown");
expect(classifyMcpClientVersion(undefined)).toBe("unknown");
expect(classifyMcpClientVersion(null)).toBe("unknown");
});

it("treats prerelease builds below the minimum or recommended cutoffs as incompatible or stale", () => {
expect(classifyMcpClientVersion("0.4.9-rc.1")).toBe("incompatible");
expect(classifyMcpClientVersion("0.5.0-rc.1")).toBe("incompatible");
expect(classifyMcpClientVersion("0.7.0-rc.1")).toBe("stale");
expect(classifyMcpClientVersion(`${MINIMUM_SUPPORTED_MCP_VERSION}-rc.1`)).toBe("incompatible");
expect(classifyMcpClientVersion(`${LATEST_RECOMMENDED_MCP_VERSION}-rc.1`)).toBe("stale");
});

it("classifies the exact recommended version and newer releases as current", () => {
expect(classifyMcpClientVersion("0.7.0")).toBe("current");
expect(classifyMcpClientVersion("0.7.1")).toBe("current");
expect(classifyMcpClientVersion("1.0.0")).toBe("current");
expect(compareMcpSemver("0.7.0", "0.7.0")).toBe(0);
expect(compareMcpSemver("0.8.0", "0.7.0")).toBe(1);
expect(classifyMcpClientVersion(LATEST_RECOMMENDED_MCP_VERSION)).toBe("current");
expect(classifyMcpClientVersion(bumpPatch(LATEST_RECOMMENDED_MCP_VERSION))).toBe("current");
expect(classifyMcpClientVersion("999.0.0")).toBe("current");
expect(compareMcpSemver(LATEST_RECOMMENDED_MCP_VERSION, LATEST_RECOMMENDED_MCP_VERSION)).toBe(0);
expect(compareMcpSemver(bumpMinor(LATEST_RECOMMENDED_MCP_VERSION), LATEST_RECOMMENDED_MCP_VERSION)).toBe(1);
});

it("builds bounded telemetry from allowlisted MCP headers", () => {
Expand Down