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
30 changes: 30 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,14 @@ const loginShape = {
login: z.string().min(1),
};

// #7762: stdio mirror of the remote loopover_mark_notifications_read shape (src/mcp/server.ts). login is
// optional here, resolved from `login` / the active session / LOOPOVER_LOGIN like the notifications-read CLI;
// ids is optional -- omit to mark every delivered notification read.
const markNotificationsReadShape = {
login: z.string().min(1).optional(),
ids: z.array(z.string().min(1)).optional(),
};

const loginRepoShape = {
login: z.string().min(1),
owner: z.string().min(1),
Expand Down Expand Up @@ -1241,6 +1249,12 @@ const STDIO_TOOL_DESCRIPTORS = [
description:
"Return a contributor's own post-merge outcome records — for each merged PR, a public-safe attribution of what it did for their standing on the repo. Self-scoped: only the authenticated login's outcomes.",
},
{
name: "loopover_mark_notifications_read",
category: "utility",
description:
"Mark a contributor's own delivered notifications as read (clears the badge). Self-scoped; pass `ids` to clear specific notifications or omit to clear all.",
},
{
name: "loopover_compare_pr_variants",
category: "branch",
Expand Down Expand Up @@ -2318,6 +2332,22 @@ registerStdioTool(
},
);

// #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).
registerStdioTool(
"loopover_mark_notifications_read",
{
description: stdioToolDescription("loopover_mark_notifications_read"),
inputSchema: markNotificationsReadShape,
},
async ({ login, ids }: any) => {
const contributorLogin = login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
if (!contributorLogin) throw new Error("No GitHub login: pass `login`, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
return toolResult(`Marked LoopOver notifications read for ${contributorLogin}.`, await postMarkNotificationsRead(contributorLogin, ids));
},
);

registerStdioTool(
"loopover_compare_pr_variants",
{
Expand Down
103 changes: 103 additions & 0 deletions test/unit/mcp-cli-mark-notifications-read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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";

// #7762: in-process coverage for the loopover_mark_notifications_read 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). The bin reuses postMarkNotificationsRead, so this drives the POST proxy end to end.
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;

type BinModule = {
server: { connect: (transport: unknown) => Promise<void> };
};

let tempDir = "";
const markReadBodies: unknown[] = [];
const loaded = new Map<string, BinModule>();

beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), "loopover-mark-notifications-read-"));
const apiUrl = await startFixtureServer({ onMarkNotificationsRead: (body) => markReadBodies.push(body) });
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_mark_notifications_read stdio tool (in-process, #7762)", () => {
it.each(MODULES)("registers and proxies POST .../notifications/read, marking all read — %s", async (specifier) => {
markReadBodies.length = 0;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "mark-notifications-read-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_mark_notifications_read");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/notifications as read|clears the badge/i);

// No ids -> mark every delivered notification read (empty POST body).
const all = await client.callTool({
name: "loopover_mark_notifications_read",
arguments: { login: "JSONbored" },
});
expect(all.isError).toBeFalsy();
expect(JSON.stringify(all)).toContain("marked");
expect(markReadBodies).toEqual([{}]);

// Explicit ids -> forwarded as { ids } in the POST body.
const some = await client.callTool({
name: "loopover_mark_notifications_read",
arguments: { login: "JSONbored", ids: ["d1", "d2"] },
});
expect(some.isError).toBeFalsy();
expect(markReadBodies[1]).toEqual({ ids: ["d1", "d2"] });
} finally {
await client.close().catch(() => undefined);
}
});

it.each(MODULES)("errors (no request) when no login can be resolved from arg/session/env — %s", async (specifier) => {
markReadBodies.length = 0;
// Exercise the login ?? session ?? env fallback chain bottoming out, and the resulting throw.
const savedLogin = process.env.LOOPOVER_LOGIN;
const savedGh = process.env.GITHUB_LOGIN;
delete process.env.LOOPOVER_LOGIN;
delete process.env.GITHUB_LOGIN;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "mark-notifications-read-nologin", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const result = await client.callTool({ name: "loopover_mark_notifications_read", arguments: {} });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toMatch(/No GitHub login|LOOPOVER_LOGIN/i);
expect(markReadBodies).toEqual([]);
} finally {
await client.close().catch(() => undefined);
if (savedLogin !== undefined) process.env.LOOPOVER_LOGIN = savedLogin;
if (savedGh !== undefined) process.env.GITHUB_LOGIN = savedGh;
}
});
});
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.)
// (#7762 registered the loopover_mark_notifications_read 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