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: 4 additions & 0 deletions packages/loopover-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
"./signals/path-matchers": {
"types": "./dist/signals/path-matchers.d.ts",
"default": "./dist/signals/path-matchers.js"
},
"./signals/slop": {
"types": "./dist/signals/slop.d.ts",
"default": "./dist/signals/slop.js"
}
},
"files": [
Expand Down
8 changes: 6 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { delimiter, dirname, join } from "node:path";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildFeasibilityVerdict, buildPrTextLint } from "@loopover/engine";
import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "@loopover/engine/signals/slop";
import { z } from "zod";
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";
import { formatTable } from "../lib/format-table.js";
Expand Down Expand Up @@ -484,7 +485,7 @@ const STDIO_TOOL_DESCRIPTORS = [
},
{
name: "loopover_check_slop_risk",
description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. No repo data needed.",
description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. Computed in-process; no repo data and no API round-trip.",
},
{
name: "loopover_check_issue_slop",
Expand Down Expand Up @@ -830,7 +831,10 @@ registerStdioTool(
description: stdioToolDescription("loopover_check_slop_risk"),
inputSchema: checkSlopRiskShape,
},
async (input) => toolResult("LoopOver slop-risk self-check.", await apiPost("/v1/lint/slop-risk", input)),
// Computed in-process from @loopover/engine (#6267) — matches the remote server's own buildSlopAssessment
// call (src/mcp/server.ts) and the /v1/lint/slop-risk route's `{ ...assessment, rubric }` shape with no API
// round-trip, so slop-risk self-checks work fully offline.
(input) => toolResult("LoopOver slop-risk self-check.", { ...buildSlopAssessment(input), rubric: SLOP_RUBRIC_MARKDOWN }),
);

registerStdioTool(
Expand Down
98 changes: 98 additions & 0 deletions test/unit/mcp-local-check-slop-risk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");

let client: Client;
let transport: StdioClientTransport;
let configDir: string;

// An unreachable API endpoint with a tight timeout: if the stdio tool ever regressed to proxying over HTTP
// (`apiPost("/v1/lint/slop-risk", …)`) instead of computing in-process, every call below would error out
// against this dead address. Their success is what proves the local tool runs fully offline (#6267).
async function connect() {
configDir = mkdtempSync(join(tmpdir(), "loopover-check-slop-risk-"));
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
env: {
...(process.env as Record<string, string>),
LOOPOVER_CONFIG_DIR: configDir,
LOOPOVER_API_URL: "http://127.0.0.1:1",
LOOPOVER_API_TIMEOUT_MS: "400",
},
});
client = new Client({ name: "check-slop-risk-test", version: "0.0.1" });
await client.connect(transport);
}

async function disconnect() {
await client.close().catch(() => undefined);
if (configDir) rmSync(configDir, { recursive: true, force: true });
}

describe("loopover_check_slop_risk stdio tool (#6267)", () => {
beforeEach(connect);
afterEach(disconnect);

it("advertises the in-process, no-round-trip behavior in the tool list", async () => {
const { tools } = await client.listTools();
const tool = tools.find((t) => t.name === "loopover_check_slop_risk");
expect(tool).toBeDefined();
expect(tool?.description).toContain("no API round-trip");
});

it("regression: flags a low-effort change with band/findings/rubric computed in-process, no network call", async () => {
const result = await client.callTool({
name: "loopover_check_slop_risk",
arguments: {
changedFiles: [{ path: "src/foo.ts", additions: 50, deletions: 0 }],
description: "",
},
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as {
slopRisk: number;
band: string;
findings: { code: string }[];
rubric: string;
};
// A code file with no test evidence (15) + an empty description (15) = 30 → "low".
expect(data.slopRisk).toBe(30);
expect(data.band).toBe("low");
expect(data.findings.map((f) => f.code).sort()).toEqual(["empty_pr_description", "missing_test_evidence"]);
// The local path keeps the /v1/lint/slop-risk route's `{ ...assessment, rubric }` shape byte-for-byte.
expect(data.rubric).toContain("LoopOver slop assessment rubric");
});

it("returns a clean band with no findings offline for a substantive, well-described change", async () => {
const result = await client.callTool({
name: "loopover_check_slop_risk",
arguments: {
changedFiles: [
{ path: "src/foo.ts", additions: 40, deletions: 2 },
{ path: "test/unit/foo.test.ts", additions: 30, deletions: 0 },
],
description: "Adds X to support Y because Z, with focused regression coverage.",
},
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as { slopRisk: number; band: string; findings: unknown[] };
expect(data.slopRisk).toBe(0);
expect(data.band).toBe("clean");
expect(data.findings).toEqual([]);
});

it("never leaks private financial terminology in the offline response", async () => {
const result = await client.callTool({
name: "loopover_check_slop_risk",
arguments: { changedFiles: [{ path: "src/foo.ts", additions: 50, deletions: 0 }], description: "" },
});
expect(result.isError).toBeFalsy();
expect(JSON.stringify(result)).not.toMatch(/hotkey|coldkey|wallet|mnemonic|payout|reward|trust score/i);
});
});