Skip to content

Commit 3790ecd

Browse files
feat(mcp): register loopover_get_contributor_profile as a local stdio tool (#7760) (#7958)
Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
1 parent fa3581d commit 3790ecd

3 files changed

Lines changed: 175 additions & 7 deletions

File tree

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,12 @@ const STDIO_TOOL_DESCRIPTORS = [
12431243
description:
12441244
"Inspect a contributor's open PRs on registered repos, classify queue state, and return public-safe next-step packets from cached metadata.",
12451245
},
1246+
{
1247+
name: "loopover_get_contributor_profile",
1248+
category: "discovery",
1249+
description:
1250+
"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`.",
1251+
},
12461252
{
12471253
name: "loopover_pr_outcome",
12481254
category: "review",
@@ -2317,6 +2323,23 @@ registerStdioTool(
23172323
},
23182324
);
23192325

2326+
// #7760: local stdio mirror of the loopover_get_contributor_profile remote tool (src/mcp/server.ts). The remote
2327+
// tool + `contributor-profile` CLI (#6737) already served this endpoint; only the stdio surface was missing. Mirrors
2328+
// the loopover_monitor_open_prs block above -- loginShape + the shared getContributorProfile call (no duplicated HTTP
2329+
// path). The summary is the remote tool's own fixed sentence (server.ts uses the identical string), so the two
2330+
// surfaces never drift; the full API payload rides along as structuredContent.
2331+
registerStdioTool(
2332+
"loopover_get_contributor_profile",
2333+
{
2334+
description: stdioToolDescription("loopover_get_contributor_profile"),
2335+
inputSchema: loginShape,
2336+
},
2337+
async ({ login }: any) => {
2338+
const payload = await getContributorProfile(login);
2339+
return toolResult(`LoopOver contributor profile for ${login}.`, payload);
2340+
},
2341+
);
2342+
23202343
registerStdioTool(
23212344
"loopover_pr_outcome",
23222345
{
@@ -4179,11 +4202,14 @@ function printContributorProfileHelp() {
41794202
// from --login / the active session / LOOPOVER_LOGIN / GITHUB_LOGIN, exactly like the sibling contributor
41804203
// commands, so an already-logged-in contributor never retypes their own login. Named `contributor-profile`
41814204
// because the top-level `profile` command already manages MCP client profiles.
4182-
async function contributorProfileCli(options: any) {
4205+
// #7760: exported (like maintainCli) so an in-process test can drive it directly -- the subprocess CLI harness
4206+
// v8 can't instrument, so the shared getContributorProfile call below is graded through this in-process entry.
4207+
export async function contributorProfileCli(options: any) {
41834208
if (options.help === true) return printContributorProfileHelp();
41844209
const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
41854210
if (!login) throw new Error("Pass --login <github-login>, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
4186-
const payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`);
4211+
// #7760: shared with the loopover_get_contributor_profile stdio tool so the endpoint path lives in one place.
4212+
const payload = await getContributorProfile(login);
41874213
if (options.json) {
41884214
process.stdout.write(`${JSON.stringify(payload, null, 2)}
41894215
`);
@@ -6090,6 +6116,12 @@ function getOpenPrMonitor(login: any) {
60906116
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`);
60916117
}
60926118

6119+
// #7760: single source of truth for GET /v1/contributors/:login/profile, shared by the contributor-profile CLI
6120+
// and the loopover_get_contributor_profile stdio tool so neither duplicates the endpoint path.
6121+
function getContributorProfile(login: any) {
6122+
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`);
6123+
}
6124+
60936125
function getPrOutcomes(login: any, limit: any) {
60946126
const query = new URLSearchParams();
60956127
if (limit != null) query.set("limit", String(limit));
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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, vi } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7760: in-process coverage for the stdio loopover_get_contributor_profile tool AND the exported
10+
// contributorProfileCli, both in packages/loopover-mcp/bin/loopover-mcp.ts. The bin is otherwise only exercised
11+
// via subprocess spawn (the sibling mcp-cli-contributor-profile.test.ts), which v8 cannot instrument -- the
12+
// isProcessEntrypoint guard is what lets a test import the module without it hijacking argv / binding stdin, so
13+
// the shared getContributorProfile call + the new stdio handler get real Codecov-measured coverage. Same shape
14+
// as mcp-cli-plan-issues.test.ts / mcp-cli-activation-preview.test.ts. Only the committed .ts source is imported.
15+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
16+
17+
type BinModule = {
18+
contributorProfileCli: (options: { login?: string; json?: boolean }) => Promise<void>;
19+
server: { connect: (transport: unknown) => Promise<void> };
20+
};
21+
22+
let tempDir = "";
23+
const capturedRequests: Array<{ url: string; method: string }> = [];
24+
const loaded = new Map<string, BinModule>();
25+
26+
beforeAll(async () => {
27+
tempDir = mkdtempSync(join(tmpdir(), "loopover-contributor-profile-inprocess-"));
28+
const apiUrl = await startFixtureServer({
29+
onApiRequest: (request) => {
30+
if (request.url && request.url.includes("/profile")) {
31+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
32+
}
33+
},
34+
});
35+
// The bin reads LOOPOVER_API_URL at module load, so set the env BEFORE importing (hence the dynamic import).
36+
process.env.LOOPOVER_API_URL = apiUrl;
37+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
38+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
39+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
40+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
41+
for (const specifier of MODULES) {
42+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
43+
}
44+
}, 120_000);
45+
46+
afterAll(async () => {
47+
await closeFixtureServer();
48+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
49+
delete process.env.LOOPOVER_API_URL;
50+
delete process.env.LOOPOVER_API_TOKEN;
51+
delete process.env.LOOPOVER_API_TIMEOUT_MS;
52+
delete process.env.LOOPOVER_CONFIG_DIR;
53+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
54+
});
55+
56+
async function captureStdout(fn: () => Promise<void>): Promise<string> {
57+
const chunks: string[] = [];
58+
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => {
59+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
60+
return true;
61+
});
62+
try {
63+
await fn();
64+
} finally {
65+
spy.mockRestore();
66+
}
67+
return chunks.join("");
68+
}
69+
70+
describe("bin loopover_get_contributor_profile stdio tool (in-process, #7760)", () => {
71+
it.each(MODULES)("registers and proxies GET /v1/contributors/:login/profile — %s", async (specifier) => {
72+
capturedRequests.length = 0;
73+
const mod = loaded.get(specifier)!;
74+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
75+
await mod.server.connect(serverTransport);
76+
const client = new Client({ name: "contributor-profile-test", version: "0.1.0" }, { capabilities: {} });
77+
await client.connect(clientTransport);
78+
try {
79+
const { tools } = await client.listTools();
80+
const tool = tools.find((entry) => entry.name === "loopover_get_contributor_profile");
81+
expect(tool).toBeDefined();
82+
expect(tool?.description).toMatch(/contributor profile/i);
83+
84+
const result = await client.callTool({
85+
name: "loopover_get_contributor_profile",
86+
arguments: { login: "octocat" },
87+
});
88+
expect(capturedRequests.length).toBe(1);
89+
const captured = capturedRequests[0]!;
90+
expect(captured.url).toContain("/v1/contributors/octocat/profile");
91+
expect(captured.method).toBe("GET");
92+
expect(result.isError).toBeFalsy();
93+
// structuredContent is the raw API payload; the summary line is the remote tool's fixed sentence.
94+
expect(result.structuredContent).toMatchObject({ login: "octocat" });
95+
const text = JSON.stringify(result);
96+
expect(text).toContain("LoopOver contributor profile for octocat.");
97+
expect(text).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling.");
98+
} finally {
99+
await client.close().catch(() => undefined);
100+
}
101+
});
102+
103+
it.each(MODULES)("url-encodes the login in the proxied path — %s", async (specifier) => {
104+
capturedRequests.length = 0;
105+
const mod = loaded.get(specifier)!;
106+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
107+
await mod.server.connect(serverTransport);
108+
const client = new Client({ name: "contributor-profile-encode-test", version: "0.1.0" }, { capabilities: {} });
109+
await client.connect(clientTransport);
110+
try {
111+
await client.callTool({ name: "loopover_get_contributor_profile", arguments: { login: "a b/c" } });
112+
expect(capturedRequests.at(-1)!.url).toContain("/v1/contributors/a%20b%2Fc/profile");
113+
} finally {
114+
await client.close().catch(() => undefined);
115+
}
116+
});
117+
});
118+
119+
describe("bin contributor-profile CLI (in-process, #7760)", () => {
120+
it.each(MODULES)("shares getContributorProfile with the stdio tool: prints the header + API summary — %s", async (specifier) => {
121+
capturedRequests.length = 0;
122+
const mod = loaded.get(specifier)!;
123+
const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat" }));
124+
expect(capturedRequests.at(-1)!.url).toBe("/v1/contributors/octocat/profile");
125+
expect(out).toMatch(/LoopOver contributor profile for octocat\./);
126+
expect(out).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling.");
127+
});
128+
129+
it.each(MODULES)("--json re-serializes the same payload the shared call returned — %s", async (specifier) => {
130+
const mod = loaded.get(specifier)!;
131+
const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat", json: true }));
132+
const payload = JSON.parse(out) as { login: string; summary: string };
133+
expect(payload).toMatchObject({ login: "octocat", summary: "3 registered repos; 12 merged PRs; strongest in review-tooling." });
134+
});
135+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
// (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.)
3333
// (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 88 to 89.)
3434
// (#7762 registered the loopover_mark_notifications_read stdio tool, taking the count from 89 to 90.)
35+
// (#7760 registered the loopover_get_contributor_profile stdio tool, taking the count from 90 to 91.)
3536
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3637
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3738
import { mkdtempSync, rmSync } from "node:fs";
@@ -78,14 +79,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7879
});
7980
afterEach(disconnect);
8081

81-
it("lists exactly 90 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
82+
it("lists exactly 91 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
8283
const { tools } = await client.listTools();
8384
const names = tools.map((t) => t.name);
8485
const primary = names.filter((n) => n.startsWith("loopover_"));
8586
const legacy = names.filter((n) => n.startsWith("gittensory_"));
86-
expect(primary.length).toBe(90);
87+
expect(primary.length).toBe(91);
8788
expect(legacy.length).toBe(0);
88-
expect(names.length).toBe(90);
89+
expect(names.length).toBe(91);
8990
});
9091

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

100-
it("`loopover-mcp tools --json` reports the same 90-tool count the live server registers", async () => {
101+
it("`loopover-mcp tools --json` reports the same 91-tool count the live server registers", async () => {
101102
const { tools } = await client.listTools();
102103
const payload = JSON.parse(run(["tools", "--json"])) as {
103104
count: number;
104105
tools: Array<{ name: string }>;
105106
};
106107
expect(payload.count).toBe(tools.length);
107-
expect(payload.count).toBe(90);
108+
expect(payload.count).toBe(91);
108109
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
109110
[...tools.map((t) => t.name)].sort(),
110111
);

0 commit comments

Comments
 (0)