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
4 changes: 2 additions & 2 deletions packages/loopover-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ loopover-mcp --stdio
`loopover-mcp version` (aliases `--version` and `-v`) prints the installed package version, the targeted API version, and the Node.js runtime version:

```text
@loopover/mcp/0.5.0 (api 0.1.0, node v22.12.0)
@loopover/mcp/3.0.0 (api 0.1.0, node v22.12.0)
```

Add `--json` for machine-readable output:

```json
{
"name": "@loopover/mcp",
"version": "0.5.0",
"version": "3.0.0",
"apiVersion": "0.1.0",
"node": "v22.12.0"
}
Expand Down
32 changes: 32 additions & 0 deletions scripts/check-ui-mcp-version-copy.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export declare const SOURCE_LATEST_PATH: string;
export declare const SCAN_TARGETS: string[];

export type StaleVersionMatchers = {
floorVersion: string;
minorLabel: string;
visibleVersion: RegExp;
versionRange: RegExp;
floor: RegExp;
};

export declare function collectVersionCopyFailures(input: {
label: string;
text: string;
matchers: StaleVersionMatchers;
}): string[];

export declare function collectSourceFiles(path: string): string[];

export declare function isTextSource(path: string): boolean;

export declare function isMinimumSupportedContext(line: string): boolean;

export declare function buildStaleVersionMatchers(
floorVersion: string,
): StaleVersionMatchers;

export declare function readKnownLatestVersion(path: string): string;

export declare function readMinimumSupportedVersion(path: string): string;

export declare function fetchLatestVersion(): Promise<string>;
254 changes: 180 additions & 74 deletions scripts/check-ui-mcp-version-copy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,131 +2,237 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { get } from "node:https";
import { pathToFileURL } from "node:url";

const root = process.cwd();
const packageName = "@loopover/mcp";
const registryUrl = "https://registry.npmjs.org/@loopover%2fmcp";
const sourceLatestPath = join(root, "apps/loopover-ui/src/lib/mcp-package.ts");
const targets = [

// Single source of truth for the version literals this scan enforces: the same UI module the known-latest
// check already reads. The minimum-supported floor is derived from MCP_MINIMUM_SUPPORTED_VERSION rather than
// hardcoded here, so a floor bump can't silently leave this deterministic drift-detector aimed at a stale
// version (its literals had frozen several majors behind the real floor — #6292).
export const SOURCE_LATEST_PATH = "apps/loopover-ui/src/lib/mcp-package.ts";
export const SCAN_TARGETS = [
"README.md",
"packages/loopover-mcp/README.md",
"apps/loopover-ui/src",
].map((target) => join(root, target));

// The live npm-registry check is BEST-EFFORT: a transient registry blip must not fail CI, because a red
// required check one-shot-closes a contributor PR. Set LOOPOVER_MCP_LATEST_VERSION to make it fully
// offline/deterministic. The deterministic stale-version-string scan below always runs regardless.
let latest = process.env.LOOPOVER_MCP_LATEST_VERSION ?? null;
let latestSkipReason = null;
if (!latest) {
try {
latest = await fetchLatestVersion();
} catch (error) {
latestSkipReason = error instanceof Error ? error.message : "unknown error";
}
}
const sourceLatest = readKnownLatestVersion(sourceLatestPath);
const failures = [];
];

async function main() {
const root = process.cwd();
const sourceLatestPath = join(root, SOURCE_LATEST_PATH);
const targets = SCAN_TARGETS.map((target) => join(root, target));

if (latest && sourceLatest !== latest) {
failures.push(
`apps/loopover-ui/src/lib/mcp-package.ts: known latest ${sourceLatest} does not match npm dist-tags.latest ${latest}`,
// The live npm-registry check is BEST-EFFORT: a transient registry blip must not fail CI, because a red
// required check one-shot-closes a contributor PR. Set LOOPOVER_MCP_LATEST_VERSION to make it fully
// offline/deterministic. The deterministic stale-version-string scan below always runs regardless.
let latest = process.env.LOOPOVER_MCP_LATEST_VERSION ?? null;
let latestSkipReason = null;
if (!latest) {
try {
latest = await fetchLatestVersion();
} catch (error) {
latestSkipReason =
error instanceof Error ? error.message : "unknown error";
}
}
const sourceLatest = readKnownLatestVersion(sourceLatestPath);
const matchers = buildStaleVersionMatchers(
readMinimumSupportedVersion(sourceLatestPath),
);
} else if (!latest) {
console.warn(
`::warning::skipped the npm dist-tag drift check (registry unavailable: ${latestSkipReason}); set LOOPOVER_MCP_LATEST_VERSION to enforce it offline`,
const failures = [];

if (latest && sourceLatest !== latest) {
failures.push(
`${SOURCE_LATEST_PATH}: known latest ${sourceLatest} does not match npm dist-tags.latest ${latest}`,
);
} else if (!latest) {
console.warn(
`::warning::skipped the npm dist-tag drift check (registry unavailable: ${latestSkipReason}); set LOOPOVER_MCP_LATEST_VERSION to enforce it offline`,
);
}

for (const file of targets.flatMap(collectSourceFiles)) {
const label = relative(root, file);
const text = readFileSync(file, "utf8");
failures.push(...collectVersionCopyFailures({ label, text, matchers }));
}

if (failures.length > 0) {
console.error(failures.join("\n"));
process.exit(1);
}

console.log(
`MCP UI version copy ok: npm latest ${latest ?? "unchecked"}, minimum floor ${matchers.floorVersion}, scanned ${targets.length} target(s)`,
);
}

for (const file of targets.flatMap(collectSourceFiles)) {
const label = relative(root, file);
const text = readFileSync(file, "utf8");
export function collectVersionCopyFailures({ label, text, matchers }) {
const failures = [];
const lines = text.split(/\r?\n/);

lines.forEach((line, index) => {
const lineNumber = index + 1;
if (/\bv0\.2(?:\.0)?\b/.test(line)) {
failures.push(`${label}:${lineNumber}: stale visible v0.2 version text`);
if (matchers.visibleVersion.test(line)) {
failures.push(
`${label}:${lineNumber}: stale visible v${matchers.minorLabel} version text`,
);
}
if (/\b0\.2\.x\b/.test(line)) {
failures.push(`${label}:${lineNumber}: stale 0.2.x package-version range`);
if (matchers.versionRange.test(line)) {
failures.push(
`${label}:${lineNumber}: stale ${matchers.minorLabel}.x package-version range`,
);
}
if (/\b0\.2\.0\b/.test(line) && !isMinimumSupportedContext(line)) {
failures.push(`${label}:${lineNumber}: 0.2.0 is only allowed as an explicit minimum-supported compatibility floor`);
if (matchers.floor.test(line) && !isMinimumSupportedContext(line)) {
failures.push(
`${label}:${lineNumber}: ${matchers.floorVersion} is only allowed as an explicit minimum-supported compatibility floor`,
);
}
if (/@loopover\/mcp(?:\s+|@)v?\d+\.\d+\.\d+/.test(line)) {
failures.push(`${label}:${lineNumber}: hardcoded ${packageName} display version`);
failures.push(
`${label}:${lineNumber}: hardcoded ${packageName} display version`,
);
}
if (/(?:npm (?:i|install) -g|npx -y)\s+@loopover\/mcp(?!@)/.test(line)) {
failures.push(`${label}:${lineNumber}: install command must use ${packageName}@latest or resolved npm latest`);
failures.push(
`${label}:${lineNumber}: install command must use ${packageName}@latest or resolved npm latest`,
);
}
if (/args\s*=\s*\[.*"@loopover\/mcp"/.test(line) || /"args":\s*\[.*"@loopover\/mcp"/.test(line)) {
failures.push(`${label}:${lineNumber}: MCP client args must use ${packageName}@latest or resolved npm latest`);
if (
/args\s*=\s*\[.*"@loopover\/mcp"/.test(line) ||
/"args":\s*\[.*"@loopover\/mcp"/.test(line)
) {
failures.push(
`${label}:${lineNumber}: MCP client args must use ${packageName}@latest or resolved npm latest`,
);
}
});
}

if (failures.length > 0) {
console.error(failures.join("\n"));
process.exit(1);
return failures;
}

console.log(`MCP UI version copy ok: npm latest ${latest ?? "unchecked"}, scanned ${targets.length} target(s)`);

function collectSourceFiles(path) {
export function collectSourceFiles(path) {
const stat = statSync(path);
if (stat.isFile()) return isTextSource(path) ? [path] : [];
return readdirSync(path).flatMap((entry) => {
const next = join(path, entry);
if (entry === "node_modules" || entry === "dist" || entry === ".vitepress" || entry === "coverage") return [];
if (/routeTree\.gen\.ts$/.test(next) || /public\/openapi\.json$/.test(next)) return [];
if (
entry === "node_modules" ||
entry === "dist" ||
entry === ".vitepress" ||
entry === "coverage"
)
return [];
if (/routeTree\.gen\.ts$/.test(next) || /public\/openapi\.json$/.test(next))
return [];
return collectSourceFiles(next);
});
}

function isTextSource(path) {
export function isTextSource(path) {
return /\.(md|ts|tsx|js|jsx|json)$/.test(path);
}

function isMinimumSupportedContext(line) {
return /minimum[_ -]?supported|MCP_MINIMUM_SUPPORTED_VERSION|MINIMUM_SUPPORTED_MCP_VERSION|compatibility floor|API minimum|supportedVersionRange/i.test(line);
export function isMinimumSupportedContext(line) {
return /minimum[_ -]?supported|MCP_MINIMUM_SUPPORTED_VERSION|MINIMUM_SUPPORTED_MCP_VERSION|compatibility floor|API minimum|supportedVersionRange/i.test(
line,
);
}

function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

// Derives the three stale-version matchers from the current minimum-supported floor. Keeping the shapes in
// one place (visible `vX.Y`, the `X.Y.x` range, and the bare `X.Y.Z` floor allowed only in an explicit
// minimum-supported statement) means the deterministic scan tracks the floor instead of a frozen literal.
export function buildStaleVersionMatchers(floorVersion) {
if (!/^\d+\.\d+\.\d+$/.test(floorVersion)) {
throw new Error(
`Expected a semver minimum-supported floor like 0.5.0, got "${floorVersion}".`,
);
}
const [major, minor, patch] = floorVersion.split(".");
const minorLabel = `${major}.${minor}`;
const minorPattern = escapeRegExp(minorLabel);
const patchPattern = escapeRegExp(patch);
const floorPattern = escapeRegExp(floorVersion);
return {
floorVersion,
minorLabel,
visibleVersion: new RegExp(`\\bv${minorPattern}(?:\\.${patchPattern})?\\b`),
versionRange: new RegExp(`\\b${minorPattern}\\.x\\b`),
floor: new RegExp(`\\b${floorPattern}\\b`),
};
}

function readKnownLatestVersion(path) {
export function readKnownLatestVersion(path) {
const text = readFileSync(path, "utf8");
const match = /MCP_PACKAGE_KNOWN_LATEST_VERSION\s*=\s*"([^"]+)"/.exec(text);
if (!match) throw new Error("Could not find MCP_PACKAGE_KNOWN_LATEST_VERSION.");
if (!match)
throw new Error("Could not find MCP_PACKAGE_KNOWN_LATEST_VERSION.");
return match[1];
}

function fetchLatestVersion() {
export function readMinimumSupportedVersion(path) {
const text = readFileSync(path, "utf8");
const match = /MCP_MINIMUM_SUPPORTED_VERSION\s*=\s*"([^"]+)"/.exec(text);
if (!match) throw new Error("Could not find MCP_MINIMUM_SUPPORTED_VERSION.");
return match[1];
}

export function fetchLatestVersion() {
return new Promise((resolve, reject) => {
const request = get(registryUrl, { headers: { accept: "application/json" } }, (response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
body += chunk;
});
response.on("end", () => {
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
reject(new Error(`npm registry returned ${response.statusCode ?? "unknown"}`));
return;
}
try {
const latest = JSON.parse(body)?.["dist-tags"]?.latest;
if (typeof latest !== "string" || !/^\d+\.\d+\.\d+$/.test(latest)) {
reject(new Error("npm registry did not return a stable latest version"));
const request = get(
registryUrl,
{ headers: { accept: "application/json" } },
(response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
body += chunk;
});
response.on("end", () => {
if (
!response.statusCode ||
response.statusCode < 200 ||
response.statusCode >= 300
) {
reject(
new Error(
`npm registry returned ${response.statusCode ?? "unknown"}`,
),
);
return;
}
resolve(latest);
} catch (error) {
reject(error);
}
});
});
try {
const latest = JSON.parse(body)?.["dist-tags"]?.latest;
if (typeof latest !== "string" || !/^\d+\.\d+\.\d+$/.test(latest)) {
reject(
new Error(
"npm registry did not return a stable latest version",
),
);
return;
}
resolve(latest);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(8000, () => {
request.destroy(new Error("npm registry timeout"));
});
request.on("error", reject);
});
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
Loading
Loading