Files
fusion/packages/engine/src/cli-agent-ask.ts
gsxdsm ad3149093a FN-7077: inject configured MCP servers across agent surfaces
Configured MCP servers now follow every agent-work lane that launches model-backed tools.

- Thread MCP server forwarding through chat, task execution, tools, PR response, cron, memory, research, and planning surfaces.
- Add regression coverage for dashboard and engine lanes that previously omitted MCP server configuration.
- Document all covered MCP surfaces and add a published package changeset.

Files changed:
 .changeset/fn-7077-mcp-all-surfaces.md             |   7 +
 docs/mcp.md                                        |   3 +
 docs/settings-reference.md                         |   2 +-
 packages/cli/src/commands/__tests__/serve.test.ts  |   3 +-
 packages/core/src/memory-compaction.ts             |   4 +
 .../src/__tests__/mcp-lane-forwarding.test.ts      |  62 +++++++++
 packages/dashboard/src/agent-generation.ts         |   4 +
 packages/dashboard/src/ai-refine.ts                |   8 ++
 .../dashboard/src/milestone-slice-interview.ts     |  11 +-
 packages/dashboard/src/mission-interview.ts        |   9 +-
 packages/dashboard/src/pr-conflict-resolver.ts     |  12 +-
 packages/dashboard/src/routes.ts                   |  10 ++
 packages/dashboard/src/subtask-breakdown.ts        |   8 ++
 packages/engine/src/__tests__/cron-runner.test.ts  |  61 +++++++++
 .../src/__tests__/mcp-lane-forwarding.test.ts      |   3 +-
 .../__tests__/mcp-pr-response-forwarding.test.ts   | 110 ++++++++++++++++
 .../src/__tests__/mcp-surface-coverage.test.ts     | 141 +++++++++++++++++++++
 packages/engine/src/agent-heartbeat.ts             |  18 +++
 packages/engine/src/cli-agent-ask.ts               |   4 +
 packages/engine/src/cron-runner.ts                 |   9 +-
 packages/engine/src/pr-nodes.ts                    |   2 +-
 packages/engine/src/pr-response-run-ops.ts         |  10 +-
 packages/engine/src/project-engine.ts              |   2 +-
 .../research/providers/llm-synthesis-provider.ts   |   4 +
 .../src/research/providers/web-search-provider.ts  |   4 +
 25 files changed, 497 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-7077
Fusion-Task-Lineage: 00d36357-c3d8-4954-b647-d3aea24a967b
2026-06-26 16:14:44 -07:00

125 lines
4.3 KiB
TypeScript

import type { AgentSession } from "@earendil-works/pi-coding-agent";
import type { AgentRuntime } from "./agent-runtime.js";
import { extractJsonObjects } from "./cli-agent/one-shot-session.js";
export type AskAcpOnceFailureReason =
| "create_session_failed"
| "turn_failed"
| "timeout"
| "abnormal_stop"
| "dispose_failed";
export type AskAcpOnceResult =
| { ok: true; text: string; parsed?: Record<string, unknown>; stopReason?: string }
| { ok: false; reason: AskAcpOnceFailureReason; message: string; text?: string; stopReason?: string };
export interface AskAcpOnceOptions {
prompt: string;
cwd: string;
model?: string;
systemPrompt?: string;
timeoutMs?: number;
recoverJson?: boolean;
}
function messageFromError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function recoverTrailingJson(text: string): Record<string, unknown> | undefined {
const objects = extractJsonObjects(text);
return objects.length > 0 ? objects[objects.length - 1] : undefined;
}
function isCleanStop(stopReason: string | undefined): boolean {
return stopReason === undefined || stopReason === "end_turn";
}
async function disposeSession(
runtime: AgentRuntime,
session: AgentSession | undefined,
): Promise<void> {
if (!session) return;
const runtimeWithDispose = runtime as AgentRuntime & { dispose?: (session: AgentSession) => Promise<void> | void };
if (typeof runtimeWithDispose.dispose === "function") {
await runtimeWithDispose.dispose(session);
return;
}
session.dispose();
}
export async function askAcpOnce(runtime: AgentRuntime, opts: AskAcpOnceOptions): Promise<AskAcpOnceResult> {
/*
FNXC:ACP-RouteB 2026-06-14-20:11:
Planning and validator Route-B seams need a one-turn ACP runner that preserves the previous one-shot shape while using readonly tools only. Accumulate streamed prose, optionally recover a trailing JSON object, and always dispose the ACP session.
*/
let text = "";
let session: AgentSession | undefined;
try {
/*
* FNXC:McpConfig 2026-06-26-00:00:
* `askAcpOnce` is a direct Route-B runtime helper that receives an already-resolved runtime plus readonly prompt options, not a TaskStore or secrets reader. MCP-capable callers must resolve MCP before this seam if they need it.
*/
const created = await runtime.createSession({
cwd: opts.cwd,
systemPrompt: opts.systemPrompt ?? "",
tools: "readonly",
defaultModelId: opts.model,
runtimeContext: { sessionPurpose: "cli-agent-ask", toolMode: "readonly" },
onText: (delta) => {
text += delta;
},
});
session = created.session;
} catch (err) {
return { ok: false, reason: "create_session_failed", message: messageFromError(err), text };
}
let timeout: NodeJS.Timeout | undefined;
let timedOut = false;
try {
const promptPromise = runtime.promptWithFallback(session, opts.prompt).catch((err: unknown) => {
if (timedOut) return undefined;
throw err;
});
const result = opts.timeoutMs && opts.timeoutMs > 0
? await Promise.race([
promptPromise,
new Promise<"timeout">((resolve) => {
timeout = setTimeout(() => resolve("timeout"), opts.timeoutMs);
}),
])
: await promptPromise;
if (result === "timeout") {
timedOut = true;
return { ok: false, reason: "timeout", message: `ACP prompt timed out after ${opts.timeoutMs}ms`, text };
}
const stopReason = typeof result === "object" && result && "stopReason" in result
? String((result as { stopReason?: unknown }).stopReason ?? "") || undefined
: undefined;
if (!isCleanStop(stopReason)) {
return {
ok: false,
reason: "abnormal_stop",
message: `ACP prompt ended with stopReason=${stopReason}`,
text,
stopReason,
};
}
const parsed = opts.recoverJson ? recoverTrailingJson(text) : undefined;
return { ok: true, text, ...(parsed ? { parsed } : {}), ...(stopReason ? { stopReason } : {}) };
} catch (err) {
return { ok: false, reason: "turn_failed", message: messageFromError(err), text };
} finally {
if (timeout) clearTimeout(timeout);
try {
await disposeSession(runtime, session);
} catch {
// The turn result is more useful than a best-effort disposal error. Runtimes also own process registries.
}
}
}