Skip to content

Commit f1fb812

Browse files
feat(api): REST + CLI mirror for loopover_intake_idea (#6916)
The loopover_intake_idea MCP tool (src/mcp/server.ts) is explicitly described as deterministic and source-free and is rate-limit-only gated, but had neither a REST route nor a CLI mirror -- unlike its same-tier sibling loopover_check_slop_risk, which has both. Add POST /v1/loop/intake-idea and register the matching in-process loopover_intake_idea stdio tool, so idea intake is available over REST/CLI and works fully offline. Both reproduce the tool's handler exactly: validate the submission, then assemble the task-graph from the optional caller-supplied decomposition (else the single-issue baseline), delegating to the same pure validateIdeaSubmission/buildTaskGraph and adding no logic of their own. A malformed or empty submission returns the engine's actionable error list rather than a silent failure, mirroring the existing find-opportunities route's semantic-validation shape. Both surfaces mirror intakeIdeaShape verbatim, including its deliberate looseness, so the engine -- not the schema -- keeps owning the real bounds and error list. Closes #6755
1 parent 68b8694 commit f1fb812

5 files changed

Lines changed: 293 additions & 0 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { buildTestEvidenceReport } from "@loopover/engine/signals/test-evidence"
3232
import { evaluateEscalation } from "@loopover/engine";
3333
// #6752: the same pure composer the remote MCP tool + /v1/loop/results-payload both call.
3434
import { buildResultsPayload } from "@loopover/engine";
35+
// #6755: the same pure bridge the remote MCP tool + /v1/loop/intake-idea both call.
36+
import { validateIdeaSubmission, buildTaskGraph } from "@loopover/engine";
3537
import { z } from "zod";
3638
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";
3739
import { formatTable } from "../lib/format-table.js";
@@ -547,6 +549,22 @@ const evaluateEscalationShape = {
547549
killRequested: z.boolean().optional(),
548550
};
549551

552+
// #6755: mirrors intakeIdeaShape in src/mcp/server.ts exactly, so the local tool, the remote tool, and the REST
553+
// route all accept an identical payload. Deliberately loose -- validateIdeaSubmission owns the real checks.
554+
const intakeIdeaShape = {
555+
id: z.string().optional(),
556+
title: z.string().optional(),
557+
body: z.string().optional(),
558+
targetRepo: z.string().optional(),
559+
constraints: z.array(z.string()).max(50).optional(),
560+
acceptanceHints: z.array(z.string()).max(50).optional(),
561+
priority: z.string().optional(),
562+
decomposition: z
563+
.array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() }))
564+
.max(50)
565+
.optional(),
566+
};
567+
550568
// #6752: mirrors buildResultsPayloadShape in src/mcp/server.ts exactly, so the local tool, the remote tool, and
551569
// the REST route all accept an identical payload.
552570
const resultsPayloadShape = {
@@ -957,6 +975,12 @@ const STDIO_TOOL_DESCRIPTORS = [
957975
description:
958976
"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.",
959977
},
978+
{
979+
name: "loopover_intake_idea",
980+
category: "agent",
981+
description:
982+
"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.",
983+
},
960984
{
961985
name: "loopover_check_issue_slop",
962986
category: "review",
@@ -1579,6 +1603,27 @@ registerStdioTool(
15791603
(input) => toolResult("LoopOver loop results payload.", buildResultsPayload(input)),
15801604
);
15811605

1606+
registerStdioTool(
1607+
"loopover_intake_idea",
1608+
{
1609+
description: stdioToolDescription("loopover_intake_idea"),
1610+
inputSchema: intakeIdeaShape,
1611+
},
1612+
// Computed in-process from @loopover/engine (#6755) — the same pure validateIdeaSubmission/buildTaskGraph the
1613+
// remote server (src/mcp/server.ts) and the /v1/loop/intake-idea route both call, reproducing the tool's
1614+
// handler exactly so all three surfaces return an identical payload for identical input, fully offline.
1615+
(input) => {
1616+
const validated = validateIdeaSubmission(input);
1617+
if (!validated.ok) return toolResult(`Invalid idea submission: ${validated.errors.join(", ")}.`, { ok: false, errors: validated.errors });
1618+
const taskGraph = buildTaskGraph(validated.idea, input.decomposition);
1619+
return toolResult(`Task-graph verdict: ${taskGraph.rubric.verdict} across ${taskGraph.issues.length} issue(s).`, {
1620+
ok: true,
1621+
verdict: taskGraph.rubric.verdict,
1622+
taskGraph,
1623+
});
1624+
},
1625+
);
1626+
15821627
registerStdioTool(
15831628
"loopover_check_issue_slop",
15841629
{

src/api/routes.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } f
203203
import { buildTestEvidenceReport } from "../signals/test-evidence";
204204
import { evaluateEscalation } from "../loop-escalation";
205205
import { buildResultsPayload } from "../results-payload";
206+
import { validateIdeaSubmission, buildTaskGraph } from "../idea-intake";
206207
import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings";
207208
import {
208209
buildMcpCompatibilityMetadata,
@@ -493,6 +494,24 @@ const evaluateEscalationSchema = z.object({
493494
killRequested: z.boolean().optional(),
494495
});
495496

497+
// #6755: mirrors intakeIdeaShape in src/mcp/server.ts VERBATIM. Fields are deliberately LOOSE here for the same
498+
// reason they are on the tool: the engine's validateIdeaSubmission owns the real bounds/format checks and returns
499+
// the actionable error list, so an empty/malformed submission must reach the handler rather than be rejected
500+
// upstream by the schema.
501+
const intakeIdeaSchema = z.object({
502+
id: z.string().optional(),
503+
title: z.string().optional(),
504+
body: z.string().optional(),
505+
targetRepo: z.string().optional(),
506+
constraints: z.array(z.string()).max(50).optional(),
507+
acceptanceHints: z.array(z.string()).max(50).optional(),
508+
priority: z.string().optional(),
509+
decomposition: z
510+
.array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() }))
511+
.max(50)
512+
.optional(),
513+
});
514+
496515
// #6752: mirrors buildResultsPayloadShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the
497516
// REST surface can never accept an input the MCP tool would reject, or vice versa.
498517
const resultsPayloadSchema = z.object({
@@ -3332,6 +3351,22 @@ export function createApp() {
33323351
return c.json(buildResultsPayload(parsed.data));
33333352
});
33343353

3354+
// #6755: REST mirror of the loopover_intake_idea MCP tool, bringing it to the same REST/CLI parity its
3355+
// same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. Reproduces the tool's handler
3356+
// exactly -- validate, then assemble the task-graph from the optional caller-supplied decomposition (else the
3357+
// single-issue baseline) -- delegating to the same pure functions and adding no logic of its own. A malformed
3358+
// or empty submission returns the engine's actionable error list (mirroring the find-opportunities route's
3359+
// semantic-validation shape: the payload, with 400), never a silent failure.
3360+
app.post("/v1/loop/intake-idea", async (c) => {
3361+
const body = await c.req.json().catch(() => null);
3362+
const parsed = intakeIdeaSchema.safeParse(body);
3363+
if (!parsed.success) return c.json({ error: "invalid_intake_idea_request", issues: parsed.error.issues }, 400);
3364+
const validated = validateIdeaSubmission(parsed.data);
3365+
if (!validated.ok) return c.json({ ok: false, errors: validated.errors }, 400);
3366+
const taskGraph = buildTaskGraph(validated.idea, parsed.data.decomposition);
3367+
return c.json({ ok: true, verdict: taskGraph.rubric.verdict, taskGraph });
3368+
});
3369+
33353370
app.post("/v1/lint/issue-slop", async (c) => {
33363371
const body = await c.req.json().catch(() => null);
33373372
const parsed = issueSlopSchema.safeParse(body);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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, beforeEach, describe, expect, it } from "vitest";
7+
import { buildTaskGraph, validateIdeaSubmission } from "../../src/idea-intake";
8+
9+
// #6755: the local mirror of loopover_intake_idea. Like its same-tier sibling loopover_check_slop_risk, it
10+
// computes IN-PROCESS from @loopover/engine — no API round-trip — so idea intake works fully offline. The point
11+
// of these tests is cross-surface PARITY: the stdio tool must return exactly what the pure bridge returns for
12+
// identical input (the same functions /v1/loop/intake-idea delegates to), including the actionable error list.
13+
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
14+
15+
let client: Client;
16+
let transport: StdioClientTransport;
17+
let configDir: string;
18+
19+
const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", targetRepo: "acme/widgets" };
20+
21+
beforeEach(async () => {
22+
configDir = mkdtempSync(join(tmpdir(), "loopover-intake-idea-"));
23+
transport = new StdioClientTransport({
24+
command: "node",
25+
args: [bin, "--stdio"],
26+
// Pure + in-process: a black-holed API URL proves no round-trip happens.
27+
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" },
28+
});
29+
client = new Client({ name: "intake-idea-test", version: "0.0.1" });
30+
await client.connect(transport);
31+
});
32+
33+
afterEach(async () => {
34+
await client?.close().catch(() => undefined);
35+
if (configDir) rmSync(configDir, { recursive: true, force: true });
36+
});
37+
38+
describe("loopover_intake_idea stdio mirror (#6755)", () => {
39+
it("registers the tool alongside its same-tier check_slop_risk sibling", async () => {
40+
const names = new Set((await client.listTools()).tools.map((t) => t.name));
41+
expect(names).toContain("loopover_intake_idea");
42+
expect(names).toContain("loopover_check_slop_risk");
43+
});
44+
45+
it("matches the pure bridge for every accepted shape — offline, with no API reachable", async () => {
46+
const cases: unknown[] = [
47+
VALID,
48+
{ ...VALID, priority: "high" },
49+
{ ...VALID, constraints: ["no new deps"], acceptanceHints: ["covered by a unit test"] },
50+
{ ...VALID, decomposition: [{ key: "a", title: "Only issue", body: "Body." }] },
51+
{ ...VALID, decomposition: [{ key: "a", title: "First", body: "Body." }, { key: "b", title: "Second", body: "Body.", dependsOn: ["a"] }] },
52+
];
53+
for (const args of cases) {
54+
const result = await client.callTool({ name: "loopover_intake_idea", arguments: args as Record<string, unknown> });
55+
expect(result.isError, JSON.stringify(args)).toBeFalsy();
56+
const validated = validateIdeaSubmission(args);
57+
expect(validated.ok, JSON.stringify(args)).toBe(true);
58+
if (!validated.ok) continue;
59+
const graph = buildTaskGraph(validated.idea, (args as { decomposition?: never }).decomposition);
60+
// PARITY: identical to what the REST route returns, because both call these same functions.
61+
expect((result as { structuredContent?: unknown }).structuredContent, JSON.stringify(args)).toEqual(
62+
JSON.parse(JSON.stringify({ ok: true, verdict: graph.rubric.verdict, taskGraph: graph })),
63+
);
64+
}
65+
});
66+
67+
it("returns the engine's actionable error list — not a silent failure — for a malformed submission", async () => {
68+
for (const [args, expectedError] of [
69+
[{}, "id_required"],
70+
[{ ...VALID, targetRepo: "not-a-repo" }, "target_repo_malformed"],
71+
[{ ...VALID, priority: "urgent" }, "priority_invalid"],
72+
] as Array<[Record<string, unknown>, string]>) {
73+
const result = await client.callTool({ name: "loopover_intake_idea", arguments: args });
74+
expect(result.isError, JSON.stringify(args)).toBeFalsy();
75+
expect((result as { structuredContent?: { ok: boolean; errors: string[] } }).structuredContent, JSON.stringify(args)).toMatchObject({
76+
ok: false,
77+
errors: expect.arrayContaining([expectedError]),
78+
});
79+
}
80+
});
81+
82+
it("rejects schema-invalid input (zod input-schema validation)", async () => {
83+
for (const args of [{ ...VALID, title: 7 }, { ...VALID, constraints: [7] }, { ...VALID, decomposition: [{ key: "a", title: "Missing body" }] }]) {
84+
const rejected = await client.callTool({ name: "loopover_intake_idea", arguments: args as Record<string, unknown> }).then(
85+
(r) => Boolean(r.isError),
86+
() => true,
87+
);
88+
expect(rejected, `${JSON.stringify(args)} should be rejected`).toBe(true);
89+
}
90+
});
91+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// (#6615 registered the loopover_close_pr write-tool — 9th of the 9 buildXSpec builders — taking the count from 62 to 63.)
1212
// (#6732 registered the loopover_monitor_open_prs CLI mirror, taking the count from 63 to 64.)
1313
// (#6752 registered the loopover_build_results_payload CLI mirror, taking the count from 67 to 68.)
14+
// (#6755 registered the loopover_intake_idea CLI mirror, taking the count from 68 to 69.)
1415
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1516
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
1617
import { mkdtempSync, rmSync } from "node:fs";
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createApp } from "../../src/api/routes";
3+
import { buildTaskGraph, IDEA_TITLE_MAX_CHARS, validateIdeaSubmission } from "../../src/idea-intake";
4+
import { createTestEnv } from "../helpers/d1";
5+
6+
// #6755: POST /v1/loop/intake-idea — the REST mirror bringing loopover_intake_idea to the same parity its
7+
// same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. The route delegates to the pure
8+
// validateIdeaSubmission/buildTaskGraph (covered by their own unit tests), so these pin the ROUTE contract: the
9+
// task-graph and verdict are returned unmodified, a malformed/empty submission comes back as the engine's
10+
// actionable error list rather than a silent failure, and the deliberately-loose schema still lets the engine
11+
// (not zod) own the real bounds — e.g. an out-of-range `priority` is a string, so only the engine rejects it.
12+
const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" });
13+
const PATH = "/v1/loop/intake-idea";
14+
15+
const post = (env: Env, body: unknown) =>
16+
createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env);
17+
18+
const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", targetRepo: "acme/widgets" };
19+
20+
describe("POST /v1/loop/intake-idea (#6755)", () => {
21+
it("turns a valid submission into a scored task-graph", async () => {
22+
const env = createTestEnv();
23+
const response = await post(env, VALID);
24+
expect(response.status).toBe(200);
25+
const payload = (await response.json()) as { ok: boolean; verdict: string; taskGraph: { ideaId: string; issues: unknown[] } };
26+
expect(payload.ok).toBe(true);
27+
expect(["go", "raise", "avoid"]).toContain(payload.verdict);
28+
expect(payload.taskGraph.ideaId).toBe("idea-1");
29+
// No decomposition supplied => the single-issue baseline.
30+
expect(payload.taskGraph.issues).toHaveLength(1);
31+
});
32+
33+
it("assembles the caller-supplied decomposition instead of the baseline", async () => {
34+
const env = createTestEnv();
35+
const response = await post(env, {
36+
...VALID,
37+
decomposition: [
38+
{ key: "a", title: "Add retry helper", body: "Introduce the helper." },
39+
{ key: "b", title: "Wire the helper in", body: "Use it in the upload client.", dependsOn: ["a"] },
40+
],
41+
});
42+
expect(response.status).toBe(200);
43+
const payload = (await response.json()) as { ok: boolean; taskGraph: { issues: Array<{ key: string }> } };
44+
expect(payload.ok).toBe(true);
45+
expect(payload.taskGraph.issues.map((i) => i.key)).toEqual(["a", "b"]);
46+
});
47+
48+
it("returns exactly what the pure bridge returns for every accepted shape", async () => {
49+
const env = createTestEnv();
50+
const cases: unknown[] = [
51+
VALID,
52+
{ ...VALID, priority: "high" },
53+
{ ...VALID, priority: "normal" },
54+
{ ...VALID, constraints: ["no new deps"], acceptanceHints: ["covered by a unit test"] },
55+
{ ...VALID, decomposition: [{ key: "a", title: "Only issue", body: "Body." }] },
56+
{ ...VALID, decomposition: [{ key: "a", title: "First", body: "Body." }, { key: "b", title: "Second", body: "Body.", dependsOn: ["a"] }] },
57+
];
58+
for (const body of cases) {
59+
const response = await post(env, body);
60+
expect(response.status, JSON.stringify(body)).toBe(200);
61+
// PARITY: the route must return exactly what the pure functions the MCP tool calls return.
62+
const validated = validateIdeaSubmission(body);
63+
expect(validated.ok, JSON.stringify(body)).toBe(true);
64+
if (!validated.ok) continue;
65+
const graph = buildTaskGraph(validated.idea, (body as { decomposition?: never }).decomposition);
66+
await expect(response.json(), JSON.stringify(body)).resolves.toEqual(
67+
JSON.parse(JSON.stringify({ ok: true, verdict: graph.rubric.verdict, taskGraph: graph })),
68+
);
69+
}
70+
});
71+
72+
it("returns the engine's actionable error list for a malformed or empty submission", async () => {
73+
const env = createTestEnv();
74+
// Each of these passes the deliberately-loose zod schema and is rejected by the engine instead.
75+
const cases: Array<[unknown, string]> = [
76+
[{}, "id_required"],
77+
[{ ...VALID, id: "" }, "id_required"],
78+
[{ ...VALID, title: "" }, "title_required"],
79+
[{ ...VALID, body: "" }, "body_required"],
80+
[{ ...VALID, targetRepo: "" }, "target_repo_required"],
81+
[{ ...VALID, targetRepo: "not-a-repo" }, "target_repo_malformed"],
82+
[{ ...VALID, title: "x".repeat(IDEA_TITLE_MAX_CHARS + 1) }, "title_too_long"],
83+
[{ ...VALID, priority: "urgent" }, "priority_invalid"],
84+
];
85+
for (const [body, expectedError] of cases) {
86+
const response = await post(env, body);
87+
expect(response.status, JSON.stringify(body)).toBe(400);
88+
const payload = (await response.json()) as { ok: boolean; errors: string[] };
89+
expect(payload.ok, JSON.stringify(body)).toBe(false);
90+
expect(payload.errors, JSON.stringify(body)).toContain(expectedError);
91+
}
92+
// An empty submission reports every missing field at once, not just the first.
93+
const all = (await (await post(env, {})).json()) as { errors: string[] };
94+
expect(all.errors).toEqual(expect.arrayContaining(["id_required", "title_required", "body_required", "target_repo_required"]));
95+
});
96+
97+
it("rejects a schema-invalid or unparseable body with 400", async () => {
98+
const env = createTestEnv();
99+
// These cannot reach the engine: the mirrored shape rejects them, exactly as the MCP tool's does.
100+
for (const body of [
101+
{ ...VALID, title: 7 },
102+
{ ...VALID, constraints: [7] },
103+
{ ...VALID, constraints: "no new deps" },
104+
{ ...VALID, decomposition: [{ key: "a", title: "Missing body" }] },
105+
{ ...VALID, decomposition: Array.from({ length: 51 }, (_, i) => ({ key: `k${i}`, title: "T", body: "B" })) },
106+
]) {
107+
const response = await post(env, body);
108+
expect(response.status, JSON.stringify(body)).toBe(400);
109+
await expect(response.json()).resolves.toMatchObject({ error: "invalid_intake_idea_request" });
110+
}
111+
const malformed = await createApp().request(PATH, { method: "POST", headers: apiHeaders(createTestEnv()), body: "{not json" }, createTestEnv());
112+
expect(malformed.status).toBe(400);
113+
});
114+
115+
it("never emits the maintainer-only gittensor:priority label, and leaks no wallet/hotkey terms", async () => {
116+
const env = createTestEnv();
117+
const text = JSON.stringify(await (await post(env, { ...VALID, priority: "high" })).json());
118+
expect(text).not.toContain("gittensor:priority");
119+
expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score/i);
120+
});
121+
});

0 commit comments

Comments
 (0)