From 6033692938f4c03c170c20f6a32765cef3ace04b Mon Sep 17 00:00:00 2001 From: rsnetworkinginc Date: Wed, 22 Jul 2026 02:49:35 +0300 Subject: [PATCH] feat(mcp): register loopover_get_repo_onboarding_pack as a local stdio tool loopover_get_repo_onboarding_pack has a remote MCP tool (src/mcp/server.ts) and a `maintain onboarding-pack` CLI mirror, but no local stdio MCP tool registration, so a self-host operator using the local MCP server could not call it. Adds the registerStdioTool block following the existing sibling get-repo pattern (loopover_get_repo_focus_manifest et al.): a repoOnboardingPackShape zod const (owner/repo required, refresh optional), a STDIO_TOOL_DESCRIPTORS entry, and a thin GET proxy of {repoBase}/onboarding-pack/preview -- the same endpoint the onboarding-pack CLI already calls, no duplicated HTTP. `refresh: true` forwards ?refresh=true exactly as the CLI does; omitted otherwise so the server serves the cached preview. test/unit/mcp-cli-get-repo-onboarding-pack.test.ts drives it in-process (the coverage, exercising both sides of the refresh query ternary. Tool-count invariant bumped 90 -> 91. Closes #7756 --- packages/loopover-mcp/bin/loopover-mcp.ts | 36 ++++++ .../mcp-cli-get-repo-onboarding-pack.test.ts | 109 ++++++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 7 +- 3 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 test/unit/mcp-cli-get-repo-onboarding-pack.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index be50d84c86..3b5e49d48c 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -360,6 +360,16 @@ const ownerRepoShape = { repo: z.string().min(1), }; +// #7756: stdio mirror of the remote loopover_get_repo_onboarding_pack shape (src/mcp/server.ts) + the +// `maintain onboarding-pack` CLI. owner/repo are required like the sibling get-repo tools; `refresh` is +// optional and, when true, forwards ?refresh=true (the server treats only the exact string "true" as a +// refresh) so the preview is regenerated rather than served from cache. +const repoOnboardingPackShape = { + owner: z.string().min(1), + repo: z.string().min(1), + refresh: z.boolean().optional(), +}; + const skippedPrAuditShape = { repoFullName: z.string().trim().min(1).max(200).optional(), reason: z.string().trim().min(1).max(64).optional(), @@ -1053,6 +1063,12 @@ const STDIO_TOOL_DESCRIPTORS = [ 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_repo_onboarding_pack", + category: "maintainer", + description: + "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.", + }, { name: "loopover_get_activation_preview", category: "maintainer", @@ -1692,6 +1708,26 @@ registerStdioTool( }, ); +// #7756: stdio mirror of the remote loopover_get_repo_onboarding_pack + the `maintain onboarding-pack` CLI. +// Thin GET proxy of {repoBase}/onboarding-pack/preview (the same helper the CLI mirror calls); owner/repo +// resolve from args like the sibling get-repo tools, and bare `refresh: true` forwards ?refresh=true exactly +// as the CLI does (omit the query otherwise so the server serves the cached preview). +registerStdioTool( + "loopover_get_repo_onboarding_pack", + { + description: stdioToolDescription("loopover_get_repo_onboarding_pack"), + inputSchema: repoOnboardingPackShape, + }, + async ({ owner, repo, refresh }: any) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const query = refresh === true ? "?refresh=true" : ""; + return toolResult( + `LoopOver onboarding pack preview for ${owner}/${repo} (preview-only, not published).`, + await apiGet(`${prefix}/onboarding-pack/preview${query}`), + ); + }, +); + // (#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). diff --git a/test/unit/mcp-cli-get-repo-onboarding-pack.test.ts b/test/unit/mcp-cli-get-repo-onboarding-pack.test.ts new file mode 100644 index 0000000000..1c48279379 --- /dev/null +++ b/test/unit/mcp-cli-get-repo-onboarding-pack.test.ts @@ -0,0 +1,109 @@ +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"; + +// #7756: in-process coverage for the loopover_get_repo_onboarding_pack stdio tool. +// Same #7764 entrypoint-guard pattern as mcp-cli-repo-focus-manifest — import the .ts, hold the exported +// `server`, connect an InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess +// spawn cannot be instrumented). Drives GET {repoBase}/onboarding-pack/preview end to end, covering both +// sides of the `refresh === true` query ternary. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-repo-onboarding-pack-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/onboarding-pack/preview")) { + 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_onboarding_pack stdio tool (in-process, #7756)", () => { + it.each(MODULES)("registers and proxies GET .../onboarding-pack/preview without a refresh query — %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-onboarding-pack-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_onboarding_pack"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/onboarding pack|preview-only|not published/i); + + // refresh omitted -> the else side of `refresh === true`: no query string appended. + const result = await client.callTool({ + name: "loopover_get_repo_onboarding_pack", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toBe("/v1/repos/owner/repo/onboarding-pack/preview"); + expect(captured.url).not.toContain("refresh"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).toContain("onboarding pack preview for owner/repo"); + expect(text).toContain("preview-only, not published"); + expect(text).toContain("Contributor onboarding"); + } finally { + await client.close().catch(() => undefined); + } + }); + + it.each(MODULES)("forwards ?refresh=true when refresh is true — %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-onboarding-pack-refresh", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + // refresh: true -> the true side of `refresh === true`: ?refresh=true appended. + const result = await client.callTool({ + name: "loopover_get_repo_onboarding_pack", + arguments: { owner: "owner", repo: "repo", refresh: true }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toBe("/v1/repos/owner/repo/onboarding-pack/preview?refresh=true"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + expect(JSON.stringify(result)).toContain("onboarding pack preview for owner/repo"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 984e9da048..585e23c9c3 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -36,6 +36,7 @@ // (#7763 registered the loopover_watch_issues stdio tool, taking the count from 91 to 92.) // (#7759 registered the loopover_check_improvement_potential stdio tool, taking the count from 92 to 93.) // (#7761 registered the loopover_list_notifications stdio tool, taking the count from 93 to 94.) +// (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 94 to 95.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -87,9 +88,9 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { 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(94); + expect(primary.length).toBe(95); expect(legacy.length).toBe(0); - expect(names.length).toBe(94); + expect(names.length).toBe(95); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -108,7 +109,7 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { tools: Array<{ name: string }>; }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(94); + expect(payload.count).toBe(95); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );