From 70d6eedd8d12d6ae9ca5ba5e627a65aee703e6c0 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:51:31 +0200 Subject: [PATCH] feat(mcp): expose .loopover.yml config recommendations as a read-only MCP tool Add loopover_get_config_recommendation, mirroring the existing loopover_get_registration_readiness tool: read-only, requireRepoAccess-gated, advisory only (never writes config). Wire it into the repo-owner-intake agent profile's recommended tools. --- packages/loopover-mcp/bin/loopover-mcp.js | 2 +- src/api/routes.ts | 2 +- src/mcp/server.ts | 38 ++++++++- test/unit/mcp-config-recommendation.test.ts | 90 +++++++++++++++++++++ test/unit/mcp-output-schemas.test.ts | 1 + 5 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 test/unit/mcp-config-recommendation.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 6aff9ac112..f6dfe12118 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", "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.", diff --git a/src/api/routes.ts b/src/api/routes.ts index f8d3f61bc9..03d67d7e42 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -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 diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 78b6eb9940..3776fbda13 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 { @@ -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(), @@ -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", { @@ -2809,6 +2832,19 @@ export class LoopoverMcp { }; } + private async getConfigRecommendation(input: { owner: string; repo: string }): Promise { + 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, + }; + } + private async getBurdenForecast(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(fullName); diff --git a/test/unit/mcp-config-recommendation.test.ts b/test/unit/mcp-config-recommendation.test.ts new file mode 100644 index 0000000000..29c20b0ff3 --- /dev/null +++ b/test/unit/mcp-config-recommendation.test.ts @@ -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 { + 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; + 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; + 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); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 81dfcd3046..87332f9478 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -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",