diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index bce2e533e5..d3e13097e1 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -137,7 +137,7 @@ const AGENT_PROFILES = { audience: "repository owners preparing intake readiness and onboarding plans", purpose: "Review registration readiness, focus manifests, docs/onboarding gaps, and manual setup actions.", recommendedPrompts: ["loopover_repo_owner_intake_readiness", "loopover_repo_owner_focus_manifest_review", "loopover_repo_owner_onboarding_pack"], - recommendedTools: ["loopover_get_repo_context", "loopover_get_issue_quality"], + recommendedTools: ["loopover_get_repo_context", "loopover_get_issue_quality", "loopover_get_registration_readiness"], boundaries: [ "Human-approved only: review, explain, and draft setup plans; do not push config, label issues, post comments, close issues, or publish public output.", "Separate public readiness guidance from private maintainer or authenticated owner context.", diff --git a/src/api/routes.ts b/src/api/routes.ts index b694d647b3..9f6f894a06 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -4911,7 +4911,7 @@ async function buildRepoOutcomePatternsResponse(env: Env, fullName: string) { return attachDataQuality(response as unknown as Record, dataQuality); } -async function buildRegistrationReadinessResponse(env: Env, fullName: string) { +export async function buildRegistrationReadinessResponse(env: Env, fullName: string) { /* v8 ignore start -- Registration readiness route-level shaping over covered signal helpers. */ // Intentionally the raw DB `settings` alongside the raw (cache-only, never live-fetched) `focusManifest`, // not resolveRepositorySettings's merged view: this endpoint's whole purpose is to advise on the diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ae6d3d2c64..54bf19b9ba 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -102,6 +102,7 @@ import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/m import { loadLabelAudit, labelAuditSummary } from "../services/label-audit"; import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; +import { buildRegistrationReadinessResponse } from "../api/routes"; import { loadGatePrecisionReport } from "../services/gate-precision"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { @@ -784,6 +785,28 @@ const repoOnboardingPackOutputSchema = { error: z.string().optional(), }; +const registrationReadinessOutputSchema = { + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + ready: z.boolean().optional(), + recommendedRegistrationMode: z.string().optional(), + issuePolicy: z.string().optional(), + directPrReadiness: z.unknown().optional(), + issueDiscoveryReadiness: z.unknown().optional(), + labelPolicy: z.unknown().optional(), + maintainerCutReadiness: z.unknown().optional(), + testCoverageHealth: z.unknown().optional(), + queueHealth: z.unknown().optional(), + contributorIntakeHealth: z.unknown().optional(), + docsCompleteness: z.unknown().optional(), + githubApp: z.unknown().optional(), + policyReadiness: z.unknown().optional(), + onboardingPackPreview: z.unknown().optional(), + blockers: z.array(z.string()).optional(), + warnings: z.array(z.string()).optional(), + dataQuality: z.unknown().optional(), +}; + const freshnessResponseOutputSchema = { status: z.string().optional(), repoFullName: z.string().optional(), @@ -1637,6 +1660,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getRepoOnboardingPack(input)), ); + server.registerTool( + "loopover_get_registration_readiness", + { + description: + "Preview-only registration-readiness report for a repository: what's missing/present before/after registering with LoopOver (direct-PR and issue-discovery lane readiness, label policy, maintainer-cut readiness, queue health, docs, and the GitHub App install state). Advisory only, not a registration action.", + inputSchema: ownerRepoShape, + outputSchema: registrationReadinessOutputSchema, + }, + async (input) => this.toolResult(await this.getRegistrationReadiness(input)), + ); + server.registerTool( "loopover_get_burden_forecast", { @@ -2730,6 +2764,18 @@ export class LoopoverMcp { }; } + private async getRegistrationReadiness(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoAccess(fullName); + const report = await buildRegistrationReadinessResponse(this.env, fullName); + return { + summary: report.ready + ? `LoopOver registration readiness for ${fullName}: ready (preview-only, not a registration action).` + : `LoopOver registration readiness for ${fullName}: not ready — ${report.blockers.length} blocker(s) (preview-only, not a registration action).`, + data: report as unknown as Record, + }; + } + private async getBurdenForecast(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(fullName); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 77f700ff31..cedd39df50 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5295,6 +5295,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_get_label_audit"); expect(toolNames).toContain("loopover_get_maintainer_lane"); expect(toolNames).toContain("loopover_get_repo_onboarding_pack"); + expect(toolNames).toContain("loopover_get_registration_readiness"); expect(toolNames).toContain("loopover_get_issue_quality"); expect(toolNames).toContain("loopover_get_burden_forecast"); expect(toolNames).toContain("loopover_get_contributor_profile"); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 54fb3c7356..ea2582eb54 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -16,6 +16,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_get_label_audit", "loopover_get_maintainer_lane", "loopover_get_repo_onboarding_pack", + "loopover_get_registration_readiness", "loopover_get_burden_forecast", "loopover_get_repo_outcome_patterns", "loopover_get_outcome_calibration", diff --git a/test/unit/mcp-registration-readiness.test.ts b/test/unit/mcp-registration-readiness.test.ts new file mode 100644 index 0000000000..c535dd352c --- /dev/null +++ b/test/unit/mcp-registration-readiness.test.ts @@ -0,0 +1,115 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { createSessionForGitHubUser, type AuthIdentity } from "../../src/auth/security"; +import { persistRepoGithubTotalsSnapshot, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { normalizeRegistryPayload } from "../../src/registry/normalize"; +import { persistRegistrySnapshot } from "../../src/registry/sync"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env, identity?: AuthIdentity): Promise { + const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "registration-readiness-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("loopover_get_registration_readiness MCP tool (#5824)", () => { + it("returns ready:true for a registered repo with clean config and a healthy queue", async () => { + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "owner/ready-repo": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false, maintainer_cut: 0 }, + }, + { kind: "raw-github", url: "fixture://registration-readiness-ready" }, + "2026-05-26T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "ready-repo", full_name: "owner/ready-repo", private: false, owner: { login: "owner" }, default_branch: "main" }); + + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_registration_readiness", arguments: { owner: "owner", repo: "ready-repo" } }); + + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ + repoFullName: "owner/ready-repo", + ready: true, + recommendedRegistrationMode: "direct_pr", + blockers: [], + }); + expect(result.content).toEqual([expect.objectContaining({ text: expect.stringMatching(/ready \(preview-only, not a registration action\)/i) })]); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + + it("returns ready:false with the outstanding blockers for a registered repo in bad shape", async () => { + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "owner/gap-repo": { emission_share: 0, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: true, maintainer_cut: 0 }, + }, + { kind: "raw-github", url: "fixture://registration-readiness-gap" }, + "2026-05-26T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gap-repo", full_name: "owner/gap-repo", private: false, owner: { login: "owner" }, default_branch: "main" }); + await persistRepoGithubTotalsSnapshot(env, { + id: "gap-repo-totals", + repoFullName: "owner/gap-repo", + openIssuesTotal: 500, + openPullRequestsTotal: 300, + mergedPullRequestsTotal: 0, + closedUnmergedPullRequestsTotal: 0, + labelsTotal: 0, + sourceKind: "github", + fetchedAt: "2026-05-26T00:00:00.000Z", + payload: {}, + }); + + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_registration_readiness", arguments: { owner: "owner", repo: "gap-repo" } }); + + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ + repoFullName: "owner/gap-repo", + ready: false, + blockers: expect.arrayContaining(["Repository config quality is fragile.", "Contributor intake health is blocked."]), + }); + expect(result.content).toEqual([expect.objectContaining({ text: expect.stringMatching(/not ready — 2 blocker\(s\)/i) })]); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + + it("returns ready:false with a not-registered blocker for a repo LoopOver has never seen", async () => { + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_registration_readiness", arguments: { owner: "unknown-owner", repo: "unknown-repo" } }); + + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ + repoFullName: "unknown-owner/unknown-repo", + ready: false, + recommendedRegistrationMode: "direct_pr", + blockers: expect.arrayContaining(["Repository is not registered in the latest LoopOver registry snapshot."]), + }); + }); + + it("forbids a session that cannot access the repository", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim-org/private-repo", private: false, owner: { login: "victim-org" } }); + const { session } = await createSessionForGitHubUser(env, { login: "someone-else", id: 999 }); + const client = await connect(env, { kind: "session", actor: "someone-else", session }); + const result = await client.callTool({ name: "loopover_get_registration_readiness", arguments: { owner: "victim-org", repo: "private-repo" } }); + + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/cannot access this repository/i); + }); +});