|
| 1 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 2 | +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 3 | +import { mkdtempSync, rmSync } from "node:fs"; |
| 4 | +import { tmpdir } from "node:os"; |
| 5 | +import { join } from "node:path"; |
| 6 | +import { afterEach, describe, expect, it } from "vitest"; |
| 7 | +import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness"; |
| 8 | + |
| 9 | +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); |
| 10 | + |
| 11 | +// #7753: the stdio counterpart to the remote's already-shipped loopover_propose_action (src/mcp/server.ts) and |
| 12 | +// to `maintain propose` -- same maintain-adjacent family as #6152's five siblings (test/unit/mcp-cli-maintain- |
| 13 | +// tools.test.ts), added later because #6744 shipped the route + CLI mirror without a stdio registration. These |
| 14 | +// assert the proxy contract -- that the tool reaches the same bare POST .../agent/pending-actions endpoint |
| 15 | +// `maintain propose` already calls, with the same body -- rather than re-testing the endpoint itself, which |
| 16 | +// test/unit/mcp-cli-maintain.test.ts's "propose" describe block already covers via the CLI. |
| 17 | +let client: Client | null = null; |
| 18 | +let transport: StdioClientTransport | null = null; |
| 19 | +let configDir: string | null = null; |
| 20 | +let capturedRequests: Array<{ url: string; method: string }>; |
| 21 | + |
| 22 | +async function connect(options: Parameters<typeof startFixtureServer>[0] = {}) { |
| 23 | + configDir = mkdtempSync(join(tmpdir(), "loopover-propose-action-tool-")); |
| 24 | + capturedRequests = []; |
| 25 | + const apiUrl = await startFixtureServer({ |
| 26 | + ...options, |
| 27 | + onApiRequest: (request) => { |
| 28 | + const url = request.url ?? ""; |
| 29 | + if (url.includes("pending-actions")) capturedRequests.push({ url, method: request.method ?? "GET" }); |
| 30 | + }, |
| 31 | + }); |
| 32 | + transport = new StdioClientTransport({ |
| 33 | + command: "node", |
| 34 | + args: [bin, "--stdio"], |
| 35 | + env: { |
| 36 | + ...process.env, |
| 37 | + LOOPOVER_CONFIG_DIR: configDir, |
| 38 | + LOOPOVER_API_URL: apiUrl, |
| 39 | + LOOPOVER_TOKEN: "session-token", |
| 40 | + LOOPOVER_API_TIMEOUT_MS: "5000", |
| 41 | + }, |
| 42 | + }); |
| 43 | + client = new Client({ name: "propose-action-tool-test", version: "0.0.1" }); |
| 44 | + await client.connect(transport); |
| 45 | +} |
| 46 | + |
| 47 | +afterEach(async () => { |
| 48 | + await client?.close().catch(() => undefined); |
| 49 | + client = null; |
| 50 | + transport = null; |
| 51 | + await closeFixtureServer(); |
| 52 | + if (configDir) rmSync(configDir, { recursive: true, force: true }); |
| 53 | + configDir = null; |
| 54 | +}); |
| 55 | + |
| 56 | +const REPO = { owner: "owner", repo: "repo" }; |
| 57 | + |
| 58 | +describe("loopover-mcp loopover_propose_action stdio proxy (#7753)", () => { |
| 59 | + it("registers loopover_propose_action in the stdio server tool list, with a non-empty description", async () => { |
| 60 | + await connect(); |
| 61 | + const tools = (await client!.listTools()).tools; |
| 62 | + const tool = tools.find((entry) => entry.name === "loopover_propose_action"); |
| 63 | + expect(tool, "loopover_propose_action is not registered").toBeTruthy(); |
| 64 | + expect(tool!.description?.trim().length ?? 0).toBeGreaterThan(0); |
| 65 | + }); |
| 66 | + |
| 67 | + it("lists loopover_propose_action via `loopover-mcp tools --json` with the same description the server carries", async () => { |
| 68 | + await connect(); |
| 69 | + const wireDescription = (await client!.listTools()).tools.find((entry) => entry.name === "loopover_propose_action")!.description; |
| 70 | + const payload = JSON.parse(run(["tools", "--json"])) as { tools: Array<{ name: string; description: string }> }; |
| 71 | + const entry = payload.tools.find((t) => t.name === "loopover_propose_action"); |
| 72 | + expect(entry, "missing descriptor for loopover_propose_action").toBeTruthy(); |
| 73 | + expect(entry!.description).toBe(wireDescription); |
| 74 | + }); |
| 75 | + |
| 76 | + it("proxies to the bare POST .../agent/pending-actions endpoint `maintain propose` already calls, forwarding every field", async () => { |
| 77 | + await connect(); |
| 78 | + const result = await client!.callTool({ |
| 79 | + name: "loopover_propose_action", |
| 80 | + arguments: { ...REPO, pullNumber: 7, actionClass: "merge", reason: "needs a look", label: "priority", reviewBody: "lgtm", mergeMethod: "squash", closeComment: "n/a" }, |
| 81 | + }); |
| 82 | + expect(result.isError).toBeFalsy(); |
| 83 | + expect(JSON.stringify(result)).toContain("pa-1"); |
| 84 | + expect(capturedRequests).toHaveLength(1); |
| 85 | + expect(capturedRequests[0]!.url).toBe("/v1/repos/owner/repo/agent/pending-actions"); |
| 86 | + expect(capturedRequests[0]!.method).toBe("POST"); |
| 87 | + }); |
| 88 | + |
| 89 | + it("reports 'Staged' when the route creates a new action", async () => { |
| 90 | + await connect({ proposeActionCreated: true }); |
| 91 | + const result = await client!.callTool({ name: "loopover_propose_action", arguments: { ...REPO, pullNumber: 7, actionClass: "merge" } }); |
| 92 | + expect(result.isError).toBeFalsy(); |
| 93 | + const text = (result.content as Array<{ type: string; text?: string }>).find((block) => block.type === "text")?.text ?? ""; |
| 94 | + expect(text).toMatch(/^Staged /); |
| 95 | + }); |
| 96 | + |
| 97 | + it("reports 'Already staged' when an equivalent action is already queued (created: false)", async () => { |
| 98 | + await connect({ proposeActionCreated: false }); |
| 99 | + const result = await client!.callTool({ name: "loopover_propose_action", arguments: { ...REPO, pullNumber: 7, actionClass: "merge" } }); |
| 100 | + expect(result.isError).toBeFalsy(); |
| 101 | + const text = (result.content as Array<{ type: string; text?: string }>).find((block) => block.type === "text")?.text ?? ""; |
| 102 | + expect(text).toMatch(/^Already staged /); |
| 103 | + }); |
| 104 | + |
| 105 | + // The fixture serves owner/repo only and 404s anything else, so an unregistered repo exercises the same |
| 106 | + // failure path a real caller hits without maintainer access to the target: an API error, surfaced as a tool |
| 107 | + // error rather than a silent empty success -- same contract #6152's siblings assert in mcp-cli-maintain- |
| 108 | + // tools.test.ts. |
| 109 | + it("surfaces an API failure as a tool error", async () => { |
| 110 | + await connect(); |
| 111 | + const result = await client!.callTool({ name: "loopover_propose_action", arguments: { owner: "nobody", repo: "missing", pullNumber: 7, actionClass: "merge" } }); |
| 112 | + expect(result.isError).toBe(true); |
| 113 | + expect(JSON.stringify(result.content)).toMatch(/404|not_found/); |
| 114 | + }); |
| 115 | + |
| 116 | + it("rejects an unknown action class before any API call", async () => { |
| 117 | + await connect(); |
| 118 | + const result = await client!.callTool({ name: "loopover_propose_action", arguments: { ...REPO, pullNumber: 7, actionClass: "bogus" } }); |
| 119 | + expect(result.isError).toBe(true); |
| 120 | + expect(capturedRequests).toEqual([]); |
| 121 | + }); |
| 122 | + |
| 123 | + it("rejects a non-positive pull number before any API call", async () => { |
| 124 | + await connect(); |
| 125 | + const result = await client!.callTool({ name: "loopover_propose_action", arguments: { ...REPO, pullNumber: 0, actionClass: "merge" } }); |
| 126 | + expect(result.isError).toBe(true); |
| 127 | + expect(capturedRequests).toEqual([]); |
| 128 | + }); |
| 129 | +}); |
0 commit comments