Skip to content
Closed
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
21 changes: 21 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.",
},
{
name: "loopover_get_repo_focus_manifest",
category: "maintainer",
description:
"Return a repo's own persisted focus manifest (.loopover.yml policy) plus its compiled policy. Read-only; maintainer/owner/operator authenticated. Distinct from loopover_validate_config (ad-hoc string validation).",
},
{
name: "loopover_get_activation_preview",
category: "maintainer",
Expand Down Expand Up @@ -1569,6 +1575,21 @@ registerStdioTool(
},
);

// (#7808) CLI stdio mirror of the remote loopover_get_repo_focus_manifest — thin GET proxy of the
// requireAppRole-gated /v1/repos/:owner/:repo/focus-manifest route (same ownerRepoShape + apiGet pattern
// as maintainer_noise). No human CLI verb.
registerStdioTool(
"loopover_get_repo_focus_manifest",
{
description: stdioToolDescription("loopover_get_repo_focus_manifest"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }: any) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver focus manifest.", await apiGet(`${prefix}/focus-manifest`));
},
);

// (#7799) CLI stdio mirror of the remote loopover_get_activation_preview — thin GET proxy of the already
// maintainer-scoped /v1/repos/:owner/:repo/activation-preview route (same ownerRepoShape + apiGet pattern
// as maintainer_noise).
Expand Down
50 changes: 49 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadi
import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode";
import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVerdict } from "../rules/predicted-gate";
export { buildGateDispositions, type GateDisposition } from "../rules/predicted-gate";
Expand Down Expand Up @@ -905,6 +905,13 @@ const maintainerNoiseOutputSchema = {
summary: z.string().optional(),
};

// #7808 - repo's own persisted focus manifest + compiled policy (REST GET focus-manifest mirror).
const repoFocusManifestOutputSchema = {
repoFullName: z.string().optional(),
manifest: z.unknown().optional(),
policy: z.unknown().optional(),
};

// (#7799) Repo-specific "here's what LoopOver would have surfaced" activation preview over recent PRs.
// Mirrors buildMaintainerActivationPreview's shape; deterministic, maintainer-authenticated, advisory only.
const activationPreviewOutputSchema = {
Expand Down Expand Up @@ -1868,6 +1875,7 @@ export const MCP_TOOL_CATEGORY_IDS: readonly McpToolCategory[] = ["discovery", "
export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
loopover_get_repo_context: "maintainer",
loopover_get_maintainer_noise: "maintainer",
loopover_get_repo_focus_manifest: "maintainer",
loopover_get_activation_preview: "maintainer",
loopover_get_label_audit: "maintainer",
loopover_get_maintainer_lane: "maintainer",
Expand Down Expand Up @@ -2005,6 +2013,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getMaintainerNoise(input)),
);

register(
"loopover_get_repo_focus_manifest",
{
description:
"Return a repo's own persisted focus manifest (.loopover.yml policy) plus its compiled policy. Read-only; maintainer/owner/operator authenticated — same auth boundary as GET /v1/repos/:owner/:repo/focus-manifest. Distinct from loopover_validate_config (ad-hoc string validation with no repo lookup).",
inputSchema: ownerRepoShape,
outputSchema: repoFocusManifestOutputSchema,
},
async (input) => this.toolResult(await this.getRepoFocusManifest(input)),
);

