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
21 changes: 21 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ import { loadUpstreamStatus } from "../upstream/ruleset";
import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/open-pr-pressure-scenarios";
import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/finding-taxonomy";
import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy";
import { buildSlopRulesDocument, SLOP_RULES_URI } from "../review/slop-rules-taxonomy";

type AppContext = Context<{ Bindings: Env }>;
type ToolPayload = {
Expand Down Expand Up @@ -2082,6 +2083,26 @@ export class GittensoryMcp {
}),
);

// #2237 — read-only catalog of the deterministic slop rule codes + score bands for MCP discovery.
server.registerResource(
"gittensory_slop_rules",
SLOP_RULES_URI,
{
title: "Gittensory Slop Rules",
description: "Deterministic slop-signal catalog: rule codes with their point weights (PR + issue) and the clean/low/elevated/high score bands.",
mimeType: "application/json",
},
async () => ({
contents: [
{
uri: SLOP_RULES_URI,
mimeType: "application/json",
text: JSON.stringify(buildSlopRulesDocument(), null, 2),
},
],
}),
);

return server;
}

Expand Down
46 changes: 46 additions & 0 deletions src/review/slop-rules-taxonomy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ISSUE_SLOP_WEIGHTS, SLOP_WEIGHTS, type SlopBand } from "../signals/slop";

/** MCP resource URI for the deterministic slop-rule catalog (#2237). */
export const SLOP_RULES_URI = "gittensory://slop-rules" as const;

export interface SlopBandRange {
band: SlopBand;
/** Inclusive slopRisk (0-100) range that maps to this band. */
range: string;
}

export interface SlopRuleEntry {
/** Deterministic signal code — a key of SLOP_WEIGHTS / ISSUE_SLOP_WEIGHTS. */
code: string;
/** slopRisk points this signal contributes when it fires. */
weight: number;
}

export interface SlopRulesDocument {
bands: readonly SlopBandRange[];
pullRequestRules: readonly SlopRuleEntry[];
issueRules: readonly SlopRuleEntry[];
}

// The fixed score bands, matching slopBandFor's documented cut-points in src/signals/slop.ts (clean=0,
// low=1-30, elevated=31-59, high=60-100). Typed as SlopBand so a renamed or removed band fails the build
// here rather than drifting silently from the detector's own union.
const SLOP_BANDS: readonly SlopBandRange[] = [
{ band: "clean", range: "0" },
{ band: "low", range: "1-30" },
{ band: "elevated", range: "31-59" },
{ band: "high", range: "60-100" },
];

/** Project the deterministic slop-signal weight maps (SLOP_WEIGHTS + ISSUE_SLOP_WEIGHTS — the single source
* of truth in src/signals/slop.ts) into a static, read-only catalog of rule codes, their point weights, and
* the score bands, so an agent can pre-plan against the detector without triggering a scoring call. Codes
* and weights are read straight off the const maps (no duplicated literals); adding a signal there surfaces
* it here automatically. */
export function buildSlopRulesDocument(): SlopRulesDocument {
return {
bands: SLOP_BANDS,
pullRequestRules: Object.entries(SLOP_WEIGHTS).map(([code, weight]) => ({ code, weight })),
issueRules: Object.entries(ISSUE_SLOP_WEIGHTS).map(([code, weight]) => ({ code, weight })),
};
}
43 changes: 43 additions & 0 deletions test/unit/mcp-slop-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { GittensoryMcp } from "../../src/mcp/server";
import { SLOP_RULES_URI } from "../../src/review/slop-rules-taxonomy";
import { ISSUE_SLOP_WEIGHTS, SLOP_WEIGHTS } from "../../src/signals/slop";
import { createTestEnv } from "../helpers/d1";

async function connectTestClient() {
const mcpServer = new GittensoryMcp(createTestEnv()).createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.connect(serverTransport);
const client = new Client({ name: "gittensory-slop-rules-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
return { client, mcpServer };
}

describe("MCP slop-rules resource (#2237)", () => {
it("discovers the slop-rules resource", async () => {
const { client } = await connectTestClient();
const { resources } = await client.listResources();
expect(resources.map((r) => r.uri)).toContain(SLOP_RULES_URI);
});

it("returns every rule code and score band as JSON", async () => {
const { client } = await connectTestClient();
const result = await client.readResource({ uri: SLOP_RULES_URI });
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content?.mimeType).toBe("application/json");
if (!content || !("text" in content)) throw new Error("expected text content");
const body = JSON.parse(content.text ?? "") as {
bands: Array<{ band: string; range: string }>;
pullRequestRules: Array<{ code: string; weight: number }>;
issueRules: Array<{ code: string; weight: number }>;
};
const prCodes = body.pullRequestRules.map((rule) => rule.code);
for (const code of Object.keys(SLOP_WEIGHTS)) expect(prCodes).toContain(code);
const issueCodes = body.issueRules.map((rule) => rule.code);
for (const code of Object.keys(ISSUE_SLOP_WEIGHTS)) expect(issueCodes).toContain(code);
expect(body.bands.map((band) => band.band)).toEqual(["clean", "low", "elevated", "high"]);
});
});
33 changes: 33 additions & 0 deletions test/unit/slop-rules-taxonomy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildSlopRulesDocument, SLOP_RULES_URI } from "../../src/review/slop-rules-taxonomy";
import { ISSUE_SLOP_WEIGHTS, SLOP_WEIGHTS } from "../../src/signals/slop";

describe("slop-rules taxonomy document (#2237)", () => {
it("projects every SLOP_WEIGHTS code + weight into pullRequestRules (single source of truth)", () => {
const doc = buildSlopRulesDocument();
const expected = Object.entries(SLOP_WEIGHTS);
expect(doc.pullRequestRules).toHaveLength(expected.length);
for (const [code, weight] of expected) {
expect(doc.pullRequestRules).toContainEqual({ code, weight });
}
});

it("projects every ISSUE_SLOP_WEIGHTS code + weight into issueRules", () => {
const doc = buildSlopRulesDocument();
const expected = Object.entries(ISSUE_SLOP_WEIGHTS);
expect(doc.issueRules).toHaveLength(expected.length);
for (const [code, weight] of expected) {
expect(doc.issueRules).toContainEqual({ code, weight });
}
});

it("enumerates the four score bands with their ranges", () => {
const doc = buildSlopRulesDocument();
expect(doc.bands.map((b) => b.band)).toEqual(["clean", "low", "elevated", "high"]);
expect(doc.bands).toContainEqual({ band: "elevated", range: "31-59" });
});

it("exposes the stable resource URI", () => {
expect(SLOP_RULES_URI).toBe("gittensory://slop-rules");
});
});