From 3b31d0ec8e56150066251569ff10b831ae0f4ab3 Mon Sep 17 00:00:00 2001 From: bitfathers94 <237535319+bitfathers94@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:46:04 +0000 Subject: [PATCH] feat(mcp): add loopover_get_repo_settings mirroring GET /repos/:owner/:repo/settings Expose the raw effective maintainer settings row (resolveRepositorySettings) over MCP, the last unmirrored read in the settings / automation-state / gate-config-effective trio. Mirrors loopover_get_automation_state: same ownerRepoShape input, same maintainer-only repo-access boundary, thin GET proxy returning the resolver output unmodified. Categorized maintainer; no OpenAPI/schema regen needed. --- src/mcp/server.ts | 37 ++++++++++++++++ test/unit/mcp-repo-settings.test.ts | 65 +++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 test/unit/mcp-repo-settings.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6b47efa240..635f863ad7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1085,6 +1085,18 @@ const gateConfigEffectiveOutputSchema = { status: z.string().optional(), }; +// #9297: the raw EFFECTIVE settings row GET /v1/repos/:owner/:repo/settings returns (resolveRepositorySettings), +// distinct from the derived automation-state / gate-config-effective views. Every field optional (non-strict, +// documentation-only) so this stays byte-for-byte the resolver's output -- extra keys are passed through +// unmodified and future settings fields need no schema edit here. +const repoSettingsOutputSchema = { + repoFullName: z.string().optional(), + commentMode: z.string().optional(), + gatePack: z.string().optional(), + reviewCheckMode: z.string().optional(), + slopGateMode: z.string().optional(), +}; + const maintainerMeasurementReportOutputSchema = { repoFullName: z.string().optional(), generatedAt: z.string().optional(), @@ -2043,6 +2055,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_pr_maintainer_packet: "review", loopover_get_live_gate_thresholds: "maintainer", loopover_get_gate_config_effective: "maintainer", + loopover_get_repo_settings: "maintainer", loopover_validate_linked_issue: "discovery", loopover_check_before_start: "discovery", loopover_find_opportunities: "discovery", @@ -2696,6 +2709,21 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGateConfigEffective(input)), ); + // #9297: the last unmirrored read in the settings/automation-state/gate-config-effective trio. Mirrors + // GET /v1/repos/:owner/:repo/settings -- the RAW effective settings row those two derived views compute + // from, which /settings deliberately returns on its own. Maintainer-only, same shape as + // loopover_get_automation_state. + register( + "loopover_get_repo_settings", + { + description: + "Return a repo's RAW effective maintainer settings row (gate/slop/label/surface/command-auth settings, including agent autonomy controls) -- the same resolveRepositorySettings output GET /v1/repos/:owner/:repo/settings returns, distinct from the derived automation-state / gate-config-effective views. Metadata-only, repo-scoped, no GitHub writes. Maintainer access required.", + inputSchema: ownerRepoShape, + outputSchema: repoSettingsOutputSchema, + }, + async (input) => this.toolResult(await this.getRepoSettings(input)), + ); + register( "loopover_validate_linked_issue", { @@ -3857,6 +3885,15 @@ export class LoopoverMcp { }; } + private async getRepoSettings(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoAccess(fullName); + // Shared with GET /v1/repos/:owner/:repo/settings so the two surfaces cannot drift: return the resolved + // EFFECTIVE settings row unmodified (spread into a plain Record for ToolPayload.data), no derived fields. + const settings = await resolveRepositorySettings(this.env, fullName); + return { summary: `Effective settings for ${fullName}.`, data: { ...settings } }; + } + private async validateLinkedIssue(input: { owner: string; repo: string; diff --git a/test/unit/mcp-repo-settings.test.ts b/test/unit/mcp-repo-settings.test.ts new file mode 100644 index 0000000000..6c7b91d085 --- /dev/null +++ b/test/unit/mcp-repo-settings.test.ts @@ -0,0 +1,65 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { resolveRepositorySettings } from "../../src/settings/repository-settings"; +import type { AuthIdentity } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +// #9297: loopover_get_repo_settings mirrors GET /v1/repos/:owner/:repo/settings -- the RAW effective settings +// row (resolveRepositorySettings), maintainer-gated like the REST route, distinct from the derived +// automation-state / gate-config-effective views that already have MCP tools. +async function connect(env: Env, identity?: AuthIdentity) { + 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: "loopover-repo-settings-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP loopover_get_repo_settings (#9297)", () => { + it("returns a repo's RAW effective settings for an authorized (maintainer-scoped) caller", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", gatePack: "oss-anti-slop", slopGateMode: "block", autonomy: { merge: "auto" } }); + + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_repo_settings", arguments: { owner: "owner", repo: "repo" } }); + + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("owner/repo"); + expect(data.gatePack).toBe("oss-anti-slop"); + expect(data.slopGateMode).toBe("block"); + // No reward/wallet/hotkey leakage in the exposed settings surface. + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); + }); + + it("REGRESSION (#9297): its output is byte-identical to GET /v1/repos/:owner/:repo/settings for the same repo", async () => { + // The REST route returns `resolveRepositorySettings(env, fullName)` unmodified; this tool must return the + // exact same row (no derived fields, no reshaping) so the two surfaces cannot drift. + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", commentMode: "all_prs", reviewCheckMode: "required" }); + + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_repo_settings", arguments: { owner: "owner", repo: "repo" } }); + + const restShape = await resolveRepositorySettings(env, "owner/repo"); + expect(result.structuredContent).toEqual(restShape); + }); + + it("forbids a static MCP-token caller when the repo is not in MCP_READ_REPO_ALLOWLIST (#2455)", async () => { + // "" overrides createTestEnv's own MCP_READ_REPO_ALLOWLIST: "*" default back to unset, exercising the real + // deny-by-default maintainer boundary (requireRepoAccess throws -> isError, never leaking the settings row). + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const client = await connect(env); // default identity: { kind: "static", actor: "mcp" } + const result = await client.callTool({ name: "loopover_get_repo_settings", arguments: { owner: "owner", repo: "repo" } }); + + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/cannot access this repository/i); + }); +});