register(
"loopover_get_activation_preview",
{
Expand Down Expand Up @@ -3138,6 +3157,22 @@ export class LoopoverMcp {
await this.requireRepoAccess(repoFullName);
}

// #7808 - mirror GET /v1/repos/:owner/:repo/focus-manifest auth: requireAppRole([maintainer,owner,operator])
// plus session requireSessionRepoAccess. Static `mcp` is never trusted (insufficient_role); api/internal are.
private async requireFocusManifestReadAccess(repoFullName: string): Promise<void> {
if (this.identity.kind === "static") {
if (this.identity.actor === "mcp") {
throw new Error("Forbidden: focus-manifest requires a maintainer, owner, or operator session (insufficient_role).");
}
return;
}
const summary = await loadControlPanelRoleSummary(this.env, this.identity.actor);
if (!summary.roles.some((role) => role === "maintainer" || role === "owner" || role === "operator")) {
throw new Error("Forbidden: maintainer, owner, or operator role is required for focus-manifest (insufficient_role).");
}
await this.requireRepoAccess(repoFullName);
}

// Stricter than requireRepoAccess (read): a maintainer-MANAGE gate for write actions (#784 propose-action).
// A session must own/maintain the repo (or be an operator); api/internal static identities are trusted (they
// are operator-only Worker secrets, never handed to end users). The static `mcp` identity is NOT trusted here:
Expand Down Expand Up @@ -3255,6 +3290,19 @@ export class LoopoverMcp {
};
}

// #7808 - thin MCP surface over GET /v1/repos/:owner/:repo/focus-manifest. Same requireAppRole +
// session-repo-access boundary as the REST route; same loadRepoFocusManifest + compileFocusManifestPolicy pair.
private async getRepoFocusManifest(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
await this.requireFocusManifestReadAccess(fullName);
const manifest = await loadRepoFocusManifest(this.env, fullName);
const policy = compileFocusManifestPolicy(manifest);
return {
summary: `LoopOver focus manifest for ${fullName}.`,
data: { repoFullName: fullName, manifest, policy } as unknown as Record<string, unknown>,
};
}

// (#7799) MCP surface for GET /v1/repos/:owner/:repo/activation-preview. Assembles the same inputs the REST
// route does (getRepository + resolveRepositorySettings + listPullRequests) and defers to the guarded
// buildMaintainerActivationPreview service. Deterministic and advisory-only -- never runs AI.
Expand Down
2 changes: 2 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5530,6 +5530,7 @@ describe("api routes", () => {
expect(toolNames).toContain("loopover_get_upstream_ruleset");
expect(toolNames).toContain("loopover_get_live_gate_thresholds");
expect(toolNames).toContain("loopover_get_gate_config_effective");
expect(toolNames).toContain("loopover_get_repo_focus_manifest");
expect(toolNames).toContain("loopover_get_pr_maintainer_packet");
expect(toolNames).toContain("loopover_explain_review_risk");
expect(toolNames).toContain("loopover_compare_pr_variants");
Expand Down Expand Up @@ -5807,6 +5808,7 @@ describe("api routes", () => {
["loopover_get_upstream_ruleset", {}],
["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_gate_config_effective", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_repo_focus_manifest", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }],
[
"loopover_preview_local_pr_score",
Expand Down
80 changes: 80 additions & 0 deletions test/unit/mcp-cli-repo-focus-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// #7808: in-process coverage for the loopover_get_repo_focus_manifest stdio tool.
// Same #7764 entrypoint-guard pattern as mcp-cli-gate-config-effective — import .ts, hold exported `server`,
// connect InMemoryTransport so v8/Codecov attributes registerStdioTool.
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;

type BinModule = {
server: { connect: (transport: unknown) => Promise<void> };
};

let tempDir = "";
const capturedRequests: Array<{ url: string; method: string }> = [];
const loaded = new Map<string, BinModule>();

beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), "loopover-repo-focus-manifest-"));
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/focus-manifest")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
process.env.LOOPOVER_API_URL = apiUrl;
process.env.LOOPOVER_API_TOKEN = "in-process-token";
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
process.env.LOOPOVER_CONFIG_DIR = tempDir;
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
for (const specifier of MODULES) {
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
}
}, 120_000);

afterAll(async () => {
await closeFixtureServer();
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
delete process.env.LOOPOVER_API_URL;
delete process.env.LOOPOVER_API_TOKEN;
delete process.env.LOOPOVER_CONFIG_DIR;
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
});

describe("bin loopover_get_repo_focus_manifest stdio tool (in-process, #7808)", () => {
it.each(MODULES)("registers and proxies GET .../focus-manifest — %s", async (specifier) => {
capturedRequests.length = 0;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "repo-focus-manifest-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_get_repo_focus_manifest");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/focus manifest|compiled policy/i);

const result = await client.callTool({
name: "loopover_get_repo_focus_manifest",
arguments: { owner: "owner", repo: "repo" },
});
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/repos/owner/repo/focus-manifest");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).toContain("manifest");
expect(text).toContain("policy");
} finally {
await client.close().catch(() => undefined);
}
});
});
73 changes: 73 additions & 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_activation_preview",
"loopover_get_live_gate_thresholds",
"loopover_get_gate_config_effective",
"loopover_get_repo_focus_manifest",
"loopover_get_label_audit",
"loopover_get_maintainer_lane",
"loopover_get_repo_onboarding_pack",
Expand Down Expand Up @@ -158,6 +159,10 @@ describe("MCP output schema discovery", () => {
const gateConfig = byName.get("loopover_get_gate_config_effective");
const gateConfigProps = Object.keys((gateConfig?.outputSchema?.properties ?? {}) as Record<string, unknown>);
expect(gateConfigProps).toEqual(expect.arrayContaining(["repoFullName", "effective", "shadowPending"]));

const focusManifest = byName.get("loopover_get_repo_focus_manifest");
const focusManifestProps = Object.keys((focusManifest?.outputSchema?.properties ?? {}) as Record<string, unknown>);
expect(focusManifestProps).toEqual(expect.arrayContaining(["repoFullName", "manifest", "policy"]));
});

