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
2 changes: 1 addition & 1 deletion packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4911,7 +4911,7 @@ async function buildRepoOutcomePatternsResponse(env: Env, fullName: string) {
return attachDataQuality(response as unknown as Record<string, unknown>, 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
Expand Down
46 changes: 46 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -2730,6 +2764,18 @@ export class LoopoverMcp {
};
}

private async getRegistrationReadiness(input: { owner: string; repo: string }): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async getBurdenForecast(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(fullName);
Expand Down
1 change: 1 addition & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
115 changes: 115 additions & 0 deletions test/unit/mcp-registration-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -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<Client> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});
});