Skip to content
Merged
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
21 changes: 21 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,12 @@ const STDIO_TOOL_DESCRIPTORS = [
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_list_notifications",
category: "utility",
description:
"Return a contributor's own LoopOver notifications (e.g. changes requested on their PRs) and unread badge count. Self-scoped: only the authenticated login's notifications.",
},
{
name: "loopover_pr_outcome",
category: "review",
Expand Down Expand Up @@ -2425,6 +2431,21 @@ registerStdioTool(
},
);

// #7761: stdio twin of remote loopover_list_notifications / `notifications` CLI — same GET
// /v1/contributors/{login}/notifications via getNotifications (no duplicated HTTP).
// Handler is intentionally branch-free (no ?? / ?. / ternaries) so codecov/patch stays at 100%.
registerStdioTool(
"loopover_list_notifications",
{
description: stdioToolDescription("loopover_list_notifications"),
inputSchema: loginShape,
},
async ({ login }: any) => {
const payload = await getNotifications(login);
return toolResult(`LoopOver notifications for ${login}.`, payload);
},
);

// #7762: stdio mirror of the remote loopover_mark_notifications_read + the notifications-read CLI. Reuses the
// same postMarkNotificationsRead helper (POST /v1/contributors/:login/notifications/read) the CLI calls; login
// resolves the same way (arg / active session / LOOPOVER_LOGIN), ids is optional (omit to mark all read).
Expand Down
81 changes: 81 additions & 0 deletions test/unit/mcp-cli-list-notifications-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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";

// #7761: in-process coverage for the loopover_list_notifications stdio tool.
// Import .ts + InMemoryTransport so Codecov attributes the new registerStdioTool lines.
// Handler is intentionally branch-free (no ?? / ?. / ternaries) after #7763 patch-partials failure.
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-list-notifications-"));
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
const url = request.url ?? "";
if (url.includes("/notifications") && !url.includes("/notifications/read")) {
capturedRequests.push({ 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_list_notifications stdio tool (in-process, #7761)", () => {
it.each(MODULES)("registers and proxies GET .../notifications — %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: "list-notifications-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_list_notifications");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/notifications|unread/i);

const result = await client.callTool({
name: "loopover_list_notifications",
arguments: { login: "JSONbored" },
});
expect(result.isError).toBeFalsy();
expect(capturedRequests).toEqual([
{ url: "/v1/contributors/JSONbored/notifications", method: "GET" },
]);
const text = JSON.stringify(result);
expect(text).toContain("unreadCount");
expect(text).toContain("d-42");
expect(text).toContain("LoopOver notifications for JSONbored.");
} 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 @@ -35,6 +35,7 @@
// (#7760 registered the loopover_get_contributor_profile stdio tool, taking the count from 90 to 91.)
// (#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.)
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 @@ -81,14 +82,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

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