it("preserves the full tool inventory while adding output schemas", async () => {
Expand Down Expand Up @@ -384,6 +389,74 @@ describe("MCP tool calls return schema-valid structured content", () => {
expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "octo/demo" });
});

it("loopover_get_repo_focus_manifest returns persisted manifest + policy for trusted api identity (#7808)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
const { client } = await connectTestClient(env, { kind: "static", actor: "api" });
const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toMatchObject({
repoFullName: "octo/demo",
manifest: expect.anything(),
policy: expect.anything(),
});
});

it("loopover_get_repo_focus_manifest rejects the shared static mcp identity (#7808)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
const { client } = await connectTestClient(env);
const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toMatch(/insufficient_role|maintainer, owner, or operator/i);
});

it("loopover_get_repo_focus_manifest rejects sessions without maintainer/owner/operator role (#7808)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
const { client } = await connectTestClient(env, {
kind: "session",
actor: "read-only-member",
session: {
id: "session-read-only-member-focus",
tokenHash: "hash",
login: "read-only-member",
scopes: [],
expiresAt: "2999-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
metadata: {},
},
});
const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toMatch(/insufficient_role|maintainer, owner, or operator/i);
});

it("loopover_get_repo_focus_manifest allows an operator session (#7808)", async () => {
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "focus-operator" });
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
const { client } = await connectTestClient(env, {
kind: "session",
actor: "focus-operator",
session: {
id: "session-focus-operator",
tokenHash: "hash",
login: "focus-operator",
scopes: [],
expiresAt: "2999-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
metadata: {},
},
});
const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toMatchObject({
repoFullName: "octo/demo",
manifest: expect.anything(),
policy: expect.anything(),
});
});

it("loopover_get_activation_preview denies cached member-only session access (#7799)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim-org/private-repo", private: true, owner: { login: "victim-org" }, default_branch: "main" });
Expand Down
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
// (#7801 registered the loopover_get_live_gate_thresholds remote+stdio tool, taking the count from 84 to 85.)
// (#7802 registered the loopover_get_pr_maintainer_packet remote+stdio tool, taking the count from 85 to 86.)
// (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.)
// (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 87 to 88.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -75,14 +76,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 87 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 88 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name);
const primary = names.filter((n) => n.startsWith("loopover_"));
const legacy = names.filter((n) => n.startsWith("gittensory_"));
expect(primary.length).toBe(87);
expect(primary.length).toBe(88);
expect(legacy.length).toBe(0);
expect(names.length).toBe(87);
expect(names.length).toBe(88);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -94,14 +95,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 87-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 88-tool count the live server registers", async () => {
const { tools } = await client.listTools();
const payload = JSON.parse(run(["tools", "--json"])) as {
count: number;
tools: Array<{ name: string }>;
};
expect(payload.count).toBe(tools.length);
expect(payload.count).toBe(87);
expect(payload.count).toBe(88);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down
11 changes: 11 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,17 @@ export async function startFixtureServer(
);
return;
}
// #7808: repo's own persisted focus manifest + compiled policy.
if (request.url === "/v1/repos/owner/repo/focus-manifest" && request.method === "GET") {
response.end(
JSON.stringify({
repoFullName: "owner/repo",
manifest: { version: 1, lanes: { lane: "bug" } },
policy: { lane: "bug", generatedAt: "2026-06-01T00:00:00.000Z" },
}),
);
return;
}
if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") {
response.end(
JSON.stringify({
Expand Down