Skip to content

Commit 58e65dd

Browse files
committed
feat(mcp): add remote + stdio tool surfaces for loopover_get_repo_focus_manifest
Register loopover_get_repo_focus_manifest as a remote MCP tool (src/mcp/server.ts) and a local stdio MCP tool (packages/loopover-mcp/bin), mirroring the two-surface loopover_get_maintainer_noise shape but replicating the GET /v1/repos/:owner/:repo/focus-manifest route's own auth: requireRepoAccess (the read-level maintainer/owner/operator + session-repo-access mirror), not the stricter requireRepoApprovalQueueAccess. Read-only: no refresh/PUT tool, no new REST route, no new human CLI verb. Closes #7808
1 parent b3e1bc3 commit 58e65dd

6 files changed

Lines changed: 167 additions & 6 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,11 @@ const STDIO_TOOL_DESCRIPTORS = [
986986
category: "maintainer",
987987
description: "Return the repo's maintainer activation preview: a deterministic run of the advisory engine over recent PRs (evaluated/with-findings counts, distinct finding codes, per-PR samples, current review-check mode, and the single recommended next action). Maintainer-authenticated; advisory only.",
988988
},
989+
{
990+
name: "loopover_get_repo_focus_manifest",
991+
category: "maintainer",
992+
description: "Return a repo's own persisted focus manifest plus its compiled policy. Same as GET /v1/repos/:owner/:repo/focus-manifest; read-only, maintainer-authenticated.",
993+
},
989994
{
990995
name: "loopover_preflight_pr",
991996
category: "discovery",
@@ -1542,6 +1547,18 @@ registerStdioTool(
15421547
},
15431548
);
15441549

