diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 28b087e8a3..f87f913170 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -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 `; diff --git a/src/services/mcp-compatibility.ts b/src/services/mcp-compatibility.ts index c413b4a75e..79bd857067 100644 --- a/src/services/mcp-compatibility.ts +++ b/src/services/mcp-compatibility.ts @@ -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"; diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 637266eb4e..8f86b2a8c6 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -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; @@ -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); @@ -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); }); diff --git a/test/unit/mcp-cli-doctor.test.ts b/test/unit/mcp-cli-doctor.test.ts index e3897f5658..01048d72aa 100644 --- a/test/unit/mcp-cli-doctor.test.ts +++ b/test/unit/mcp-cli-doctor.test.ts @@ -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; @@ -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, @@ -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", }); }); @@ -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, @@ -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, @@ -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, @@ -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", }); }); diff --git a/test/unit/mcp-cli-profiles.test.ts b/test/unit/mcp-cli-profiles.test.ts index 56b3dd8900..a2c3b5ad9b 100644 --- a/test/unit/mcp-cli-profiles.test.ts +++ b/test/unit/mcp-cli-profiles.test.ts @@ -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; @@ -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"); }); @@ -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"], diff --git a/test/unit/mcp-compatibility.test.ts b/test/unit/mcp-compatibility.test.ts index 5cbfb7cbde..515db58ee0 100644 --- a/test/unit/mcp-compatibility.test.ts +++ b/test/unit/mcp-compatibility.test.ts @@ -1,6 +1,21 @@ 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", () => { @@ -8,10 +23,9 @@ describe("MCP compatibility telemetry", () => { 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"); @@ -19,16 +33,16 @@ describe("MCP compatibility telemetry", () => { 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", () => {