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
45 changes: 45 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import { buildTestEvidenceReport } from "@loopover/engine/signals/test-evidence"
import { evaluateEscalation } from "@loopover/engine";
// #6752: the same pure composer the remote MCP tool + /v1/loop/results-payload both call.
import { buildResultsPayload } from "@loopover/engine";
// #6755: the same pure bridge the remote MCP tool + /v1/loop/intake-idea both call.
import { validateIdeaSubmission, buildTaskGraph } from "@loopover/engine";
import { z } from "zod";
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";
import { formatTable } from "../lib/format-table.js";
Expand Down Expand Up @@ -547,6 +549,22 @@ const evaluateEscalationShape = {
killRequested: z.boolean().optional(),
};

// #6755: mirrors intakeIdeaShape in src/mcp/server.ts exactly, so the local tool, the remote tool, and the REST
// route all accept an identical payload. Deliberately loose -- validateIdeaSubmission owns the real checks.
const intakeIdeaShape = {
id: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
targetRepo: z.string().optional(),
constraints: z.array(z.string()).max(50).optional(),
acceptanceHints: z.array(z.string()).max(50).optional(),
priority: z.string().optional(),
decomposition: z
.array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() }))
.max(50)
.optional(),
};

// #6752: mirrors buildResultsPayloadShape in src/mcp/server.ts exactly, so the local tool, the remote tool, and
// the REST route all accept an identical payload.
const resultsPayloadShape = {
Expand Down Expand Up @@ -911,6 +929,12 @@ const STDIO_TOOL_DESCRIPTORS = [
description:
"Package a completed loop iteration into the customer-facing result (#4801): a PR link, a plain-language summary, and a bounded diff preview, from already-computed iteration metadata. Deterministic and source-free — it formats the result, it does not fetch, open, or deliver anything. Computed in-process; no API round-trip.",
},
{
name: "loopover_intake_idea",
category: "agent",
description:
"Turn a freeform renter idea into a strict, claimable task-graph (spec #4779) and score it against the same feasibility gate the loop runs on. Deterministic and source-free: validates the submission, assembles constituent issues (an optional caller-supplied decomposition, else a single-issue baseline), and returns the graph plus its go/raise/avoid verdict. A malformed or empty submission returns an actionable error list, not a silent failure. Computed in-process; no API round-trip.",
},
{
name: "loopover_check_issue_slop",
category: "review",
Expand Down Expand Up @@ -1520,6 +1544,27 @@ registerStdioTool(
(input) => toolResult("LoopOver loop results payload.", buildResultsPayload(input)),
);

registerStdioTool(
"loopover_intake_idea",
{
description: stdioToolDescription("loopover_intake_idea"),
inputSchema: intakeIdeaShape,
},
// Computed in-process from @loopover/engine (#6755) — the same pure validateIdeaSubmission/buildTaskGraph the
// remote server (src/mcp/server.ts) and the /v1/loop/intake-idea route both call, reproducing the tool's
// handler exactly so all three surfaces return an identical payload for identical input, fully offline.
(input) => {
const validated = validateIdeaSubmission(input);
if (!validated.ok) return toolResult(`Invalid idea submission: ${validated.errors.join(", ")}.`, { ok: false, errors: validated.errors });
const taskGraph = buildTaskGraph(validated.idea, input.decomposition);
return toolResult(`Task-graph verdict: ${taskGraph.rubric.verdict} across ${taskGraph.issues.length} issue(s).`, {
ok: true,
verdict: taskGraph.rubric.verdict,
taskGraph,
});
},
);

registerStdioTool(
"loopover_check_issue_slop",
{
Expand Down
35 changes: 35 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } f
import { buildTestEvidenceReport } from "../signals/test-evidence";
import { evaluateEscalation } from "../loop-escalation";
import { buildResultsPayload } from "../results-payload";
import { validateIdeaSubmission, buildTaskGraph } from "../idea-intake";
import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings";
import {
buildMcpCompatibilityMetadata,
Expand Down Expand Up @@ -491,6 +492,24 @@ const evaluateEscalationSchema = z.object({
killRequested: z.boolean().optional(),
});

// #6755: mirrors intakeIdeaShape in src/mcp/server.ts VERBATIM. Fields are deliberately LOOSE here for the same
// reason they are on the tool: the engine's validateIdeaSubmission owns the real bounds/format checks and returns
// the actionable error list, so an empty/malformed submission must reach the handler rather than be rejected
// upstream by the schema.
const intakeIdeaSchema = z.object({
id: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
targetRepo: z.string().optional(),
constraints: z.array(z.string()).max(50).optional(),
acceptanceHints: z.array(z.string()).max(50).optional(),
priority: z.string().optional(),
decomposition: z
.array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() }))
.max(50)
.optional(),
});

// #6752: mirrors buildResultsPayloadShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the
// REST surface can never accept an input the MCP tool would reject, or vice versa.
const resultsPayloadSchema = z.object({
Expand Down Expand Up @@ -3319,6 +3338,22 @@ export function createApp() {
return c.json(buildResultsPayload(parsed.data));
});

// #6755: REST mirror of the loopover_intake_idea MCP tool, bringing it to the same REST/CLI parity its
// same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. Reproduces the tool's handler
// exactly -- validate, then assemble the task-graph from the optional caller-supplied decomposition (else the
// single-issue baseline) -- delegating to the same pure functions and adding no logic of its own. A malformed
// or empty submission returns the engine's actionable error list (mirroring the find-opportunities route's
// semantic-validation shape: the payload, with 400), never a silent failure.
app.post("/v1/loop/intake-idea", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = intakeIdeaSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_intake_idea_request", issues: parsed.error.issues }, 400);
const validated = validateIdeaSubmission(parsed.data);
if (!validated.ok) return c.json({ ok: false, errors: validated.errors }, 400);
const taskGraph = buildTaskGraph(validated.idea, parsed.data.decomposition);
return c.json({ ok: true, verdict: taskGraph.rubric.verdict, taskGraph });
});

app.post("/v1/lint/issue-slop", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = issueSlopSchema.safeParse(body);
Expand Down
91 changes: 91 additions & 0 deletions test/unit/mcp-cli-intake-idea-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildTaskGraph, validateIdeaSubmission } from "../../src/idea-intake";

// #6755: the local mirror of loopover_intake_idea. Like its same-tier sibling loopover_check_slop_risk, it
// computes IN-PROCESS from @loopover/engine — no API round-trip — so idea intake works fully offline. The point
// of these tests is cross-surface PARITY: the stdio tool must return exactly what the pure bridge returns for
// identical input (the same functions /v1/loop/intake-idea delegates to), including the actionable error list.
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");

let client: Client;
let transport: StdioClientTransport;
let configDir: string;

const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", targetRepo: "acme/widgets" };

beforeEach(async () => {
configDir = mkdtempSync(join(tmpdir(), "loopover-intake-idea-"));
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
// Pure + in-process: a black-holed API URL proves no round-trip happens.
env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_URL: "http://127.0.0.1:1", LOOPOVER_API_TIMEOUT_MS: "1000" },
});
client = new Client({ name: "intake-idea-test", version: "0.0.1" });
await client.connect(transport);
});