1550+
registerStdioTool(
1551+
"loopover_get_repo_focus_manifest",
1552+
{
1553+
description: stdioToolDescription("loopover_get_repo_focus_manifest"),
1554+
inputSchema: ownerRepoShape,
1555+
},
1556+
async ({ owner, repo }: any) => {
1557+
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
1558+
return toolResult(`Focus manifest for ${owner}/${repo}.`, await apiGet(`${prefix}/focus-manifest`));
1559+
},
1560+
);
1561+
15451562
registerStdioTool(
15461563
"loopover_get_issue_quality",
15471564
{

src/mcp/server.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadi
168168
import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
169169
import { resolveRepositorySettings } from "../settings/repository-settings";
170170
import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode";
171-
import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
171+
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
172172
import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader";
173173
import { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVerdict } from "../rules/predicted-gate";
174174
export { buildGateDispositions, type GateDisposition } from "../rules/predicted-gate";
@@ -911,6 +911,15 @@ const activationPreviewOutputSchema = {
911911
summary: z.string().optional(),
912912
};
913913

914+
// #7808: the repo's own persisted focus manifest plus its compiled policy, mirroring the
915+
// GET /v1/repos/:owner/:repo/focus-manifest response ({ repoFullName, manifest, policy }). Both
916+
// nested payloads are large structured objects, so they follow the house z.unknown() style.
917+
const focusManifestOutputSchema = {
918+
repoFullName: z.string().optional(),
919+
manifest: z.unknown().optional(),
920+
policy: z.unknown().optional(),
921+
};
922+
914923
const labelAuditOutputSchema = {
915924
repoFullName: z.string().optional(),
916925
generatedAt: z.string().optional(),
@@ -1816,6 +1825,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
18161825
loopover_get_repo_context: "maintainer",
18171826
loopover_get_maintainer_noise: "maintainer",
18181827
loopover_get_activation_preview: "maintainer",
1828+
loopover_get_repo_focus_manifest: "maintainer",
18191829
loopover_get_label_audit: "maintainer",
18201830
loopover_get_maintainer_lane: "maintainer",
18211831
loopover_get_repo_onboarding_pack: "maintainer",
@@ -1958,6 +1968,16 @@ export class LoopoverMcp {
19581968
async (input) => this.toolResult(await this.getActivationPreview(input)),
19591969
);
19601970

1971+
register(
1972+
"loopover_get_repo_focus_manifest",
1973+
{
1974+
description: "Return a repo's own persisted focus manifest plus its compiled policy. Same as GET /v1/repos/:owner/:repo/focus-manifest; read-only, maintainer-authenticated.",
1975+
inputSchema: ownerRepoShape,
1976+
outputSchema: focusManifestOutputSchema,
1977+
},
1978+
async (input) => this.toolResult(await this.getRepoFocusManifest(input)),
1979+
);
1980+
19611981
register(
19621982
"loopover_get_label_audit",
19631983
{
@@ -3168,6 +3188,21 @@ export class LoopoverMcp {
31683188
};
31693189
}
31703190

3191+
// #7808: mirror GET /v1/repos/:owner/:repo/focus-manifest exactly -- read the repo's own stored
3192+
// manifest and compile its policy. That route gates on requireAppRole(["maintainer","owner","operator"])
3193+
// plus a session-repo-access check; in the MCP layer requireRepoAccess is the faithful read-level mirror
3194+
// (the stricter requireRepoApprovalQueueAccess above adds a live-write check the GET route does not).
3195+
private async getRepoFocusManifest(input: { owner: string; repo: string }): Promise<ToolPayload> {
3196+
const fullName = `${input.owner}/${input.repo}`;
3197+
await this.requireRepoAccess(fullName);
3198+
const manifest = await loadRepoFocusManifest(this.env, fullName);
3199+
const policy = compileFocusManifestPolicy(manifest);
3200+
return {
3201+
summary: `Focus manifest for ${fullName}.`,
3202+
data: { repoFullName: fullName, manifest, policy } as unknown as Record<string, unknown>,
3203+
};
3204+
}
3205+
31713206
private async getLabelAudit(input: { owner: string; repo: string }): Promise<ToolPayload> {
31723207
const fullName = `${input.owner}/${input.repo}`;
31733208
await this.requireRepoAccess(fullName);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7808: in-process coverage for the loopover_get_repo_focus_manifest stdio tool in
10+
// packages/loopover-mcp/bin/loopover-mcp.ts. The bin's stdio server is otherwise only exercised via
11+
// subprocess spawn (mcp-cli-*.test.ts), which v8 cannot instrument -- #7764's entrypoint guard
12+
// (isProcessEntrypoint) is what lets a test import the module without it binding stdin/hijacking argv,
13+
// so the registered tool's apiGet-proxy body gets real Codecov-measured coverage. Only the committed .ts
14+
// source is imported: since #7705 the compiled .js is a gitignored build artifact.
15+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
16+
17+
type BinModule = {
18+
server: { connect: (transport: unknown) => Promise<void> };
19+
};
20+
21+
let tempDir = "";
22+
const loaded = new Map<string, BinModule>();
23+
24+
beforeAll(async () => {
25+
tempDir = mkdtempSync(join(tmpdir(), "loopover-focus-manifest-"));
26+
const apiUrl = await startFixtureServer();
27+
// The bin reads LOOPOVER_API_URL at module load, so set the env BEFORE importing (hence the dynamic import).
28+
process.env.LOOPOVER_API_URL = apiUrl;
29+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
30+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
31+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
32+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
33+
for (const specifier of MODULES) {
34+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
35+
}
36+
});
37+
38+
afterAll(async () => {
39+
await closeFixtureServer();
40+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
41+
delete process.env.LOOPOVER_API_URL;
42+
delete process.env.LOOPOVER_API_TOKEN;
43+
delete process.env.LOOPOVER_CONFIG_DIR;
44+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
45+
});
46+
47+
describe("bin loopover_get_repo_focus_manifest stdio tool (in-process, #7808)", () => {
48+
it.each(MODULES)("proxies GET /focus-manifest and returns the manifest + policy — %s", async (specifier) => {
49+
const mod = loaded.get(specifier)!;
50+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
51+
await mod.server.connect(serverTransport);
52+
const client = new Client({ name: "focus-manifest-test", version: "0.1.0" }, { capabilities: {} });
53+
await client.connect(clientTransport);
54+
try {
55+
const result = await client.callTool({
56+
name: "loopover_get_repo_focus_manifest",
57+
arguments: { owner: "owner", repo: "repo" },
58+
});
59+
expect(result.isError).toBeFalsy();
60+
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text;
61+
expect(text).toContain("Focus manifest for owner/repo.");
62+
// The fixture's manifest/policy payload is proxied through verbatim.
63+
expect(text).toContain("focusPaths");
64+
expect(text).toContain("pathAllowlist");
65+
expect(text).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
66+
} finally {
67+
await client.close();
68+
}
69+
});
70+
});

test/unit/mcp-output-schemas.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import { LoopoverMcp } from "../../src/mcp/server";
77
import { normalizeRegistryPayload } from "../../src/registry/normalize";
88
import { persistRegistrySnapshot } from "../../src/registry/sync";
99
import { REPO_OUTCOME_PATTERNS_SIGNAL } from "../../src/services/repo-outcome-patterns";
10+
import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader";
1011
import { createTestEnv } from "../helpers/d1";
1112

1213
// Tools that ship an MCP-native output schema so modern clients can validate/render responses.
1314
const TOOLS_WITH_OUTPUT_SCHEMA = [
1415
"loopover_get_repo_context",
1516
"loopover_get_maintainer_noise",
1617
"loopover_get_activation_preview",
18+
"loopover_get_repo_focus_manifest",
1719
"loopover_get_label_audit",
1820
"loopover_get_maintainer_lane",
1921
"loopover_get_repo_onboarding_pack",
@@ -314,6 +316,31 @@ describe("MCP tool calls return schema-valid structured content", () => {
314316
expect(result.structuredContent).toBeUndefined();
315317
});
316318

319+
it("loopover_get_repo_focus_manifest returns the repo's stored focus manifest and compiled policy (#7808)", async () => {
320+
const env = createTestEnv();
321+
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
322+
// Seed a persisted api_record manifest so loadRepoFocusManifest returns it from cache (no live GitHub fetch).
323+
await upsertRepoFocusManifest(env, "octo/demo", { wantedPaths: ["src/"], preferredLabels: ["bug"] });
324+
const { client } = await connectTestClient(env);
325+
const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } });
326+
expect(result.isError).toBeFalsy();
327+
const data = result.structuredContent as Record<string, unknown>;
328+
expect(data.repoFullName).toBe("octo/demo");
329+
expect((data.manifest as Record<string, unknown>).present).toBe(true);
330+
expect(data.policy).toBeDefined();
331+
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
332+
});
333+
334+
it("loopover_get_repo_focus_manifest forbids a static mcp identity outside the read allowlist (#7808)", async () => {
335+
// Mirrors the GET route's session-repo-access denial: requireRepoAccess rejects the shared, end-user-obtainable
336+
// mcp token for a repo it wasn't explicitly allowlisted for. Covers the forbidden branch of the new tool's gate.
337+
const { client } = await connectTestClient(createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }));
338+
const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } });
339+
expect(result.isError).toBe(true);
340+
expect(JSON.stringify(result.content)).toMatch(/cannot access this repository/i);
341+
expect(result.structuredContent).toBeUndefined();
342+
});
343+
317344
it("loopover_get_label_audit returns a structured label-policy audit for a repo", async () => {
318345
const env = createTestEnv();
319346
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });

test/unit/mcp-tool-rename-aliases.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
// (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.)
2424
// (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.)
2525
// (#7764 registered the loopover_plan_repo_issues stdio + CLI + REST tool, taking the count from 80 to 81.)
26+
// (#7887 registered loopover_get_activation_preview's stdio tool without bumping this pin — live count became 82.)
27+
// (#7808 registered the loopover_get_repo_focus_manifest remote + stdio tool, taking the count from 82 to 83.)
2628
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2729
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2830
import { mkdtempSync, rmSync } from "node:fs";
@@ -70,14 +72,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7072
});
7173
afterEach(disconnect);
7274

73-
it("lists exactly 81 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
75+
it("lists exactly 83 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
7476
const { tools } = await client.listTools();
7577
const names = tools.map((t) => t.name);
7678
const primary = names.filter((n) => n.startsWith("loopover_"));
7779
const legacy = names.filter((n) => n.startsWith("gittensory_"));
78-
expect(primary.length).toBe(81);
80+
expect(primary.length).toBe(83);
7981
expect(legacy.length).toBe(0);
80-
expect(names.length).toBe(81);
82+
expect(names.length).toBe(83);
8183
});
8284

8385
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -89,14 +91,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8991
}
9092
});
9193

92-
it("`loopover-mcp tools --json` reports the same 81-tool count the live server registers", async () => {
94+
it("`loopover-mcp tools --json` reports the same 83-tool count the live server registers", async () => {
9395
const { tools } = await client.listTools();
9496
const payload = JSON.parse(run(["tools", "--json"])) as {
9597
count: number;
9698
tools: Array<{ name: string }>;
9799
};
98100
expect(payload.count).toBe(tools.length);
99-
expect(payload.count).toBe(81);
101+
expect(payload.count).toBe(83);
100102
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
101103
[...tools.map((t) => t.name)].sort(),
102104
);

test/unit/support/mcp-cli-harness.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,16 @@ export async function startFixtureServer(
571571
);
572572
return;
573573
}
574+
if (request.url === "/v1/repos/owner/repo/focus-manifest" && request.method === "GET") {
575+
response.end(
576+
JSON.stringify({
577+
repoFullName: "owner/repo",
578+
manifest: { version: 1, focusPaths: ["src/"], generatedAt: "2026-06-01T00:00:00.000Z" },
579+
policy: { pathAllowlist: ["src/"], compiled: true },
580+
}),
581+
);
582+
return;
583+
}
574584
if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") {
575585
response.end(
576586
JSON.stringify({

0 commit comments

Comments
 (0)