Skip to content

Commit b9aa25f

Browse files
feat(mcp): register loopover_get_repo_onboarding_pack as a local (#7977)
* feat(mcp): register loopover_get_repo_onboarding_pack as a local stdio tool * add file --------- Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
1 parent eb31847 commit b9aa25f

3 files changed

Lines changed: 151 additions & 5 deletions

File tree

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,16 @@ const ownerRepoShape = {
360360
repo: z.string().min(1),
361361
};
362362

363+
// #7756: stdio mirror of the remote loopover_get_repo_onboarding_pack shape (src/mcp/server.ts) + the
364+
// `maintain onboarding-pack` CLI. owner/repo are required like the sibling get-repo tools; `refresh` is
365+
// optional and, when true, forwards ?refresh=true (the server treats only the exact string "true" as a
366+
// refresh) so the preview is regenerated rather than served from cache.
367+
const repoOnboardingPackShape = {
368+
owner: z.string().min(1),
369+
repo: z.string().min(1),
370+
refresh: z.boolean().optional(),
371+
};
372+
363373
const skippedPrAuditShape = {
364374
repoFullName: z.string().trim().min(1).max(200).optional(),
365375
reason: z.string().trim().min(1).max(64).optional(),
@@ -1074,6 +1084,12 @@ const STDIO_TOOL_DESCRIPTORS = [
10741084
description:
10751085
"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).",
10761086
},
1087+
{
1088+
name: "loopover_get_repo_onboarding_pack",
1089+
category: "maintainer",
1090+
description:
1091+
"Preview-only onboarding pack for a repository owner (contribution lanes, label policy, and public-safe guidance). Not published to GitHub. Pass `refresh` to regenerate the preview instead of serving the cached one.",
1092+
},
10771093
{
10781094
name: "loopover_get_activation_preview",
10791095
category: "maintainer",
@@ -1752,6 +1768,26 @@ registerStdioTool(
17521768
},
17531769
);
17541770

1771+
// #7756: stdio mirror of the remote loopover_get_repo_onboarding_pack + the `maintain onboarding-pack` CLI.
1772+
// Thin GET proxy of {repoBase}/onboarding-pack/preview (the same helper the CLI mirror calls); owner/repo
1773+
// resolve from args like the sibling get-repo tools, and bare `refresh: true` forwards ?refresh=true exactly
1774+
// as the CLI does (omit the query otherwise so the server serves the cached preview).
1775+
registerStdioTool(
1776+
"loopover_get_repo_onboarding_pack",
1777+
{
1778+
description: stdioToolDescription("loopover_get_repo_onboarding_pack"),
1779+
inputSchema: repoOnboardingPackShape,
1780+
},
1781+
async ({ owner, repo, refresh }: any) => {
1782+
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
1783+
const query = refresh === true ? "?refresh=true" : "";
1784+
return toolResult(
1785+
`LoopOver onboarding pack preview for ${owner}/${repo} (preview-only, not published).`,
1786+
await apiGet(`${prefix}/onboarding-pack/preview${query}`),
1787+
);
1788+
},
1789+
);
1790+
17551791
// (#7799) CLI stdio mirror of the remote loopover_get_activation_preview — thin GET proxy of the already
17561792
// maintainer-scoped /v1/repos/:owner/:repo/activation-preview route (same ownerRepoShape + apiGet pattern
17571793
// as maintainer_noise).
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
// #7756: in-process coverage for the loopover_get_repo_onboarding_pack stdio tool.
10+
// Same #7764 entrypoint-guard pattern as mcp-cli-repo-focus-manifest — import the .ts, hold the exported
11+
// `server`, connect an InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess
12+
// spawn cannot be instrumented). Drives GET {repoBase}/onboarding-pack/preview end to end, covering both
13+
// sides of the `refresh === true` query ternary.
14+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
15+
16+
type BinModule = {
17+
server: { connect: (transport: unknown) => Promise<void> };
18+
};
19+
20+
let tempDir = "";
21+
const capturedRequests: Array<{ url: string; method: string }> = [];
22+
const loaded = new Map<string, BinModule>();
23+
24+
beforeAll(async () => {
25+
tempDir = mkdtempSync(join(tmpdir(), "loopover-repo-onboarding-pack-"));
26+
const apiUrl = await startFixtureServer({
27+
onApiRequest: (request) => {
28+
if (request.url && request.url.includes("/onboarding-pack/preview")) {
29+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
30+
}
31+
},
32+
});
33+
process.env.LOOPOVER_API_URL = apiUrl;
34+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
35+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
36+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
37+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
38+
for (const specifier of MODULES) {
39+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
40+
}
41+
}, 120_000);
42+
43+
afterAll(async () => {
44+
await closeFixtureServer();
45+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
46+
delete process.env.LOOPOVER_API_URL;
47+
delete process.env.LOOPOVER_API_TOKEN;
48+
delete process.env.LOOPOVER_CONFIG_DIR;
49+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
50+
});
51+
52+
describe("bin loopover_get_repo_onboarding_pack stdio tool (in-process, #7756)", () => {
53+
it.each(MODULES)("registers and proxies GET .../onboarding-pack/preview without a refresh query — %s", async (specifier) => {
54+
capturedRequests.length = 0;
55+
const mod = loaded.get(specifier)!;
56+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
57+
await mod.server.connect(serverTransport);
58+
const client = new Client({ name: "repo-onboarding-pack-test", version: "0.1.0" }, { capabilities: {} });
59+
await client.connect(clientTransport);
60+
try {
61+
const { tools } = await client.listTools();
62+
const tool = tools.find((entry) => entry.name === "loopover_get_repo_onboarding_pack");
63+
expect(tool).toBeDefined();
64+
expect(tool?.description).toMatch(/onboarding pack|preview-only|not published/i);
65+
66+
// refresh omitted -> the else side of `refresh === true`: no query string appended.
67+
const result = await client.callTool({
68+
name: "loopover_get_repo_onboarding_pack",
69+
arguments: { owner: "owner", repo: "repo" },
70+
});
71+
expect(capturedRequests.length).toBe(1);
72+
const captured = capturedRequests[0]!;
73+
expect(captured.url).toBe("/v1/repos/owner/repo/onboarding-pack/preview");
74+
expect(captured.url).not.toContain("refresh");
75+
expect(captured.method).toBe("GET");
76+
expect(result.isError).toBeFalsy();
77+
const text = JSON.stringify(result);
78+
expect(text).toContain("onboarding pack preview for owner/repo");
79+
expect(text).toContain("preview-only, not published");
80+
expect(text).toContain("Contributor onboarding");
81+
} finally {
82+
await client.close().catch(() => undefined);
83+
}
84+
});
85+
86+
it.each(MODULES)("forwards ?refresh=true when refresh is true — %s", async (specifier) => {
87+
capturedRequests.length = 0;
88+
const mod = loaded.get(specifier)!;
89+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
90+
await mod.server.connect(serverTransport);
91+
const client = new Client({ name: "repo-onboarding-pack-refresh", version: "0.1.0" }, { capabilities: {} });
92+
await client.connect(clientTransport);
93+
try {
94+
// refresh: true -> the true side of `refresh === true`: ?refresh=true appended.
95+
const result = await client.callTool({
96+
name: "loopover_get_repo_onboarding_pack",
97+
arguments: { owner: "owner", repo: "repo", refresh: true },
98+
});
99+
expect(capturedRequests.length).toBe(1);
100+
const captured = capturedRequests[0]!;
101+
expect(captured.url).toBe("/v1/repos/owner/repo/onboarding-pack/preview?refresh=true");
102+
expect(captured.method).toBe("GET");
103+
expect(result.isError).toBeFalsy();
104+
expect(JSON.stringify(result)).toContain("onboarding pack preview for owner/repo");
105+
} finally {
106+
await client.close().catch(() => undefined);
107+
}
108+
});
109+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
// (#7752 registered the loopover_get_automation_state stdio tool, taking the count from 94 to 95.)
4040
// (#7757 registered the loopover_get_agent_audit_feed stdio tool, taking the count from 95 to 96.)
4141
// (#7754 registered the loopover_refresh_repo_docs stdio tool, taking the count from 96 to 97.)
42+
// (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 97 to 98.)
4243
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4344
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4445
import { mkdtempSync, rmSync } from "node:fs";
@@ -85,14 +86,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8586
});
8687
afterEach(disconnect);
8788

88-
it("lists exactly 97 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
89+
it("lists exactly 98 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
8990
const { tools } = await client.listTools();
9091
const names = tools.map((t) => t.name);
9192
const primary = names.filter((n) => n.startsWith("loopover_"));
9293
const legacy = names.filter((n) => n.startsWith("gittensory_"));
93-
expect(primary.length).toBe(97);
94+
expect(primary.length).toBe(98);
9495
expect(legacy.length).toBe(0);
95-
expect(names.length).toBe(97);
96+
expect(names.length).toBe(98);
9697
});
9798

9899
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -104,14 +105,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
104105
}
105106
});
106107

107-
it("`loopover-mcp tools --json` reports the same 97-tool count the live server registers", async () => {
108+
it("`loopover-mcp tools --json` reports the same 98-tool count the live server registers", async () => {
108109
const { tools } = await client.listTools();
109110
const payload = JSON.parse(run(["tools", "--json"])) as {
110111
count: number;
111112
tools: Array<{ name: string }>;
112113
};
113114
expect(payload.count).toBe(tools.length);
114-
expect(payload.count).toBe(97);
115+
expect(payload.count).toBe(98);
115116
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
116117
[...tools.map((t) => t.name)].sort(),
117118
);

0 commit comments

Comments
 (0)