afterEach(async () => {
await client?.close().catch(() => undefined);
if (configDir) rmSync(configDir, { recursive: true, force: true });
});

describe("loopover_intake_idea stdio mirror (#6755)", () => {
it("registers the tool alongside its same-tier check_slop_risk sibling", async () => {
const names = new Set((await client.listTools()).tools.map((t) => t.name));
expect(names).toContain("loopover_intake_idea");
expect(names).toContain("loopover_check_slop_risk");
});

it("matches the pure bridge for every accepted shape — offline, with no API reachable", async () => {
const cases: unknown[] = [
VALID,
{ ...VALID, priority: "high" },
{ ...VALID, constraints: ["no new deps"], acceptanceHints: ["covered by a unit test"] },
{ ...VALID, decomposition: [{ key: "a", title: "Only issue", body: "Body." }] },
{ ...VALID, decomposition: [{ key: "a", title: "First", body: "Body." }, { key: "b", title: "Second", body: "Body.", dependsOn: ["a"] }] },
];
for (const args of cases) {
const result = await client.callTool({ name: "loopover_intake_idea", arguments: args as Record<string, unknown> });
expect(result.isError, JSON.stringify(args)).toBeFalsy();
const validated = validateIdeaSubmission(args);
expect(validated.ok, JSON.stringify(args)).toBe(true);
if (!validated.ok) continue;
const graph = buildTaskGraph(validated.idea, (args as { decomposition?: never }).decomposition);
// PARITY: identical to what the REST route returns, because both call these same functions.
expect((result as { structuredContent?: unknown }).structuredContent, JSON.stringify(args)).toEqual(
JSON.parse(JSON.stringify({ ok: true, verdict: graph.rubric.verdict, taskGraph: graph })),
);
}
});

it("returns the engine's actionable error list — not a silent failure — for a malformed submission", async () => {
for (const [args, expectedError] of [
[{}, "id_required"],
[{ ...VALID, targetRepo: "not-a-repo" }, "target_repo_malformed"],
[{ ...VALID, priority: "urgent" }, "priority_invalid"],
] as Array<[Record<string, unknown>, string]>) {
const result = await client.callTool({ name: "loopover_intake_idea", arguments: args });
expect(result.isError, JSON.stringify(args)).toBeFalsy();
expect((result as { structuredContent?: { ok: boolean; errors: string[] } }).structuredContent, JSON.stringify(args)).toMatchObject({
ok: false,
errors: expect.arrayContaining([expectedError]),
});
}
});

it("rejects schema-invalid input (zod input-schema validation)", async () => {
for (const args of [{ ...VALID, title: 7 }, { ...VALID, constraints: [7] }, { ...VALID, decomposition: [{ key: "a", title: "Missing body" }] }]) {
const rejected = await client.callTool({ name: "loopover_intake_idea", arguments: args as Record<string, unknown> }).then(
(r) => Boolean(r.isError),
() => true,
);
expect(rejected, `${JSON.stringify(args)} should be rejected`).toBe(true);
}
});
});
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 @@ -11,6 +11,7 @@
// (#6615 registered the loopover_close_pr write-tool — 9th of the 9 buildXSpec builders — taking the count from 62 to 63.)
// (#6732 registered the loopover_monitor_open_prs CLI mirror, taking the count from 63 to 64.)
// (#6752 registered the loopover_build_results_payload CLI mirror, taking the count from 67 to 68.)
// (#6755 registered the loopover_intake_idea CLI mirror, taking the count from 68 to 69.)
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 @@ -54,14 +55,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

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