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
36 changes: 36 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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).
Expand Down
109 changes: 109 additions & 0 deletions test/unit/mcp-cli-get-repo-onboarding-pack.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
};

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

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);
}
});
});
7 changes: 4 additions & 3 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down