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
37 changes: 37 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -2043,6 +2055,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -3857,6 +3885,15 @@ export class LoopoverMcp {
};
}

private async getRepoSettings(input: { owner: string; repo: string }): Promise<ToolPayload> {
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;
Expand Down
65 changes: 65 additions & 0 deletions test/unit/mcp-repo-settings.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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);
});
});