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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"test:engine-parity": "vitest run test/contract/engine-parity.test.ts",
"test:live-gate-parity": "vitest run test/contract/live-gate-parity.test.ts",
"test:driver-parity": "vitest run test/contract/coding-agent-driver-parity.test.ts",
"validate:mcp": "vitest run test/contract/validate-mcp.test.ts",
"test:changed": "vitest run --changed=origin/main",
"pretest:workers": "npm run check-node-version",
"test:workers": "vitest run --config vitest.workers.config.ts",
Expand All @@ -120,7 +121,7 @@
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts",
"pretest:ci": "npm run check-node-version",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci",
"test:watch": "vitest",
Expand Down
9 changes: 7 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,13 @@ import { wrapStdioToolHandler } from "../lib/telemetry.js";
// same way any external consumer's "@loopover/mcp/..." import would, landing on the one real
// package.json/CHANGELOG.md either way -- no relative-depth arithmetic, so a future directory move
// can never silently break this again the way the pre-dist/-migration relative path did.
function resolveOwnPackageFile(specifier: string): URL {
return new URL(import.meta.resolve(specifier));
// Returns a path, not a URL: node:fs accepts both, but the two `URL` types in play (the DOM's and
// node:url's) are structurally incompatible, so handing a `URL` to readFileSync only typechecks in a
// program whose lib does not pull in the DOM one. #9520's validator imports this module from a test,
// which does -- so the boundary is narrowed to a string here rather than left to depend on which
// program the file happens to be compiled in.
function resolveOwnPackageFile(specifier: string): string {
return fileURLToPath(import.meta.resolve(specifier));
}

// Read name/version from this package's own package.json (always present in any install --
Expand Down
100 changes: 100 additions & 0 deletions scripts/lib/validate-mcp/invariants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Pure invariant checks the MCP contract validator runs (#9520).
//
// Split from the driver so every branch is reachable from a unit test without booting a server; the
// driver stays thin glue over these. Each function returns a list of human-readable failures rather
// than throwing, so one run reports every problem instead of the first.
import type { McpToolDefinition } from "@loopover/contract";

export type ListedTool = {
name: string;
description?: string | undefined;
inputSchema?: { type?: string } | undefined;
outputSchema?: { type?: string } | undefined;
};

/**
* The registry's projection for a server and that server's real `tools/list` must be the same SET.
*
* Both directions matter and fail differently: a tool the registry projects but the server never
* registered is a capability the published contract promises and nothing serves; a tool the server
* registers outside the registry is exactly the hand-maintained declaration this program exists to
* eliminate.
*/
export function diffToolSets(expected: readonly McpToolDefinition[], listed: readonly ListedTool[]): string[] {
const expectedNames = new Set(expected.map((tool) => tool.name));
const listedNames = new Set(listed.map((tool) => tool.name));
const failures: string[] = [];
for (const name of expectedNames) {
if (!listedNames.has(name)) failures.push(`registry projects ${name} but the server does not register it`);
}
for (const name of listedNames) {
if (!expectedNames.has(name)) failures.push(`server registers ${name} but it has no registry entry`);
}
return failures;
}

/** Every listed tool must advertise a description and object-typed input AND output schemas. */
export function checkAdvertisedShape(listed: readonly ListedTool[]): string[] {
const failures: string[] = [];
for (const tool of listed) {
if (!tool.description || tool.description.trim().length === 0) failures.push(`${tool.name} advertises no description`);
if (tool.inputSchema?.type !== "object") failures.push(`${tool.name} advertises a non-object inputSchema`);
if (!tool.outputSchema) failures.push(`${tool.name} advertises no outputSchema`);
else if (tool.outputSchema.type !== "object") failures.push(`${tool.name} advertises a non-object outputSchema`);
}
return failures;
}

/**
* Every registered tool must have been smoke-called.
*
* This is the assertion metagraphed's validator lacks, and the reason 92 of its 205 tools are never
* exercised: without it, a tool added without a call is simply uncovered, silently. Here the
* arguments are synthesized from the schema, so "add an entry" is not a chore anyone can forget --
* this check catches a tool the driver SKIPPED, which only ever happens deliberately.
*/
export function checkEveryToolCalled(listed: readonly ListedTool[], called: ReadonlySet<string>): string[] {
return listed.filter((tool) => !called.has(tool.name)).map((tool) => `${tool.name} was never smoke-called`);
}

export type VersionLockInput = {
packageVersion: string;
advertisedLatestVersion: string;
serverInfoVersion: string;
};

/**
* The three places the stdio server's version appears must agree.
*
* `LATEST_RECOMMENDED_MCP_VERSION` derives from the package.json today, so two of the three are
* equal by construction -- but `serverInfo.version` is read independently at server construction and
* is the one a client actually sees, so it is the one that can drift.
*/
export function checkVersionLock(input: VersionLockInput): string[] {
const failures: string[] = [];
if (input.advertisedLatestVersion !== input.packageVersion) {
failures.push(`compatibility advertises ${input.advertisedLatestVersion} but @loopover/mcp is ${input.packageVersion}`);
}
if (input.serverInfoVersion !== input.packageVersion) {
failures.push(`stdio serverInfo reports ${input.serverInfoVersion} but @loopover/mcp is ${input.packageVersion}`);
}
return failures;
}

/**
* Every path the release automation reads must exist in HEAD.
*
* The anti-rot guard metagraphed's validator lacks: its version automation broke silently because
* nothing checked that the files it keys off still existed. A version lock that only compares
* constants to each other stays green while the thing that is supposed to update them has stopped
* running -- the constants agree precisely BECAUSE nothing is touching them.
*/
export function checkWatchedPathsExist(paths: readonly string[], exists: (path: string) => boolean): string[] {
return paths.filter((path) => !exists(path)).map((path) => `release automation reads ${path}, which does not exist`);
}

/** Format a server's failures for the CLI, or an empty string when it has none. */
export function formatFailures(server: string, failures: readonly string[]): string {
if (failures.length === 0) return "";
return [`\n${server}: ${failures.length} failure(s)`, ...failures.map((failure) => ` • ${failure}`)].join("\n");
}
50 changes: 50 additions & 0 deletions scripts/lib/validate-mcp/overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Per-tool smoke-call argument overrides (#9520).
//
// Deliberately SMALL, and it must stay that way: every entry here is a place where the schema-
// derived arguments are structurally valid but semantically useless, and each one is a hint that
// the tool's own input schema could describe its expectations better.
//
// A tool that is absent from this map is still smoke-called -- with the synthesized minimum. That
// is the whole design (see synthesize-input.ts): nothing here needs an entry for a new tool.
export const SMOKE_ARGUMENT_OVERRIDES: Readonly<Record<string, Record<string, unknown>>> = Object.freeze({
// Dry-run by default is the tools' own posture; the synthesizer sends `false` for every boolean
// (the minimum), which would flip these two into their create path. Set explicitly so a smoke
// call can never open an issue even against a fixture env.
loopover_generate_contributor_issue_drafts: { dryRun: true, create: false },
loopover_plan_repo_issues: { dryRun: true, create: false, goal: "validate-mcp smoke call" },
// A staged approval-queue decision executes the action on accept; reject is the inert branch.
loopover_decide_pending_action: { decision: "reject" },
// The write-spec tools compose a shell command from these; a one-character branch name produces a
// spec no human would read, and `head`/`base` must differ for the spec to make sense.
loopover_open_pr: { head: "validate-mcp/head", base: "main", title: "validate-mcp smoke call" },
loopover_create_branch: { branch: "validate-mcp/probe" },
loopover_delete_branch: { branch: "validate-mcp/probe" },
// `framework` is an enum whose first member the synthesizer picks; targetFiles needs a real path
// shape for the spec's criteria to be meaningful.
loopover_generate_tests: { targetFiles: ["src/index.ts"] },
});

/** The override for one tool, or an empty object. Exported as a function so the driver never has to
* care whether an entry exists. */
export function overrideFor(toolName: string): Record<string, unknown> {
return SMOKE_ARGUMENT_OVERRIDES[toolName] ?? {};
}

/**
* Paths the release automation reads, asserted to exist by `checkWatchedPathsExist`.
*
* Listed here rather than derived because the point is to catch a file being MOVED OR DELETED while
* the automation still names it -- deriving the list from the same source the automation reads
* would make the check vacuous.
*/
export const RELEASE_AUTOMATION_WATCHED_PATHS: readonly string[] = Object.freeze([
"packages/loopover-mcp/package.json",
"packages/loopover-mcp/CHANGELOG.md",
"packages/loopover-engine/package.json",
"packages/loopover-miner/package.json",
"packages/loopover-miner/expected-engine.version",
"src/services/mcp-compatibility.ts",
".github/workflows/publish-mcp.yml",
".github/workflows/publish-engine.yml",
".github/workflows/publish-miner.yml",
]);
123 changes: 123 additions & 0 deletions scripts/lib/validate-mcp/synthesize-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Minimal-valid-instance synthesis for a tool's advertised inputSchema (#9520).
//
// WHY SYNTHESIZE RATHER THAN HAND-WRITE. metagraphed's validator keeps a name-keyed table of
// arguments, and its own documented gap is the direct consequence: only 113 of its 205 tools are
// ever actually called, with nothing forcing a new tool to add an entry. A table that must be
// maintained by hand for every tool is a table that rots.
//
// So the arguments for every smoke call are DERIVED from the schema the tool itself advertises. A
// new tool is smoke-called the day it is registered, with no table edit, and the only entries in
// overrides.ts are the ones where a STRUCTURALLY valid value is not a SEMANTICALLY useful one -- a
// repo that has to exist in the seeded fixture, a dry-run flag that has to be set so a write tool
// stays inert.
//
// The output is deliberately MINIMAL: required properties only, shortest permitted strings and
// arrays. A smoke call exists to prove the tool answers and that its answer matches its advertised
// output schema, not to exercise its logic -- the unit suites do that.

export type JsonSchema = {
type?: string | string[];
properties?: Record<string, JsonSchema>;
required?: readonly string[];
items?: JsonSchema;
enum?: readonly unknown[];
const?: unknown;
anyOf?: readonly JsonSchema[];
oneOf?: readonly JsonSchema[];
allOf?: readonly JsonSchema[];
format?: string;
minLength?: number;
minItems?: number;
minimum?: number;
maximum?: number;
exclusiveMinimum?: number;
default?: unknown;
};

/** A repo-shaped string. `minLength: 3` in this contract always means an owner/repo pair -- the one
* place a length floor carries a meaning beyond its number, so "xxx" would be structurally valid
* and semantically useless. */
const FIXTURE_REPO_FULL_NAME = "loopover-validate/fixture";

function synthesizeString(schema: JsonSchema): string {
if (schema.format === "date-time") return "2026-01-01T00:00:00.000Z";
const min = schema.minLength ?? 0;
if (min >= 3) return FIXTURE_REPO_FULL_NAME;
return min === 0 ? "x" : "x".repeat(min);
}

function synthesizeNumber(schema: JsonSchema): number {
const floor = schema.exclusiveMinimum !== undefined ? schema.exclusiveMinimum + 1 : (schema.minimum ?? 1);
return schema.maximum !== undefined ? Math.min(floor, schema.maximum) : floor;
}

/**
* Build the smallest value that validates against `schema`.
*
* Returns `undefined` only for a schema that permits nothing concrete (an empty `anyOf`), which the
* caller reports rather than guessing around.
*/
export function synthesizeFromSchema(schema: JsonSchema | undefined): unknown {
if (!schema) return undefined;
if (schema.const !== undefined) return schema.const;
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
if (schema.default !== undefined) return schema.default;

const branches = schema.anyOf ?? schema.oneOf;
if (branches) {
for (const branch of branches) {
const value = synthesizeFromSchema(branch);
if (value !== undefined) return value;
}
return undefined;
}
if (schema.allOf && schema.allOf.length > 0) {
// Merge the branches' object shapes. The contract only ever produces `allOf` from `.extend()`,
// so the branches are always objects and never contradict each other.
const properties: Record<string, JsonSchema> = {};
const required: string[] = [];
for (const branch of schema.allOf) {
Object.assign(properties, branch.properties ?? {});
required.push(...(branch.required ?? []));
}
return synthesizeFromSchema({ type: "object", properties, required });
}

const type = Array.isArray(schema.type) ? schema.type.find((entry) => entry !== "null") : schema.type;
switch (type) {
case "object": {
const result: Record<string, unknown> = {};
for (const key of schema.required ?? []) {
const value = synthesizeFromSchema(schema.properties?.[key]);
if (value !== undefined) result[key] = value;
}
return result;
}
case "array": {
const count = schema.minItems ?? 0;
if (count === 0) return [];
const item = synthesizeFromSchema(schema.items);
return item === undefined ? [] : Array.from({ length: count }, () => item);
}
case "string":
return synthesizeString(schema);
case "integer":
case "number":
return synthesizeNumber(schema);
case "boolean":
return false;
case "null":
return null;
default:
// An unconstrained schema (`z.unknown()`) accepts anything; an empty object is the least
// surprising thing to send and the only one that survives a downstream `Object.entries`.
return {};
}
}

/** The arguments a smoke call sends: the synthesized minimum with any per-tool override on top. */
export function buildSmokeArguments(inputSchema: JsonSchema | undefined, override: Record<string, unknown> = {}): Record<string, unknown> {
const synthesized = synthesizeFromSchema(inputSchema);
const base = synthesized !== null && typeof synthesized === "object" && !Array.isArray(synthesized) ? (synthesized as Record<string, unknown>) : {};
return { ...base, ...override };
}
Loading
Loading