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: 34 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,12 @@ const STDIO_TOOL_DESCRIPTORS = [
description:
"Inspect a contributor's open PRs on registered repos, classify queue state, and return public-safe next-step packets from cached metadata.",
},
{
name: "loopover_get_contributor_profile",
category: "discovery",
description:
"Return the evidence-backed LoopOver contributor profile for a GitHub login: registered repos, merged-PR history, and where the contributor is strongest. Takes login (the contributor's GitHub username). Same as `loopover-mcp contributor-profile`.",
},
{
name: "loopover_pr_outcome",
category: "review",
Expand Down Expand Up @@ -2303,6 +2309,23 @@ registerStdioTool(
},
);

// #7760: local stdio mirror of the loopover_get_contributor_profile remote tool (src/mcp/server.ts). The remote
// tool + `contributor-profile` CLI (#6737) already served this endpoint; only the stdio surface was missing. Mirrors
// the loopover_monitor_open_prs block above -- loginShape + the shared getContributorProfile call (no duplicated HTTP
// path). The summary is the remote tool's own fixed sentence (server.ts uses the identical string), so the two
// surfaces never drift; the full API payload rides along as structuredContent.
registerStdioTool(
"loopover_get_contributor_profile",
{
description: stdioToolDescription("loopover_get_contributor_profile"),
inputSchema: loginShape,
},
async ({ login }: any) => {
const payload = await getContributorProfile(login);
return toolResult(`LoopOver contributor profile for ${login}.`, payload);
},
);

registerStdioTool(
"loopover_pr_outcome",
{
Expand Down Expand Up @@ -4149,11 +4172,14 @@ function printContributorProfileHelp() {
// from --login / the active session / LOOPOVER_LOGIN / GITHUB_LOGIN, exactly like the sibling contributor
// commands, so an already-logged-in contributor never retypes their own login. Named `contributor-profile`
// because the top-level `profile` command already manages MCP client profiles.
async function contributorProfileCli(options: any) {
// #7760: exported (like maintainCli) so an in-process test can drive it directly -- the subprocess CLI harness
// v8 can't instrument, so the shared getContributorProfile call below is graded through this in-process entry.
export async function contributorProfileCli(options: any) {
if (options.help === true) return printContributorProfileHelp();
const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
if (!login) throw new Error("Pass --login <github-login>, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
const payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`);
// #7760: shared with the loopover_get_contributor_profile stdio tool so the endpoint path lives in one place.
const payload = await getContributorProfile(login);
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}
`);
Expand Down Expand Up @@ -6060,6 +6086,12 @@ function getOpenPrMonitor(login: any) {
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`);
}

// #7760: single source of truth for GET /v1/contributors/:login/profile, shared by the contributor-profile CLI
// and the loopover_get_contributor_profile stdio tool so neither duplicates the endpoint path.
function getContributorProfile(login: any) {
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`);
}

function getPrOutcomes(login: any, limit: any) {
const query = new URLSearchParams();
if (limit != null) query.set("limit", String(limit));
Expand Down
135 changes: 135 additions & 0 deletions test/unit/mcp-cli-contributor-profile-inprocess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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, vi } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// #7760: in-process coverage for the stdio loopover_get_contributor_profile tool AND the exported
// contributorProfileCli, both in packages/loopover-mcp/bin/loopover-mcp.ts. The bin is otherwise only exercised
// via subprocess spawn (the sibling mcp-cli-contributor-profile.test.ts), which v8 cannot instrument -- the
// isProcessEntrypoint guard is what lets a test import the module without it hijacking argv / binding stdin, so
// the shared getContributorProfile call + the new stdio handler get real Codecov-measured coverage. Same shape
// as mcp-cli-plan-issues.test.ts / mcp-cli-activation-preview.test.ts. Only the committed .ts source is imported.
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;

type BinModule = {
contributorProfileCli: (options: { login?: string; json?: boolean }) => Promise<void>;
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-contributor-profile-inprocess-"));
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/profile")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
// The bin reads LOOPOVER_API_URL at module load, so set the env BEFORE importing (hence the dynamic import).
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_API_TIMEOUT_MS;
delete process.env.LOOPOVER_CONFIG_DIR;
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
});

async function captureStdout(fn: () => Promise<void>): Promise<string> {
const chunks: string[] = [];
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => {
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
return true;
});
try {
await fn();
} finally {
spy.mockRestore();
}
return chunks.join("");
}

describe("bin loopover_get_contributor_profile stdio tool (in-process, #7760)", () => {
it.each(MODULES)("registers and proxies GET /v1/contributors/:login/profile — %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: "contributor-profile-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_contributor_profile");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/contributor profile/i);

const result = await client.callTool({
name: "loopover_get_contributor_profile",
arguments: { login: "octocat" },
});
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/contributors/octocat/profile");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
// structuredContent is the raw API payload; the summary line is the remote tool's fixed sentence.
expect(result.structuredContent).toMatchObject({ login: "octocat" });
const text = JSON.stringify(result);
expect(text).toContain("LoopOver contributor profile for octocat.");
expect(text).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling.");
} finally {
await client.close().catch(() => undefined);
}
});

it.each(MODULES)("url-encodes the login in the proxied path — %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: "contributor-profile-encode-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
await client.callTool({ name: "loopover_get_contributor_profile", arguments: { login: "a b/c" } });
expect(capturedRequests.at(-1)!.url).toContain("/v1/contributors/a%20b%2Fc/profile");
} finally {
await client.close().catch(() => undefined);
}
});
});

describe("bin contributor-profile CLI (in-process, #7760)", () => {
it.each(MODULES)("shares getContributorProfile with the stdio tool: prints the header + API summary — %s", async (specifier) => {
capturedRequests.length = 0;
const mod = loaded.get(specifier)!;
const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat" }));
expect(capturedRequests.at(-1)!.url).toBe("/v1/contributors/octocat/profile");
expect(out).toMatch(/LoopOver contributor profile for octocat\./);
expect(out).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling.");
});

it.each(MODULES)("--json re-serializes the same payload the shared call returned — %s", async (specifier) => {
const mod = loaded.get(specifier)!;
const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat", json: true }));
const payload = JSON.parse(out) as { login: string; summary: string };
expect(payload).toMatchObject({ login: "octocat", summary: "3 registered repos; 12 merged PRs; strongest in review-tooling." });
});
});
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 @@ -31,6 +31,7 @@
// (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.)
// (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.)
// (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 88 to 89.)
// (#7760 registered the loopover_get_contributor_profile stdio tool, taking the count from 89 to 90.)
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 @@ -77,14 +78,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

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