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 @@ -1074,6 +1084,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 @@ -1752,6 +1768,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);
}
});
});
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 @@ -39,6 +39,7 @@
// (#7752 registered the loopover_get_automation_state stdio tool, taking the count from 94 to 95.)
// (#7757 registered the loopover_get_agent_audit_feed stdio tool, taking the count from 95 to 96.)
// (#7754 registered the loopover_refresh_repo_docs stdio tool, taking the count from 96 to 97.)
// (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 97 to 98.)
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 @@ -85,14 +86,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 97 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 98 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(97);
expect(primary.length).toBe(98);
expect(legacy.length).toBe(0);
expect(names.length).toBe(97);
expect(names.length).toBe(98);
});

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

it("`loopover-mcp tools --json` reports the same 97-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 98-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(97);
expect(payload.count).toBe(98);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down