From c6944fd0a9bfaa3c4825a2dbff0f1bec00349895 Mon Sep 17 00:00:00 2001 From: Jeff <158072326+jeffrey701@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:42:36 +0100 Subject: [PATCH] feat(mcp): register a gittensory://slop-rules resource enumerating deterministic slop findings Expose a read-only gittensory://slop-rules MCP resource (application/json) that catalogs the deterministic slop rule codes + point weights (PR and issue) and the clean/low/elevated/high score bands, so an agent can pre-plan against the detector without a scoring call. Codes/weights are read straight off SLOP_WEIGHTS / ISSUE_SLOP_WEIGHTS in src/signals/slop.ts (single source of truth); mirrors the existing finding-taxonomy and enrichment-analyzers resources. Closes #2237 --- src/mcp/server.ts | 21 ++++++++++++ src/review/slop-rules-taxonomy.ts | 46 +++++++++++++++++++++++++++ test/unit/mcp-slop-rules.test.ts | 43 +++++++++++++++++++++++++ test/unit/slop-rules-taxonomy.test.ts | 33 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 src/review/slop-rules-taxonomy.ts create mode 100644 test/unit/mcp-slop-rules.test.ts create mode 100644 test/unit/slop-rules-taxonomy.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 109d3befe4..5c670263de 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 = { @@ -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; } diff --git a/src/review/slop-rules-taxonomy.ts b/src/review/slop-rules-taxonomy.ts new file mode 100644 index 0000000000..cbcbb1384b --- /dev/null +++ b/src/review/slop-rules-taxonomy.ts @@ -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 })), + }; +} diff --git a/test/unit/mcp-slop-rules.test.ts b/test/unit/mcp-slop-rules.test.ts new file mode 100644 index 0000000000..96776da534 --- /dev/null +++ b/test/unit/mcp-slop-rules.test.ts @@ -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"]); + }); +}); diff --git a/test/unit/slop-rules-taxonomy.test.ts b/test/unit/slop-rules-taxonomy.test.ts new file mode 100644 index 0000000000..1c0832ec41 --- /dev/null +++ b/test/unit/slop-rules-taxonomy.test.ts @@ -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"); + }); +});