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", "loopover_get_registration_readiness"],
recommendedTools: ["loopover_get_repo_context", "loopover_get_issue_quality", "loopover_get_registration_readiness", "loopover_get_config_recommendation"],
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 @@ -4960,7 +4960,7 @@ async function buildSelfDogfoodRegistrationPackResponse(env: Env) {
};
}

async function buildGittensorConfigRecommendationResponse(env: Env, fullName: string) {
export async function buildGittensorConfigRecommendationResponse(env: Env, fullName: string) {
/* v8 ignore start -- Config recommendation route-level shaping over covered signal helpers. */
// Intentionally the raw DB settings, not resolveRepositorySettings's merged view: this tool recommends what
// to ADD to .loopover.yml based on the repo's currently-active (dashboard/API-configured) behavior — using
Expand Down
38 changes: 37 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,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 { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse } from "../api/routes";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
import {
Expand Down Expand Up @@ -810,6 +810,18 @@ const registrationReadinessOutputSchema = {
dataQuality: z.unknown().optional(),
};

const configRecommendationOutputSchema = {
repoFullName: z.string().optional(),
generatedAt: z.string().optional(),
privateOnly: z.boolean().optional(),
current: z.unknown().optional(),
recommended: z.unknown().optional(),
tradeoffs: z.array(z.string()).optional(),
reasons: 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 @@ -1693,6 +1705,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getRegistrationReadiness(input)),
);

server.registerTool(
"loopover_get_config_recommendation",
{
description:
"Return recommended .loopover.yml additions for a repository, derived from the repo's live, currently-active configured behavior (the raw dashboard/API-configured settings, not a yml-merged view — so the recommendation never compares itself against an override that already exists). Advisory only, not a write action.",
inputSchema: ownerRepoShape,
outputSchema: configRecommendationOutputSchema,
},
async (input) => this.toolResult(await this.getConfigRecommendation(input)),
);

server.registerTool(
"loopover_get_burden_forecast",
{
Expand Down Expand Up @@ -2809,6 +2832,19 @@ export class LoopoverMcp {
};
}

private async getConfigRecommendation(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(fullName);
const report = await buildGittensorConfigRecommendationResponse(this.env, fullName);
return {
summary:
report.warnings.length > 0
? `LoopOver .loopover.yml recommendation for ${fullName}: ${report.warnings.length} warning(s) to review alongside the recommendation (advisory only, not a write action).`
: `LoopOver .loopover.yml recommendation for ${fullName}: recommendation generated with no outstanding warnings (advisory only, not a write 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
90 changes: 90 additions & 0 deletions test/unit/mcp-config-recommendation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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: "config-recommendation-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
return client;
}

describe("loopover_get_config_recommendation MCP tool (#5823)", () => {
it("returns a clean recommendation with no warnings for a repo LoopOver has never seen", async () => {
const env = createTestEnv();
const client = await connect(env);
const result = await client.callTool({ name: "loopover_get_config_recommendation", 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",
privateOnly: true,
current: null,
warnings: [],
});
expect(Array.isArray(data.reasons)).toBe(true);
expect((data.reasons as unknown[]).length).toBeGreaterThan(0);
expect(result.content).toEqual([
expect.objectContaining({ text: expect.stringMatching(/\.loopover\.yml recommendation for unknown-owner\/unknown-repo: recommendation generated with no outstanding warnings/i) }),
]);
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout(?!s\b)/i);
});

it("returns a recommendation with at least one warning for a registered repo whose intake is blocked", 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://config-recommendation-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_config_recommendation", 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", privateOnly: true });
expect(data.recommended).toBeTruthy();
expect((data.warnings as unknown[]).length).toBeGreaterThan(0);
expect(result.content).toEqual([expect.objectContaining({ text: expect.stringMatching(/\.loopover\.yml recommendation for owner\/gap-repo: \d+ warning\(s\) to review/i) })]);
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet/i);
});

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_config_recommendation", arguments: { owner: "victim-org", repo: "private-repo" } });

expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toMatch(/cannot access this repository/i);
});
});
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 @@ -17,6 +17,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
"loopover_get_maintainer_lane",
"loopover_get_repo_onboarding_pack",
"loopover_get_registration_readiness",
"loopover_get_config_recommendation",
"loopover_get_burden_forecast",
"loopover_get_repo_outcome_patterns",
"loopover_get_outcome_calibration",
Expand Down