FN-8926: expose graph and recall through MCP

Expose Fusion knowledge graph and durable recall through a built-in MCP transport.

- Add the reserved fusion-memory server with graph and recall MCP tools.
- Resolve built-in availability and enable/disable tombstones across configuration and UI.
- Add CLI transport, documentation, release metadata, and lane coverage tests.

Files changed:
 .changeset/fn-8926-memory-mcp-server.md            |   7 +
 docs/cli-reference.md                              |   4 +
 docs/mcp.md                                        |  10 ++
 packages/cli/src/bin.ts                            |   6 +
 .../__tests__/mcp-memory-server-spawn.test.ts      |  79 ++++++++++
 .../commands/__tests__/mcp-memory-server.test.ts   |  83 +++++++++++
 packages/cli/src/commands/__tests__/mcp.test.ts    |  27 +++-
 packages/cli/src/commands/mcp-memory-server.ts     | 104 +++++++++++++
 packages/cli/src/commands/mcp.ts                   |  64 ++++++--
 packages/core/package.json                         |  10 ++
 .../core/src/__tests__/mcp-builtin-servers.test.ts |  17 +++
 packages/core/src/__tests__/mcp-config.test.ts     |  12 ++
 packages/core/src/config/mcp-builtin-descriptor.ts |  16 ++
 packages/core/src/config/mcp-builtin-servers.ts    |  18 +++
 packages/core/src/config/mcp-config.ts             |  40 +++--
 packages/core/src/config/mcp-discovery.ts          |   3 +-
 packages/core/src/index.ts                         |   6 +
 packages/core/src/memory/index.ts                  |   1 +
 .../mcp/__tests__/memory-mcp-handler.test.ts       |  36 +++++
 .../mcp/__tests__/memory-mcp-serialization.test.ts |  23 +++
 packages/core/src/memory/mcp/index.ts              |   4 +
 .../core/src/memory/mcp/memory-mcp-backends.ts     |  39 +++++
 packages/core/src/memory/mcp/memory-mcp-handler.ts |  54 +++++++
 .../src/memory/mcp/memory-mcp-serialization.ts     |  48 ++++++
 packages/core/src/memory/mcp/memory-mcp-tools.ts   |  64 ++++++++
 packages/core/src/types.ts                         |  10 ++
 .../settings/sections/GlobalMcpSection.tsx         |  14 +-
 .../settings/sections/McpServersCard.tsx           |  67 +++++++--
 .../settings/sections/ProjectMcpSection.tsx        |  15 +-
 .../__tests__/McpServersCard.builtin.test.tsx      |  45 ++++++
 .../dashboard/src/__tests__/chat-manager.test.ts   |  24 +++
 .../register-config-mcp-pi-settings-routes.ts      |  20 ++-
 packages/dashboard/vitest.config.ts                |   2 +
 .../__tests__/mcp-builtin-lane-coverage.test.ts    | 163 +++++++++++++++++++++
 packages/engine/src/mcp/mcp-resolution.ts          |  10 +-
 packages/engine/vitest.config.ts                   |   2 +
 36 files changed, 1097 insertions(+), 50 deletions(-)

Fusion-Task-Id: FN-8926
Fusion-Task-Lineage: b2861491-33da-4b05-b9f8-a7c1448c1c8c
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 17:46:41 -07:00
parent a1b56f11e3
commit a839c61929
36 changed files with 1097 additions and 50 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Fusion memory tools through the built-in MCP server.
category: feature
dev: Adds injectable entry resolution, root plumbing and A/B/C/D/R lane coverage, store-resident budget fallback, reserved-name tombstones, browser-safe availability, and bounded JSON-RPC results.

View File

@@ -1386,6 +1386,10 @@ For configuration details used by these commands, see [Settings Reference](./set
Agent create/update payloads accept `roles` (a non-empty role-tag array) and optional `runtimeConfig.maxWorkflowSessions`. The legacy singular `role` input remains accepted for migration compatibility.
## MCP memory transport
`fn mcp serve-memory --project-root <path>` is an internal stdio transport command used by Fusion's built-in `fusion-memory` MCP server. It is not intended for direct interactive use. The built-in name is reserved; configure it through MCP enable/disable controls rather than add, edit, or remove commands. Disabling writes an `enabled: false` tombstone; normal re-enable deletes that tombstone, while a project enable may write a marker only to cancel a global tombstone.
## Knowledge graph
`fn knowledge-graph build [--force] [--dir <path>] [--json]` refreshes the deterministic, committable structure graph. `--force` bypasses incremental reuse; `--dir` overrides the project `knowledgeGraphDir`; `--json` prints build statistics as JSON.

View File

@@ -288,6 +288,16 @@ Expected outcome: enabling a server makes it available to subsequent supported A
See [Settings Reference](./settings-reference.md) for the `mcpServers` settings contract and [Agents](./agents.md) for runtime/model lane behavior.
## Fusion memory built-in
When MCP is enabled, Fusion automatically resolves the `fusion-memory` stdio server for project-rooted sessions. It exposes `graph_query`, `graph_neighbors`, and `graph_shortest_path` for the knowledge graph, plus `recall_search` and `recall_append` for durable recall. Graph edges preserve `provenance` (`extracted` or `inferred`) in JSON result text.
The server emits one bounded text block containing `{ tool, results, truncated, omittedCount }`. It drops complete trailing results before using a clearly marked non-JSON fallback. Its cap follows `agentToolOutputMaxChars`; `0` remains unlimited and small configured values are raised to the server's safe minimum. The budget is read from the project store once per MCP process, so restart a session after changing it. If the store is unavailable, graph tools still answer under the default cap and recall tools return an `isError` result. Protocol errors use JSON-RPC errors, while valid tool calls that fail during execution return `isError` so a model can react.
A built CLI entry is required; `FUSION_MEMORY_MCP_ENTRY` can provide an explicit entry for packaged or development installations. A source checkout without a resolvable entry shows the built-in as unavailable rather than breaking MCP resolution.
Disable the built-in with an `enabled: false` MCP entry (a tombstone). Removing that tombstone re-enables it; do not create a normal transport-less server definition. A project marker can cancel a global tombstone without replacing the built-in transport. Lanes without a project store continue to resolve no MCP servers, which is a pre-existing limitation.
## Plugin-provided servers
A plugin can declaratively contribute an MCP server without modifying project settings. Fusion includes it only when that plugin is enabled for the active project, then applies normal project override and `enabled:false` tombstone semantics by name. The server binary remains the consuming project's responsibility; an unavailable command produces the normal isolated MCP spawn failure. Plugin declarations may contain only Fusion secret references for sensitive values.

View File

@@ -128,6 +128,7 @@ async function loadCommandHandlers() {
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
const { runMcpList, runMcpAdd, runMcpEdit, runMcpRemove, runMcpEnable, runMcpDisable, runMcpImport, runMcpExport, runMcpValidate } = await import("./commands/mcp.js");
const { runMcpMemoryServer } = await import("./commands/mcp-memory-server.js");
const { runWorkflowValidate } = await import("./commands/workflow.js");
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, runBranchGroupAbandon } = await import("./commands/branch-group.js");
@@ -211,6 +212,7 @@ async function loadCommandHandlers() {
runMcpImport,
runMcpExport,
runMcpValidate,
runMcpMemoryServer,
runWorkflowValidate,
runGitStatus,
runGitFetch,
@@ -765,6 +767,7 @@ async function main() {
runMcpImport,
runMcpExport,
runMcpValidate,
runMcpMemoryServer,
runWorkflowValidate,
runGitStatus,
runGitFetch,
@@ -1797,6 +1800,9 @@ async function main() {
secretScope,
};
switch (subcommand) {
case "serve-memory":
await runMcpMemoryServer(getFlagValue(args, "--project-root") ?? process.cwd());
break;
case "list":
case "ls":
await runMcpList({ projectName, json: args.includes("--json") });

View File

@@ -0,0 +1,79 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { buildKnowledgeGraph } from "../../../../core/src/knowledge-graph/graph-builder.js";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const testDir = dirname(fileURLToPath(import.meta.url));
const builtEntry = resolve(testDir, "../../../dist/bin.js");
const sourceEntry = resolve(testDir, "../../bin.ts");
const CHILD_RESPONSE_TIMEOUT_MS = 10_000;
function resolveChildCommand(): { command: string; args: string[]; provisionedBy: "dist" | "tsx" } {
if (existsSync(builtEntry)) return { command: process.execPath, args: [builtEntry], provisionedBy: "dist" };
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
return { command: process.execPath, args: [tsxCli, sourceEntry], provisionedBy: "tsx" };
}
describe("memory MCP child transport", () => {
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
it("keeps serving valid JSON-RPC after a project-store open failure", async () => {
const root = await mkdtemp(join(tmpdir(), "fusion-memory-mcp-"));
roots.push(root);
// The child has no database, but graph artifacts are independent of the store.
await mkdir(join(root, "src"), { recursive: true });
await writeFile(join(root, "src", "memory.ts"), "export const memory = true;", "utf8");
await buildKnowledgeGraph({ projectRoot: root, discovery: { sourceRoots: ["src"], markdownRoots: [] } });
const childCommand = resolveChildCommand();
const child = spawn(childCommand.command, [...childCommand.args, "mcp", "serve-memory", "--project-root", root], {
stdio: ["pipe", "pipe", "pipe"],
detached: false,
// Force the lazy project-store open to fail; graph artifacts remain readable.
env: { ...process.env, FUSION_NO_EMBEDDED_PG: "1" },
});
const lines = createInterface({ input: child.stdout! });
const messages: Array<Record<string, unknown>> = [];
let stderr = "";
child.stderr!.on("data", chunk => { stderr += String(chunk); });
const exit = new Promise<void>((resolveExit, rejectExit) => {
child.once("exit", () => resolveExit());
child.once("error", rejectExit);
});
try {
const received = new Promise<void>((resolveResponse, rejectResponse) => {
const timer = setTimeout(() => rejectResponse(new Error(`memory MCP child (${childCommand.provisionedBy}) did not respond: ${stderr}`)), CHILD_RESPONSE_TIMEOUT_MS);
lines.on("line", line => {
messages.push(JSON.parse(line) as Record<string, unknown>);
if (messages.length === 4) { clearTimeout(timer); resolveResponse(); }
});
child.once("error", rejectResponse);
});
child.stdin!.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n');
child.stdin!.write('{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
child.stdin!.write('{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"graph_query","arguments":{}}}\n');
child.stdin!.write('{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"recall_search","arguments":{"query":"memory"}}}\n');
await received;
expect(messages[0]?.result).toMatchObject({ protocolVersion: "2024-11-05" });
expect(messages[1]?.result).toMatchObject({ tools: expect.arrayContaining([expect.objectContaining({ name: "graph_query" })]) });
expect(messages[2]?.result).toMatchObject({ content: [{ type: "text", text: expect.any(String) }] });
expect(messages[2]?.result).not.toMatchObject({ isError: true });
expect(JSON.parse((messages[2]?.result as { content: Array<{ text: string }> }).content[0]!.text).results).not.toHaveLength(0);
expect(messages[3]?.result).toMatchObject({ isError: true, content: [{ type: "text", text: expect.any(String) }] });
child.stdin!.end();
await exit;
} finally {
lines.close();
if (child.exitCode === null && child.signalCode === null) {
child.kill();
await exit.catch(() => undefined);
}
}
}, CHILD_RESPONSE_TIMEOUT_MS + 2_000);
});

View File

@@ -0,0 +1,83 @@
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_TOOL_OUTPUT_MAX_CHARS } from "@fusion/core";
import { runMemoryMcpServerLoop } from "../mcp-memory-server.js";
const edge = { id: "edge:one", provenance: "inferred", from: "a", to: "b" };
function createBackends() {
return {
graphQuery: vi.fn(async () => [{ id: "a", edge }]),
graphNeighbors: vi.fn(async () => [{ node: { id: "b" }, edge }]),
graphShortestPath: vi.fn(async () => [{ nodes: [{ id: "a" }, { id: "b" }], edges: [edge] }]),
recallSearch: vi.fn(async () => [{ id: "recall-1", content: "remember this", source: { kind: "task" } }]),
recallAppend: vi.fn(async () => [{ id: "recall-2", content: "saved", source: { kind: "task" } }]),
};
}
async function runTransport(
lines: string[],
budgetProvider = vi.fn(async () => ({ settings: { agentToolOutputMaxChars: 300 } })),
backendOverrides: Partial<ReturnType<typeof createBackends>> = {},
) {
const input = new PassThrough();
const output = new PassThrough();
let text = "";
output.on("data", (chunk) => { text += String(chunk); });
const backends = { ...createBackends(), ...backendOverrides };
const loop = runMemoryMcpServerLoop({ input, output, projectRoot: "/fixture", backends, budgetProvider });
for (const line of lines) input.write(line);
input.end();
await loop;
return { responses: text.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)), backends, budgetProvider };
}
/**
* FNXC:MemoryMcpTransport 2026-08-10-21:15:
* The public MCP boundary is newline-delimited JSON-RPC, so every declared tool
* must cross the real stream framing layer rather than only exercising the handler.
*/
describe("memory MCP JSON-RPC transport", () => {
it("round-trips all five tools, preserves provenance, and frames partial/batched input", async () => {
const calls = [
["graph_query", {}],
["graph_neighbors", { nodeId: "a" }],
["graph_shortest_path", { fromId: "a", toId: "b" }],
["recall_search", { query: "remember" }],
["recall_append", { kind: "decision", content: "saved", source: { kind: "task", id: "T-1" } }],
] as const;
const requests = calls.map(([name, argumentsValue], index) => JSON.stringify({ jsonrpc: "2.0", id: index + 1, method: "tools/call", params: { name, arguments: argumentsValue } }));
const { responses, backends, budgetProvider } = await runTransport([
'{"jsonrpc":"2.0","method":"notifications/initialized"}\n',
`${requests[0]!.slice(0, 28)}`,
`${requests[0]!.slice(28)}\n${requests.slice(1).join("\n")}\n`,
]);
expect(responses).toHaveLength(5);
for (const response of responses) expect(response.result).toEqual({ content: [{ type: "text", text: expect.any(String) }] });
expect(JSON.parse(responses[0]!.result.content[0].text).results[0].edge.provenance).toBe("inferred");
expect(JSON.parse(responses[1]!.result.content[0].text).results[0].edge.provenance).toBe("inferred");
expect(JSON.parse(responses[2]!.result.content[0].text).results[0].edges[0].provenance).toBe("inferred");
expect(backends.graphQuery).toHaveBeenCalledOnce();
expect(backends.graphNeighbors).toHaveBeenCalledOnce();
expect(backends.graphShortestPath).toHaveBeenCalledOnce();
expect(backends.recallSearch).toHaveBeenCalledOnce();
expect(backends.recallAppend).toHaveBeenCalledOnce();
expect(budgetProvider).toHaveBeenCalledOnce();
});
it("uses a finite fallback budget while unavailable recall storage returns an actionable error", async () => {
const provider = vi.fn(async () => null);
const { responses } = await runTransport([
`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "graph_query", arguments: {} } })}\n`,
`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "recall_search", arguments: { query: "x" } } })}\n`,
], provider, { recallSearch: vi.fn(async () => { throw new Error("store unavailable SECRET_RECALL_CONTENT"); }) });
expect(responses[0]!.result).not.toMatchObject({ isError: true });
expect(responses[0]!.result.content[0].text.length).toBeLessThanOrEqual(DEFAULT_TOOL_OUTPUT_MAX_CHARS);
expect(responses[1]!.result).toMatchObject({ isError: true });
expect(responses[1]!.result.content[0].text).toContain("Recall store is unavailable");
expect(responses[1]!.result.content[0].text).not.toContain("SECRET_RECALL_CONTENT");
expect(responses[1]!.result.content[0].text.length).toBeLessThanOrEqual(DEFAULT_TOOL_OUTPUT_MAX_CHARS);
expect(provider).toHaveBeenCalledOnce();
});
});

View File

@@ -98,6 +98,7 @@ import {
runMcpImport,
runMcpList,
runMcpRemove,
runMcpValidate,
} from "../mcp.js";
function resetState() {
@@ -135,18 +136,38 @@ describe("mcp commands", () => {
const listed = JSON.parse(consoleCapture.output.at(-1) ?? "{}");
expect(listed.global[0]).toMatchObject({ name: "github", command: "global-gh" });
expect(listed.project[0]).toMatchObject({ name: "github", command: "project-gh" });
expect(listed.effective).toEqual([expect.objectContaining({ name: "github", command: "project-gh" })]);
expect(listed.effective).toEqual(expect.arrayContaining([expect.objectContaining({ name: "github", command: "project-gh" })]));
await runMcpDisable("github", { scope: "project" });
await runMcpList({ json: true });
expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).toEqual([]);
expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: "github" })]));
await runMcpRemove("github", { scope: "project" });
await runMcpList({ json: true });
expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).toEqual([expect.objectContaining({ command: "global-gh" })]);
expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).toEqual(expect.arrayContaining([expect.objectContaining({ command: "global-gh" })]));
consoleCapture.restore();
});
it("includes an injected fusion-memory entry through list, export, and validate direct resolvers", async () => {
const previousEntry = process.env.FUSION_MEMORY_MCP_ENTRY;
process.env.FUSION_MEMORY_MCP_ENTRY = join(process.cwd(), "../..", "package.json");
state.globalSettings = { mcpServers: { enabled: true, servers: [] } };
state.projectSettings = { mcpServers: { enabled: true, servers: [] } };
const consoleCapture = captureConsole();
try {
await runMcpList({ json: true });
expect(JSON.stringify(JSON.parse(consoleCapture.output.at(-1) ?? "{}"))).toContain("fusion-memory");
await runMcpExport({ scope: "effective" });
expect(consoleCapture.output.at(-1)).toContain("fusion-memory");
await runMcpValidate({ scope: "effective", json: true });
expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").servers).toBeGreaterThan(0);
} finally {
consoleCapture.restore();
if (previousEntry === undefined) delete process.env.FUSION_MEMORY_MCP_ENTRY;
else process.env.FUSION_MEMORY_MCP_ENTRY = previousEntry;
}
});
it("imports Claude Desktop mcpServers JSON by creating secrets and persisting only references", async () => {
const fixture = join(tempDir!, "claude.json");
writeFileSync(fixture, JSON.stringify({

View File

@@ -0,0 +1,104 @@
import { createInterface } from "node:readline";
import type { Readable, Writable } from "node:stream";
import {
MemoryMcpHandler,
createMemoryMcpBackends,
type MemoryMcpBackends,
type MemoryMcpBudgetProvider,
} from "@fusion/core/memory-mcp";
import { createLocalStore, closeProjectStore, asLocalProjectContext } from "../project-context.js";
import { retryOnLock } from "../lock-retry.js";
export async function runMemoryMcpServerLoop(options: {
input: Readable;
output: Writable;
projectRoot: string;
backends?: MemoryMcpBackends;
budgetProvider?: MemoryMcpBudgetProvider;
onClose?: () => Promise<void> | void;
}): Promise<void> {
const handler = new MemoryMcpHandler(
options.backends ?? createMemoryMcpBackends(options.projectRoot, null),
options.budgetProvider,
);
const lines = createInterface({ input: options.input, crlfDelay: Infinity });
try {
for await (const line of lines) {
let request: unknown;
try { request = JSON.parse(line); } catch { continue; }
const response = await handler.handle(request);
if (response) options.output.write(`${JSON.stringify(response)}\n`);
}
} finally {
await options.onClose?.();
}
}
/**
* FNXC:MemoryMcp 2026-08-10-20:32:
* FN-8926 opens the project store once on the first tool call because the output
* budget and recall layer are store-resident. A failed open preserves graph access
* with the handler's finite fallback budget while recall reports an actionable tool error.
*/
export async function runMcpMemoryServer(projectRoot: string): Promise<void> {
let store: Awaited<ReturnType<typeof createLocalStore>> | undefined;
let opened = false;
let openFailureLogged = false;
const openStore = async () => {
if (opened) return store;
opened = true;
try {
store = await createLocalStore(projectRoot);
return store;
} catch {
if (!openFailureLogged) {
openFailureLogged = true;
console.error("fusion-memory MCP store unavailable");
}
return undefined;
}
};
const budgetProvider: MemoryMcpBudgetProvider = async () => {
const resolved = await openStore();
if (!resolved) return null;
try {
const settings = await retryOnLock(() => resolved.getSettings(), {
id: "memory-mcp-settings",
action: "read memory MCP settings",
});
return { settings };
} catch {
return null;
}
};
const lazyBackends: MemoryMcpBackends = {
graphQuery: async (args) => createMemoryMcpBackends(projectRoot, (await openStore())?.getAsyncLayer() ?? null).graphQuery(args),
graphNeighbors: async (args) => createMemoryMcpBackends(projectRoot, (await openStore())?.getAsyncLayer() ?? null).graphNeighbors(args),
graphShortestPath: async (args) => createMemoryMcpBackends(projectRoot, (await openStore())?.getAsyncLayer() ?? null).graphShortestPath(args),
recallSearch: async (args) => createMemoryMcpBackends(projectRoot, (await openStore())?.getAsyncLayer() ?? null).recallSearch(args),
recallAppend: async (args) => createMemoryMcpBackends(projectRoot, (await openStore())?.getAsyncLayer() ?? null).recallAppend(args),
};
let closePromise: Promise<void> | undefined;
const close = () => {
closePromise ??= (async () => {
if (store) await closeProjectStore(asLocalProjectContext(store));
})();
return closePromise;
};
const stop = () => {
/*
FNXC:MemoryMcpLifecycle 2026-08-10-20:34:
Signal listeners replace Node's default exit behavior. Close the owned store once and destroy stdin so the JSON-RPC loop terminates rather than retaining a live process.
*/
void close().finally(() => process.stdin.destroy());
};
process.once("SIGTERM", stop);
process.once("SIGINT", stop);
try {
await runMemoryMcpServerLoop({ input: process.stdin, output: process.stdout, projectRoot, backends: lazyBackends, budgetProvider, onClose: close });
} finally {
process.removeListener("SIGTERM", stop);
process.removeListener("SIGINT", stop);
await close();
}
}

View File

@@ -3,6 +3,8 @@ import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { TaskStore,
GlobalSettingsStore,
applyBuiltInMcpToggle,
FUSION_MEMORY_MCP_SERVER_NAME,
exportMcpServersJson,
importMcpServersJson,
isMcpSecretRef,
@@ -17,6 +19,7 @@ import { TaskStore,
type SecretScope,
type Settings,
} from "@fusion/core";
import { buildFusionMemoryBuiltIns } from "@fusion/core/mcp-builtin-servers";
import { resolveProject, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
@@ -117,6 +120,34 @@ async function failMcpCommand(error: unknown, context?: McpContext): Promise<nev
return process.exit(1);
}
/** Resolve the built-in only with a concrete project root; global-only CLI use stays non-spawnable. */
function memoryBuiltInsFor(context: McpContext): McpServerDefinition[] {
const root = context.project?.projectPath ?? context.project?.store.getRootDir();
return buildFusionMemoryBuiltIns(root);
}
/*
* FNXC:MemoryMcp 2026-08-11-00:19:
* List, export, and validate bypass the engine runtime resolver, so retain a named production
* seam for each direct path. Each accepts precomputed built-ins so tests prove these CLI paths
* include the Fusion-owned server without relying on a locally built CLI entry.
*/
export function resolveMcpListEffectiveServers(globalSettings: McpServersSettings, projectSettings: McpServersSettings | undefined, builtIns: McpServerDefinition[]) {
return resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null, [], builtIns);
}
export function resolveMcpExportEffectiveServers(globalSettings: McpServersSettings, projectSettings: McpServersSettings | undefined, builtIns: McpServerDefinition[]) {
return resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null, [], builtIns);
}
export function resolveMcpValidateEffectiveServers(globalSettings: McpServersSettings, projectSettings: McpServersSettings | undefined, builtIns: McpServerDefinition[]) {
return resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null, [], builtIns);
}
function isFusionMemoryServer(name: string): boolean {
return name === FUSION_MEMORY_MCP_SERVER_NAME;
}
function normalizeScope(scope?: McpScope): McpScope {
if (!scope) return DEFAULT_SCOPE;
if (scope !== "global" && scope !== "project") {
@@ -346,7 +377,7 @@ export async function runMcpList(opts: { projectName?: string; json?: boolean }
const globalSettings = mcpSettings(await context.globalStore.getSettings());
const project = context.project;
const projectSettings = project ? mcpSettings((await retryOnLock(async () => project.store.getSettingsByScope(), { id: "mcp-settings", action: "read project MCP settings" })).project) : undefined;
const effective = resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null);
const effective = resolveMcpListEffectiveServers(globalSettings, projectSettings, memoryBuiltInsFor(context));
if (opts.json) {
console.log(JSON.stringify({ global: globalSettings.servers ?? [], project: projectSettings?.servers ?? [], effective }, null, 2));
return;
@@ -381,6 +412,7 @@ export async function runMcpAdd(name: string, opts: McpMutationOptions = {}): Pr
let context: McpContext | undefined;
try {
const scope = normalizeScope(opts.scope);
if (isFusionMemoryServer(name)) throw new Error(`MCP server "${name}" is built in; use mcp enable or mcp disable.`);
context = await loadContext(opts.projectName, scope === "project");
const current = await readScopedSettings(context, scope);
if ((current.servers ?? []).some((server) => server.name === name)) throw new Error(`MCP server "${name}" already exists in ${scope} scope. Use edit to update it.`);
@@ -407,6 +439,7 @@ export async function runMcpEdit(name: string, opts: McpMutationOptions = {}): P
let context: McpContext | undefined;
try {
const scope = normalizeScope(opts.scope);
if (isFusionMemoryServer(name)) throw new Error(`MCP server "${name}" is built in; use mcp enable or mcp disable.`);
context = await loadContext(opts.projectName, scope === "project");
const current = await readScopedSettings(context, scope);
const existing = (current.servers ?? []).find((server) => server.name === name);
@@ -433,6 +466,7 @@ export async function runMcpEdit(name: string, opts: McpMutationOptions = {}): P
export async function runMcpRemove(name: string, opts: { projectName?: string; scope?: McpScope } = {}): Promise<void> {
let context: McpContext | undefined;
try {
if (isFusionMemoryServer(name)) throw new Error(`MCP server "${name}" is built in; use mcp enable or mcp disable.`);
const scope = normalizeScope(opts.scope);
context = await loadContext(opts.projectName, scope === "project");
const current = await readScopedSettings(context, scope);
@@ -458,19 +492,25 @@ async function setEnabled(name: string, enabled: boolean, opts: { projectName?:
const scope = normalizeScope(opts.scope);
context = await loadContext(opts.projectName, scope === "project");
const current = await readScopedSettings(context, scope);
const existing = (current.servers ?? []).find((server) => server.name === name);
if (!existing) throw new Error(`MCP server "${name}" not found in ${scope} scope.`);
await writeScopedSettings(context, scope, { enabled: current.enabled ?? true, servers: upsertServer(current.servers ?? [], { ...existing, enabled }) });
if (isFusionMemoryServer(name)) {
const global = mcpSettings(await context.globalStore.getSettings());
const lowerScopeTombstoned = scope === "project" && (global.servers ?? []).some((server) => server.name === name && server.enabled === false);
await writeScopedSettings(context, scope, applyBuiltInMcpToggle(current, {
scope,
intent: enabled ? "enable" : "disable",
lowerScopeTombstoned,
}));
} else {
const existing = (current.servers ?? []).find((server) => server.name === name);
if (!existing) throw new Error(`MCP server "${name}" not found in ${scope} scope.`);
await writeScopedSettings(context, scope, { enabled: current.enabled ?? true, servers: upsertServer(current.servers ?? [], { ...existing, enabled }) });
}
console.log(`✓ ${enabled ? "Enabled" : "Disabled"} MCP server "${name}" in ${scope} scope`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
if (error instanceof LockRetryExhaustedError) await failMcpCommand(error, context);
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
if (context) await closeMcpContext(context);
}
}
@@ -566,7 +606,7 @@ export async function runMcpExport(opts: { projectName?: string; scope?: McpScop
? globalSettings.servers ?? []
: scope === "project"
? projectSettings?.servers ?? []
: resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null);
: resolveMcpExportEffectiveServers(globalSettings, projectSettings, memoryBuiltInsFor(context));
const exported = exportMcpServersJson(definitions);
const json = JSON.stringify(exported, null, 2);
if (opts.output) {
@@ -603,7 +643,7 @@ export async function runMcpValidate(opts: { projectName?: string; scope?: McpSc
? globalSettings.servers ?? []
: scope === "project"
? projectSettings?.servers ?? []
: resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null);
: resolveMcpValidateEffectiveServers(globalSettings, projectSettings, memoryBuiltInsFor(context));
const validation = validateMcpServerDefinitionsDetailed(definitions);
const result = { ok: validation.errors.length === 0, servers: definitions.length, errors: validation.errors };
if (opts.json) {

View File

@@ -39,6 +39,16 @@
"source": "./src/i18n/detect-content-language.ts",
"import": "./dist/i18n/detect-content-language.js"
},
"./mcp-builtin-servers": {
"types": "./src/config/mcp-builtin-servers.ts",
"source": "./src/config/mcp-builtin-servers.ts",
"import": "./dist/config/mcp-builtin-servers.js"
},
"./memory-mcp": {
"types": "./src/memory/mcp/index.ts",
"source": "./src/memory/mcp/index.ts",
"import": "./dist/memory/mcp/index.js"
},
"./package.json": "./package.json"
},
"publishConfig": {

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { applyBuiltInMcpToggle, FUSION_MEMORY_MCP_SERVER_NAME } from "../config/mcp-builtin-descriptor.js";
import { buildFusionMemoryBuiltIns, resolveFusionMemoryMcpEntry } from "../config/mcp-builtin-servers.js";
describe("Fusion memory MCP built-in", () => {
it("builds a deterministic injected entry without ambient build state", () => {
expect(buildFusionMemoryBuiltIns("/project", "/fixture/bin.js")).toEqual([expect.objectContaining({ name: FUSION_MEMORY_MCP_SERVER_NAME, command: process.execPath, args: ["/fixture/bin.js", "mcp", "serve-memory", "--project-root", "/project"] })]);
expect(buildFusionMemoryBuiltIns("/project", null)).toEqual([]);
});
it("uses tombstone deletion and a marker only to cancel a global tombstone", () => {
const disabled = applyBuiltInMcpToggle({ enabled: true, servers: [] }, { scope: "project", intent: "disable" });
expect(disabled.servers).toEqual([{ name: FUSION_MEMORY_MCP_SERVER_NAME, enabled: false }]);
expect(applyBuiltInMcpToggle(disabled, { scope: "project", intent: "enable" }).servers).toEqual([]);
expect(applyBuiltInMcpToggle({ enabled: true, servers: [] }, { scope: "project", intent: "enable", lowerScopeTombstoned: true }).servers).toEqual([{ name: FUSION_MEMORY_MCP_SERVER_NAME, enabled: true }]);
});
it("ambient entry resolution never throws", () => { expect([null, expect.any(String)]).toContainEqual(resolveFusionMemoryMcpEntry()); });
});

View File

@@ -230,3 +230,15 @@ describe("MCP core config", () => {
expect(resolved.errors).toHaveLength(1);
});
});
describe("built-in MCP precedence", () => {
const memory = { name: "fusion-memory", transport: "stdio" as const, command: "node", args: ["memory"] };
it("restores a seeded built-in when a project marker cancels a global tombstone", () => {
expect(resolveEffectiveMcpServers(
{ mcpServers: { enabled: true, servers: [{ name: "fusion-memory", enabled: false } as never] } },
{ mcpServers: { enabled: true, servers: [{ name: "fusion-memory", enabled: true } as never] } },
[], [memory],
)).toEqual([memory]);
});
});

View File

@@ -0,0 +1,16 @@
import type { McpServersSettings } from "../types.js";
export const FUSION_MEMORY_MCP_SERVER_NAME = "fusion-memory";
export const FUSION_MEMORY_MCP_LABEL = "Fusion memory";
export const FUSION_MEMORY_MCP_TOOL_IDS = ["graph_query", "graph_neighbors", "graph_shortest_path", "recall_search", "recall_append"] as const;
export const FUSION_MEMORY_MCP_DESCRIPTION = "Built-in Fusion knowledge graph and durable recall tools.";
export function isBuiltInMcpServerName(name: string): boolean { return name === FUSION_MEMORY_MCP_SERVER_NAME; }
type Toggle = { scope: "global" | "project"; intent: "enable" | "disable"; lowerScopeTombstoned?: boolean };
/** Shared pure settings mutation keeps CLI and browser toggle semantics byte-identical. */
export function applyBuiltInMcpToggle(settings: McpServersSettings | undefined, toggle: Toggle): McpServersSettings {
const servers = (settings?.servers ?? []).filter(server => server.name !== FUSION_MEMORY_MCP_SERVER_NAME);
if (toggle.intent === "disable") servers.push({ name: FUSION_MEMORY_MCP_SERVER_NAME, enabled: false } as never);
if (toggle.intent === "enable" && toggle.scope === "project" && toggle.lowerScopeTombstoned) servers.push({ name: FUSION_MEMORY_MCP_SERVER_NAME, enabled: true } as never);
return { enabled: settings?.enabled ?? true, servers };
}

View File

@@ -0,0 +1,18 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import type { McpServerDefinition } from "../types.js";
import { FUSION_MEMORY_MCP_SERVER_NAME } from "./mcp-builtin-descriptor.js";
/** Missing packaged/source entry is a normal bootstrap degradation, never a session failure. */
export function resolveFusionMemoryMcpEntry(): string | null {
const override = process.env.FUSION_MEMORY_MCP_ENTRY;
const candidates = [override, resolve(dirname(process.argv[1] ?? process.cwd()), "bin.js"), resolve(process.cwd(), "packages/cli/dist/bin.js"), process.argv[1]];
return candidates.find((candidate): candidate is string => Boolean(candidate && existsSync(candidate))) ?? null;
}
export function buildFusionMemoryBuiltIns(projectRoot: string | undefined | null, entry: string | null | undefined = undefined): McpServerDefinition[] {
const root = projectRoot?.trim();
const resolved = entry === undefined ? resolveFusionMemoryMcpEntry() : entry;
if (!root || !resolved) return [];
return [{ name: FUSION_MEMORY_MCP_SERVER_NAME, transport: "stdio", command: process.execPath, args: [resolved, "mcp", "serve-memory", "--project-root", root] }];
}

View File

@@ -74,12 +74,6 @@ function normalizeMcpServersSettings(settings?: McpServersSettings): McpServersS
};
}
function validServers(settings?: McpServersSettings): McpServerDefinition[] {
return normalizeMcpServersSettings(settings).servers
?.map(validateMcpServerDefinition)
.filter((server): server is McpServerDefinition => Boolean(server)) ?? [];
}
/** Converts a declarative plugin contribution into a validated settings-shaped server. */
export function mapPluginMcpServerContribution(server: PluginMcpServerContribution | unknown): McpServerDefinition | undefined {
if (!server || typeof server !== "object" || Array.isArray(server)) return undefined;
@@ -100,6 +94,7 @@ export function resolveEffectiveMcpServers(
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null,
projectSettings?: Pick<ProjectSettings, "mcpServers"> | null,
pluginServers: Array<{ pluginId: string; server: PluginMcpServerContribution }> = [],
builtIns: McpServerDefinition[] = [],
): McpServerDefinition[] {
try {
const globalMcp = normalizeMcpServersSettings(globalSettings?.mcpServers);
@@ -107,10 +102,21 @@ export function resolveEffectiveMcpServers(
const effectiveEnabled = typeof projectMcp?.enabled === "boolean" ? projectMcp.enabled : globalMcp.enabled;
if (!effectiveEnabled) return [];
const byName = new Map<string, McpServerDefinition>();
for (const server of validServers(globalSettings?.mcpServers)) {
if (server.enabled === false) continue;
byName.set(server.name, server);
/*
FNXC:MemoryMcp 2026-08-10-20:32:
FN-8926 seeds Fusion-provided servers below operator configuration. Tombstones remove
them at either scope, while a project transport-less enabled marker restores the seeded
definition after a global tombstone; re-enable is deletion, not an unspawnable override.
Entry selection remains injectable upstream so resolution never depends on local build state.
*/
const seededBuiltIns = new Map<string, McpServerDefinition>();
for (const server of builtIns.map(validateMcpServerDefinition).filter((server): server is McpServerDefinition => Boolean(server))) seededBuiltIns.set(server.name, server);
const byName = new Map(seededBuiltIns);
// Raw tombstones/markers are intentionally inspected before validation: markers have no transport.
for (const raw of globalMcp.servers ?? []) {
if (raw?.enabled === false) { byName.delete(raw.name); continue; }
const server = validateMcpServerDefinition(raw);
if (server) byName.set(server.name, server);
}
// Plugin order is deterministic at the scoped-provider boundary; later plugins win
// duplicate names just as later project settings win inherited definitions.
@@ -125,12 +131,16 @@ export function resolveEffectiveMcpServers(
const server = mapPluginMcpServerContribution(rawServer);
if (server) byName.set(server.name, server);
}
for (const server of validServers(projectMcp)) {
if (server.enabled === false) {
byName.delete(server.name);
continue;
for (const raw of normalizeMcpServersSettings(projectMcp).servers ?? []) {
if (raw?.enabled === false) { byName.delete(raw.name); continue; }
const server = validateMcpServerDefinition(raw);
if (server) byName.set(server.name, server);
else if (raw?.enabled === true && typeof raw.name === "string") {
// The only valid transport-less enabled record is the scoped marker that cancels
// a global built-in tombstone; never let it replace a runnable seeded definition.
const seeded = seededBuiltIns.get(raw.name);
if (seeded) byName.set(raw.name, seeded);
}
byName.set(server.name, server);
}
return [...byName.values()].filter((server) => server.enabled !== false);
} catch {

View File

@@ -1,6 +1,7 @@
import type { McpSecretImportDescriptor } from "./mcp-config.js";
import { importMcpServersJson } from "./mcp-config.js";
import type { McpServerDefinition } from "../types.js";
import { isBuiltInMcpServerName } from "./mcp-builtin-descriptor.js";
export interface McpDiscoverySource {
id: string;
@@ -110,7 +111,7 @@ export function parseDiscoveredMcpServersFromFile(args: { source: McpDiscoverySo
try {
const normalized = normalizeDiscoveryContents(args.source, args.contents);
const result = importMcpServersJson(normalized, { scope: args.source.scope });
const servers = result.definitions.map((definition) => ({
const servers = result.definitions.filter((definition) => !isBuiltInMcpServerName(definition.name)).map((definition) => ({
source: args.source,
definition,
secretsToCreate: result.secretsToCreate.filter((secret) => secret.serverName === definition.name),

View File

@@ -2806,3 +2806,9 @@ export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY
export { appendAgentActivityEvent, queryAgentActivityEvents, getMaxAgentActivitySeq, pruneAgentActivityEvents } from "./task-store/async/async-agent-activity.js";
export { makeAgentActivityEventId, resolveAgentActivityAttribution, agentIdExistsInRoster, formatAgentActivitySummary, sanitizeAgentActivityMetadata } from "./task-store/agent-activity-outbox.js";
export * from "./memory/recall/index.js";
/*
* FNXC:MemoryMcp 2026-08-11-00:19:
* The dashboard aliases this barrel for browser code. Keep the pure descriptor here, while the
* filesystem-dependent spawn factory remains available only at @fusion/core/mcp-builtin-servers.
*/
export * from "./config/mcp-builtin-descriptor.js";

View File

@@ -9,3 +9,4 @@ export * from "./memory-dreams.js";
export * from "./memory-insights.js";
export * from "./project-memory.js";
export * from "./recall/index.js";
export * from "./mcp/index.js";

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { MemoryMcpHandler } from "../memory-mcp-handler.js";
const backends = {
graphQuery: async () => [{ edge: { provenance: "extracted" } }], graphNeighbors: async () => [], graphShortestPath: async () => [], recallSearch: async () => [], recallAppend: async () => [{ id: "r" }],
};
describe("MemoryMcpHandler", () => {
it("negotiates and executes every declared tool with the MCP result envelope", async () => {
const handler = new MemoryMcpHandler(backends);
const init = await handler.handle({ jsonrpc: "2.0", id: 1, method: "initialize" });
expect(init?.result).toMatchObject({ protocolVersion: "2024-11-05", capabilities: { tools: {} } });
const calls = [
["graph_query", {}], ["graph_neighbors", { nodeId: "n" }], ["graph_shortest_path", { fromId: "a", toId: "b" }], ["recall_search", { query: "q" }], ["recall_append", { kind: "decision", content: "c", source: { origin: "manual" } }],
] as const;
for (const [name, arguments_] of calls) {
const result = await handler.handle({ jsonrpc: "2.0", id: name, method: "tools/call", params: { name, arguments: arguments_ } });
expect(result?.result).toMatchObject({ content: [{ type: "text" }] });
expect((result?.result as Record<string, unknown>).structuredContent).toBeUndefined();
}
});
it("separates protocol errors, tool errors, and notifications", async () => {
const failing = new MemoryMcpHandler({ ...backends, recallAppend: async () => { throw new Error("duplicate SECRET_RECALL_CONTENT"); } });
expect((await failing.handle({ jsonrpc: "2.0", id: 1, method: "nope" }))?.error?.code).toBe(-32601);
expect((await failing.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "nope", arguments: {} } }))?.error?.code).toBe(-32602);
// FNXC:MemoryMcp 2026-08-10-20:34: tools/call schema faults are protocol errors, so bad enum, array, and extra fields must not reach a backend.
expect((await failing.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "recall_append", arguments: { kind: "other", content: "c", source: {} } } }))?.error).toMatchObject({ code: -32602, message: "Invalid kind" });
expect((await failing.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "graph_neighbors", arguments: { nodeId: "n", edgeKinds: ["contains", 4] } } }))?.error).toMatchObject({ code: -32602, message: "Invalid edgeKinds" });
expect((await failing.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "graph_query", arguments: { unexpected: true } } }))?.error).toMatchObject({ code: -32602, message: "Invalid unexpected" });
const duplicateResult = await failing.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "recall_append", arguments: { kind: "decision", content: "c", source: {} } } });
expect(duplicateResult?.result).toMatchObject({ isError: true });
expect(JSON.stringify(duplicateResult)).toContain("rejected as a duplicate");
expect(JSON.stringify(duplicateResult)).not.toContain("SECRET_RECALL_CONTENT");
await expect(failing.handle({ jsonrpc: "2.0", method: "notifications/initialized" })).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildToolOutputTruncationMarker, DEFAULT_TOOL_OUTPUT_MAX_CHARS } from "../../../tool-output-budget.js";
import { CLAMP_PREFIX, MEMORY_TOOL_MIN_TEXT_BUDGET, MEMORY_TOOL_TRUNCATION_HINT, resolveMemoryToolBudget, serializeMemoryMcpResult } from "../memory-mcp-serialization.js";
describe("memory MCP serialization", () => {
it("drops whole tail results before producing parseable JSON", () => {
const output = serializeMemoryMcpResult("graph_query", [{ provenance: "extracted", text: "x".repeat(100) }, { provenance: "inferred", text: "y".repeat(100) }], 300);
expect(output.text.length).toBeLessThanOrEqual(300);
expect(JSON.parse(output.text)).toMatchObject({ truncated: true, omittedCount: 1, results: [{ provenance: "extracted" }] });
});
it("uses the marker-aware non JSON fallback for one oversized result", () => {
const output = serializeMemoryMcpResult("graph_query", [{ provenance: "extracted", text: "x".repeat(500) }], MEMORY_TOOL_MIN_TEXT_BUDGET);
expect(output.text.length).toBeLessThanOrEqual(MEMORY_TOOL_MIN_TEXT_BUDGET);
expect(output.text.startsWith(CLAMP_PREFIX)).toBe(true);
expect(output.text.split(buildToolOutputTruncationMarker(MEMORY_TOOL_TRUNCATION_HINT)).length - 1).toBe(1);
expect(() => JSON.parse(output.text)).toThrow();
});
it("floors finite settings and preserves the unlimited sentinel", () => {
expect(resolveMemoryToolBudget({ agentToolOutputMaxChars: 1 }, undefined, "graph_query")).toBe(MEMORY_TOOL_MIN_TEXT_BUDGET);
expect(resolveMemoryToolBudget({ agentToolOutputMaxChars: 0 }, undefined, "graph_query")).toBeNull();
expect(resolveMemoryToolBudget({}, undefined, "graph_query")).toBe(DEFAULT_TOOL_OUTPUT_MAX_CHARS);
});
});

View File

@@ -0,0 +1,4 @@
export * from "./memory-mcp-tools.js";
export * from "./memory-mcp-serialization.js";
export * from "./memory-mcp-backends.js";
export * from "./memory-mcp-handler.js";

View File

@@ -0,0 +1,39 @@
import { appendRecall, searchRecall, type RecallAppendInput } from "../recall/index.js";
import { loadArtifacts, resolveKnowledgeGraphDir } from "../../knowledge-graph/graph-store.js";
import { neighbors, queryNodes, shortestPath } from "../../knowledge-graph/graph-query.js";
import type { KnowledgeGraph } from "../../knowledge-graph/graph-types.js";
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
export interface MemoryMcpBackends {
graphQuery(args: Record<string, unknown>): Promise<unknown[]>;
graphNeighbors(args: Record<string, unknown>): Promise<unknown[]>;
graphShortestPath(args: Record<string, unknown>): Promise<unknown[]>;
recallSearch(args: Record<string, unknown>): Promise<unknown[]>;
recallAppend(args: Record<string, unknown>): Promise<unknown[]>;
}
async function graphAt(projectRoot: string): Promise<KnowledgeGraph> {
const loaded = await loadArtifacts(resolveKnowledgeGraphDir(projectRoot));
if (!loaded.ok) throw new Error("Knowledge graph artifact is unavailable");
return loaded.graph;
}
/** Thin adapter only: graph and recall persistence remain owned by their respective memory layers. */
export function createMemoryMcpBackends(projectRoot: string, layer: AsyncDataLayer | null): MemoryMcpBackends {
return {
async graphQuery(args) { return queryNodes(await graphAt(projectRoot), (args.filter as never) ?? {}); },
async graphNeighbors(args) { return neighbors(await graphAt(projectRoot), String(args.nodeId), { direction: args.direction as never, edgeKinds: args.edgeKinds as never, depth: args.depth as number | undefined }); },
async graphShortestPath(args) { const path = shortestPath(await graphAt(projectRoot), String(args.fromId), String(args.toId)); return path ? [path] : []; },
async recallSearch(args) {
if (!layer) throw new Error("Recall store is unavailable");
const result = await searchRecall(layer, String(args.query), { kinds: args.kinds as never, tags: args.tags as never, limit: args.limit as number | undefined });
return result.hits;
},
async recallAppend(args) {
if (!layer) throw new Error("Recall store is unavailable");
const result = await appendRecall(layer, { kind: args.kind as RecallAppendInput["kind"], content: String(args.content), source: args.source as RecallAppendInput["source"], tags: args.tags as string[] | undefined, graphNodeIds: args.graphNodeIds as string[] | undefined });
if (result.status === "duplicate") throw new Error("Recall was rejected as a near duplicate");
return [result.record];
},
};
}

View File

@@ -0,0 +1,54 @@
import { MEMORY_MCP_TOOLS, isMemoryMcpToolName, validateMemoryMcpArguments, type MemoryMcpToolName } from "./memory-mcp-tools.js";
import { resolveMemoryToolBudget, serializeMemoryMcpError, serializeMemoryMcpResult } from "./memory-mcp-serialization.js";
import type { MemoryMcpBackends } from "./memory-mcp-backends.js";
export type MemoryMcpBudgetProvider = () => Promise<{ settings?: { agentToolOutputMaxChars?: number | null | unknown }; overrides?: Record<string, number | null | undefined> } | null>;
type JsonRpc = { jsonrpc: "2.0"; id: string | number; result?: unknown; error?: { code: number; message: string } };
/*
* FNXC:MemoryMcp 2026-08-11-00:05:
* Backend errors can include recall text, query values, or database diagnostics. MCP clients need
* an actionable category, never that untrusted detail, so valid tool failures use this fixed map.
*/
function safeToolFailureMessage(tool: MemoryMcpToolName, error: unknown): string {
const category = error instanceof Error ? error.message.toLowerCase() : "";
if (/(duplicate|dedup)/.test(category)) return "Recall entry was rejected as a duplicate";
if (/(unavailable|connect|database|store)/.test(category)) return tool.startsWith("recall_") ? "Recall store is unavailable" : "Knowledge graph is unavailable";
return tool.startsWith("recall_") ? "Recall tool failed" : "Knowledge graph tool failed";
}
export class MemoryMcpHandler {
private budgetPromise: Promise<{ settings?: { agentToolOutputMaxChars?: number | null | unknown }; overrides?: Record<string, number | null | undefined> } | null> | undefined;
constructor(private readonly backends: MemoryMcpBackends, private readonly budgetProvider?: MemoryMcpBudgetProvider) {}
private async budget(tool: string): Promise<number | null> {
this.budgetPromise ??= (this.budgetProvider ? this.budgetProvider() : Promise.resolve(null)).catch(() => null);
const value = await this.budgetPromise;
return resolveMemoryToolBudget(value?.settings, value?.overrides, tool);
}
async handle(request: unknown): Promise<JsonRpc | undefined> {
if (!request || typeof request !== "object" || Array.isArray(request)) return undefined;
const value = request as Record<string, unknown>;
const id = (typeof value.id === "string" || typeof value.id === "number") ? value.id : undefined;
if (value.jsonrpc !== "2.0" || typeof value.method !== "string") return id === undefined ? undefined : { jsonrpc: "2.0", id, error: { code: -32600, message: "Invalid JSON-RPC request" } };
if (id === undefined) return undefined;
if (value.method === "initialize") return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "fusion-memory" } } };
if (value.method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: MEMORY_MCP_TOOLS } };
if (value.method !== "tools/call") return { jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } };
const params = value.params;
if (!params || typeof params !== "object" || Array.isArray(params) || !isMemoryMcpToolName((params as Record<string, unknown>).name)) return { jsonrpc: "2.0", id, error: { code: -32602, message: "Unknown memory tool" } };
const { name, arguments: args } = params as { name: MemoryMcpToolName; arguments?: unknown };
const validation = validateMemoryMcpArguments(name, args ?? {});
if (!validation.ok) return { jsonrpc: "2.0", id, error: { code: -32602, message: `Invalid ${validation.field}` } };
const budget = await this.budget(name);
try {
const method = ({ graph_query: "graphQuery", graph_neighbors: "graphNeighbors", graph_shortest_path: "graphShortestPath", recall_search: "recallSearch", recall_append: "recallAppend" } as const)[name];
const results = await this.backends[method](validation.value);
const serialized = serializeMemoryMcpResult(name, results, budget, validation.value.limit as number | undefined);
return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: serialized.text }] } };
} catch (error) {
return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: serializeMemoryMcpError(name, safeToolFailureMessage(name, error), budget) }], isError: true } };
}
}
}

View File

@@ -0,0 +1,48 @@
import {
DEFAULT_TOOL_OUTPUT_MAX_CHARS,
buildToolOutputTruncationMarker,
clampToolOutputText,
resolveAgentToolOutputMaxChars,
resolveToolOutputBudget,
} from "../../tool-output-budget.js";
export const MEMORY_TOOL_TRUNCATION_HINT = "narrow the memory query or lower its limit";
export const CLAMP_PREFIX = "Memory MCP response was character-truncated and is not parseable JSON.\n";
/** The prefix and canonical marker must fit together before the fallback can satisfy its cap. */
export const MEMORY_TOOL_MIN_TEXT_BUDGET = CLAMP_PREFIX.length + buildToolOutputTruncationMarker(MEMORY_TOOL_TRUNCATION_HINT).length + 1;
export function resolveMemoryToolBudget(
settings: { agentToolOutputMaxChars?: number | null | unknown } | undefined,
overrides: Readonly<Record<string, number | null | undefined>> | undefined,
toolName: string,
): number | null {
const base = resolveAgentToolOutputMaxChars(settings ?? { agentToolOutputMaxChars: DEFAULT_TOOL_OUTPUT_MAX_CHARS });
if (base === null) return null;
let resolved = base;
try { resolved = resolveToolOutputBudget(toolName, overrides, base); } catch { /* invalid user override must not break MCP transport */ }
return Math.max(resolved, MEMORY_TOOL_MIN_TEXT_BUDGET);
}
export type MemoryMcpSerialized = { text: string; truncated: boolean; omittedCount: number };
/**
* FNXC:MemoryMcp 2026-08-10-19:21:
* Memory traversal output drops complete trailing results before character clamping so retained graph
* edges keep their extracted/inferred provenance and ordinary responses remain parseable JSON.
*/
export function serializeMemoryMcpResult(tool: string, items: readonly unknown[], budget: number | null, requestedLimit?: number): MemoryMcpSerialized {
const results = items.slice(0, Math.min(requestedLimit ?? items.length, 100));
if (budget === null) return { text: JSON.stringify({ tool, results, truncated: false, omittedCount: 0 }), truncated: false, omittedCount: 0 };
const kept = results.slice(); let omittedCount = 0; let text = JSON.stringify({ tool, results: kept, truncated: false, omittedCount: 0 });
while (text.length > budget && kept.length > 1) {
kept.pop(); omittedCount += 1;
text = JSON.stringify({ tool, results: kept, truncated: true, omittedCount });
}
if (text.length <= budget) return { text, truncated: omittedCount > 0, omittedCount };
return { text: clampToolOutputText(CLAMP_PREFIX + text, { maxChars: budget, hint: MEMORY_TOOL_TRUNCATION_HINT }), truncated: true, omittedCount };
}
export function serializeMemoryMcpError(tool: string, message: string, budget: number | null): string {
const text = JSON.stringify({ tool, results: [], truncated: false, omittedCount: 0, error: message });
return budget === null ? text : clampToolOutputText(text, { maxChars: budget, hint: MEMORY_TOOL_TRUNCATION_HINT });
}

View File

@@ -0,0 +1,64 @@
export const MEMORY_MCP_TOOL_NAMES = [
"graph_query",
"graph_neighbors",
"graph_shortest_path",
"recall_search",
"recall_append",
] as const;
export type MemoryMcpToolName = typeof MEMORY_MCP_TOOL_NAMES[number];
export const MEMORY_MCP_MAX_RESULTS = 100;
const limit = { type: "integer", minimum: 1, maximum: MEMORY_MCP_MAX_RESULTS } as const;
/** MCP 2024-11-05 schemas are deliberately JSON Schema objects, not SDK-specific schemas. */
export const MEMORY_MCP_TOOLS = [
{ name: "graph_query", description: "Query deterministic Fusion knowledge graph nodes.", inputSchema: { type: "object", properties: { filter: { type: "object" }, limit }, additionalProperties: false } },
{ name: "graph_neighbors", description: "Find graph neighbors while retaining edge provenance.", inputSchema: { type: "object", required: ["nodeId"], properties: { nodeId: { type: "string", minLength: 1 }, direction: { enum: ["in", "out", "both"] }, edgeKinds: { type: "array", items: { type: "string" } }, depth: { type: "integer", minimum: 0 }, limit }, additionalProperties: false } },
{ name: "graph_shortest_path", description: "Find a graph shortest path with provenance-bearing edges.", inputSchema: { type: "object", required: ["fromId", "toId"], properties: { fromId: { type: "string", minLength: 1 }, toId: { type: "string", minLength: 1 }, limit }, additionalProperties: false } },
{ name: "recall_search", description: "Search persisted project recall.", inputSchema: { type: "object", required: ["query"], properties: { query: { type: "string", minLength: 1 }, kinds: { type: "array", items: { enum: ["decision", "preference", "solution"] } }, tags: { type: "array", items: { type: "string" } }, limit }, additionalProperties: false } },
{ name: "recall_append", description: "Append durable project recall, subject to deduplication.", inputSchema: { type: "object", required: ["kind", "content", "source"], properties: { kind: { enum: ["decision", "preference", "solution"] }, content: { type: "string", minLength: 1 }, source: { type: "object" }, tags: { type: "array", items: { type: "string" } }, graphNodeIds: { type: "array", items: { type: "string" } } }, additionalProperties: false } },
] as const;
export function isMemoryMcpToolName(value: unknown): value is MemoryMcpToolName {
return typeof value === "string" && (MEMORY_MCP_TOOL_NAMES as readonly string[]).includes(value);
}
/** Returns a compact protocol-safe validation message instead of allowing tool dispatch to throw. */
export function validateMemoryMcpArguments(name: MemoryMcpToolName, args: unknown): { ok: true; value: Record<string, unknown> } | { ok: false; field: string } {
if (!args || typeof args !== "object" || Array.isArray(args)) return { ok: false, field: "arguments" };
const value = args as Record<string, unknown>;
const allowed = {
graph_query: ["filter", "limit"],
graph_neighbors: ["nodeId", "direction", "edgeKinds", "depth", "limit"],
graph_shortest_path: ["fromId", "toId", "limit"],
recall_search: ["query", "kinds", "tags", "limit"],
recall_append: ["kind", "content", "source", "tags", "graphNodeIds"],
}[name];
const nonEmpty = (field: string) => typeof value[field] === "string" && value[field].trim().length > 0;
const stringArray = (field: string) => value[field] === undefined || (Array.isArray(value[field]) && value[field].every(item => typeof item === "string"));
const unknownField = Object.keys(value).find(field => !allowed.includes(field));
if (unknownField) return { ok: false, field: unknownField };
if (value.limit !== undefined && (!Number.isInteger(value.limit) || (value.limit as number) < 1 || (value.limit as number) > MEMORY_MCP_MAX_RESULTS)) return { ok: false, field: "limit" };
if (name === "graph_query" && value.filter !== undefined && (!value.filter || typeof value.filter !== "object" || Array.isArray(value.filter))) return { ok: false, field: "filter" };
if (name === "graph_neighbors") {
if (!nonEmpty("nodeId")) return { ok: false, field: "nodeId" };
if (value.direction !== undefined && value.direction !== "in" && value.direction !== "out" && value.direction !== "both") return { ok: false, field: "direction" };
if (!stringArray("edgeKinds")) return { ok: false, field: "edgeKinds" };
if (value.depth !== undefined && (!Number.isInteger(value.depth) || (value.depth as number) < 0)) return { ok: false, field: "depth" };
}
if (name === "graph_shortest_path" && (!nonEmpty("fromId") || !nonEmpty("toId"))) return { ok: false, field: !nonEmpty("fromId") ? "fromId" : "toId" };
if (name === "recall_search") {
if (!nonEmpty("query")) return { ok: false, field: "query" };
if (!stringArray("tags")) return { ok: false, field: "tags" };
if (value.kinds !== undefined && (!Array.isArray(value.kinds) || !value.kinds.every(kind => kind === "decision" || kind === "preference" || kind === "solution"))) return { ok: false, field: "kinds" };
}
if (name === "recall_append") {
if (value.kind !== "decision" && value.kind !== "preference" && value.kind !== "solution") return { ok: false, field: "kind" };
if (!nonEmpty("content")) return { ok: false, field: "content" };
if (!value.source || typeof value.source !== "object" || Array.isArray(value.source)) return { ok: false, field: "source" };
if (!stringArray("tags")) return { ok: false, field: "tags" };
if (!stringArray("graphNodeIds")) return { ok: false, field: "graphNodeIds" };
}
return { ok: true, value };
}

View File

@@ -1594,3 +1594,13 @@ export {
resolvePermanentAgentEffectiveThinkingLevel,
} from "./ai/agent-effective-model.js";
export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js";
/* FNXC:MemoryMcp 2026-08-10-20:05: The dashboard aliases @fusion/core to this browser-safe leaf, so expose only the pure built-in descriptor and shared toggle policy here. */
export {
applyBuiltInMcpToggle,
FUSION_MEMORY_MCP_DESCRIPTION,
FUSION_MEMORY_MCP_LABEL,
FUSION_MEMORY_MCP_SERVER_NAME,
FUSION_MEMORY_MCP_TOOL_IDS,
isBuiltInMcpServerName,
} from "./config/mcp-builtin-descriptor.js";

View File

@@ -1,4 +1,5 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Settings } from "@fusion/core";
import type { ToastType } from "../../../hooks/useToast";
@@ -13,10 +14,21 @@ export interface GlobalMcpSectionProps {
export function GlobalMcpSection({ form, setForm, projectId, addToast }: GlobalMcpSectionProps) {
const { t } = useTranslation("app");
const [builtInAvailable, setBuiltInAvailable] = useState(false);
useEffect(() => {
const controller = new AbortController();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
void fetch(`/api/mcp/plugin-servers${query}`, { signal: controller.signal })
.then(async (response) => response.ok ? response.json() as Promise<{ fusionMemoryMcpAvailable?: boolean }> : null)
.then((payload) => { if (!controller.signal.aborted) setBuiltInAvailable(payload?.fusionMemoryMcpAvailable === true); })
.catch(() => { if (!controller.signal.aborted) setBuiltInAvailable(false); });
return () => controller.abort();
}, [projectId]);
return (
<>
<h4 className="settings-section-heading">{t("settings.nav.globalMcp", "MCP Servers")}</h4>
<McpServersCard scope="global" form={form} setForm={setForm} projectId={projectId} addToast={addToast} />
<McpServersCard scope="global" form={form} setForm={setForm} projectId={projectId} builtInAvailable={builtInAvailable} addToast={addToast} />
</>
);
}

View File

@@ -6,7 +6,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { withProjectId } from "../../../api/client/health";
import {
applyBuiltInMcpToggle,
exportMcpServersJson,
FUSION_MEMORY_MCP_DESCRIPTION,
FUSION_MEMORY_MCP_LABEL,
FUSION_MEMORY_MCP_SERVER_NAME,
importMcpServersJson,
isMcpSecretRef,
mapPluginMcpServerContribution,
@@ -26,7 +30,16 @@ type ToastKind = "info" | "success" | "error";
type SecretScope = "project" | "global";
type Transport = McpServerDefinition["transport"];
type ValidationStatus = "idle" | "pending" | "valid" | "unreachable" | "error";
type DisplayState = "configured" | "disabled" | "inherited" | "overridden" | "project-local" | "disabled-global" | "plugin" | "plugin-overridden" | "plugin-disabled";
type DisplayState = "configured" | "disabled" | "inherited" | "overridden" | "project-local" | "disabled-global" | "plugin" | "plugin-overridden" | "plugin-disabled" | "builtin" | "builtin-disabled" | "builtin-unavailable";
/*
* FNXC:MemoryMcp 2026-08-11-00:19:
* The SPA renders descriptor-only metadata. Node resolves the runnable command server-side, so
* this display definition must never gain a command path or import the Node-only factory.
*/
export function getFusionMemoryMcpDisplayDefinition(): McpServerDefinition {
return { name: FUSION_MEMORY_MCP_SERVER_NAME, transport: "stdio", command: "", args: [] };
}
type FormSetter = Dispatch<SetStateAction<Settings>>;
@@ -97,6 +110,8 @@ export interface McpServersCardProps {
projectId?: string;
/** Already project-scoped contributions supplied by the API/provider. */
pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>;
/** Node-only entry resolution is computed by the settings route, never in the browser. */
builtInAvailable?: boolean;
addToast: (message: string, type?: ToastKind) => void;
}
@@ -238,6 +253,9 @@ function getStateLabel(state: DisplayState): string {
if (state === "project-local") return "project local";
if (state === "plugin-overridden") return "plugin overridden";
if (state === "plugin-disabled") return "plugin disabled";
if (state === "builtin") return "built-in";
if (state === "builtin-disabled") return "built-in disabled";
if (state === "builtin-unavailable") return "built-in unavailable";
return state;
}
@@ -258,7 +276,7 @@ function getValidationLabel(status: ValidationStatus): string {
*
* Project MCP declarations override global servers by matching name and may save enabled:false tombstones to disable inherited global servers. The project card shows inherited, overridden, local, and disabled states so operators can see effective behavior before saving.
*/
export function McpServersCard({ scope, form, setForm, globalSettings, projectId, pluginServers = [], addToast }: McpServersCardProps) {
export function McpServersCard({ scope, form, setForm, globalSettings, projectId, pluginServers = [], builtInAvailable = true, addToast }: McpServersCardProps) {
const { t } = useTranslation("app");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS);
@@ -356,7 +374,14 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
}, [discovered?.servers]);
const displayRows = useMemo(() => {
if (scope === "global") return configuredServers.map((server): { server: McpServerDefinition; state: DisplayState } => ({ server, state: server.enabled === false ? "disabled" : "configured" }));
const builtIn = getFusionMemoryMcpDisplayDefinition();
const builtInState = !builtInAvailable ? "builtin-unavailable" as const : "builtin" as const;
if (scope === "global") {
const rows = configuredServers.map((server): { server: McpServerDefinition; state: DisplayState } => ({ server, state: server.enabled === false ? "disabled" : "configured" }));
const configured = configuredServers.find((server) => server.name === FUSION_MEMORY_MCP_SERVER_NAME) as { enabled?: boolean; transport?: string } | undefined;
if (settings.enabled && (!configured || !configured.transport)) rows.unshift({ server: builtIn, state: configured?.enabled === false ? "builtin-disabled" : builtInState });
return rows;
}
const effectiveByName = new Set(effectiveServers.map((server) => server.name));
const rows: Array<{ server: McpServerDefinition; state: DisplayState }> = [];
// FNXC:PluginMcpServers 2026-07-22-12:00:
@@ -364,7 +389,11 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
// that session resolution uses. In particular, a plugin replaces a
// same-name global server rather than being hidden behind it (FN-8491/#2401).
const inheritedByName = new Map<string, { server: McpServerDefinition; plugin: boolean }>();
for (const server of globalServers) inheritedByName.set(server.name, { server, plugin: false });
// Transport-less Fusion-memory records are enablement markers/tombstones, not runnable servers.
for (const server of globalServers) {
if (server.name === FUSION_MEMORY_MCP_SERVER_NAME && !(server as { transport?: string }).transport) continue;
inheritedByName.set(server.name, { server, plugin: false });
}
for (const [name, entry] of pluginByName) inheritedByName.set(name, { server: entry.definition, plugin: true });
for (const [name, inherited] of inheritedByName) {
const projectServer = projectByName.get(name);
@@ -377,10 +406,22 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
}
}
for (const server of configuredServers) {
if (server.name === FUSION_MEMORY_MCP_SERVER_NAME && !(server as { transport?: string }).transport) continue;
if (!inheritedByName.has(server.name)) rows.push({ server, state: server.enabled === false || !effectiveByName.has(server.name) ? "disabled" : "project-local" });
}
const globalBuiltIn = globalByName.get(FUSION_MEMORY_MCP_SERVER_NAME) as { enabled?: boolean; transport?: string } | undefined;
const projectBuiltIn = projectByName.get(FUSION_MEMORY_MCP_SERVER_NAME) as { enabled?: boolean; transport?: string } | undefined;
if (settings.enabled && !globalBuiltIn?.transport && !projectBuiltIn?.transport) {
/*
FNXC:MemoryMcp 2026-08-10-16:50:
A project enabled marker cancels a global tombstone in the resolver. The display must
reflect that restored seeded server rather than presenting it as globally disabled.
*/
const globalTombstoneStillApplies = globalBuiltIn?.enabled === false && projectBuiltIn?.enabled !== true;
rows.unshift({ server: builtIn, state: projectBuiltIn?.enabled === false || globalTombstoneStillApplies ? "builtin-disabled" : builtInState });
}
return rows;
}, [configuredServers, effectiveServers, globalServers, pluginByName, projectByName, scope]);
}, [builtInAvailable, configuredServers, effectiveServers, globalByName, globalServers, pluginByName, projectByName, scope, settings.enabled]);
const updateMcpSettings = (next: McpServersSettings) => {
setForm((current) => ({ ...current, mcpServers: next }));
@@ -388,6 +429,11 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
const setEnabled = (enabled: boolean) => updateMcpSettings({ ...settings, enabled });
const toggleBuiltIn = (enabled: boolean) => {
const lowerScopeTombstoned = scope === "project" && globalByName.get(FUSION_MEMORY_MCP_SERVER_NAME)?.enabled === false;
updateMcpSettings(applyBuiltInMcpToggle(settings, { scope, intent: enabled ? "enable" : "disable", lowerScopeTombstoned }));
};
const saveServer = () => {
if (!editor) return;
setEditorError(null);
@@ -642,6 +688,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
{displayRows.map(({ server, state }) => {
const validation = validateStates[server.name] ?? { status: "idle" as const };
const editable = scope === "global" || state !== "inherited";
const isBuiltInPlaceholder = state === "builtin" || state === "builtin-disabled" || state === "builtin-unavailable";
return (
/*
FNXC:McpSettings 2026-07-22-12:10:
@@ -650,19 +697,19 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
<article className="mcp-server-row" key={server.name} data-testid={`mcp-server-row-${server.name}`}>
<div className="mcp-server-row__main">
<div className="mcp-server-row__titleline">
<strong>{server.name}</strong>
<strong>{isBuiltInPlaceholder ? FUSION_MEMORY_MCP_LABEL : server.name}</strong>
<span className={`mcp-state-badge mcp-state-badge--${state}`} data-state={state}>{getStateLabel(state)}</span>{scope === "project" && pluginByName.has(server.name) ? <span className="mcp-state-badge" data-testid={`mcp-plugin-provenance-${server.name}`}>{`plugin:${pluginByName.get(server.name)!.pluginId}`}</span> : null}
<span className="mcp-transport-badge">{server.transport}</span>
</div>
<p>{serverSummary(server)}</p>
{isBuiltInPlaceholder ? <p>{state === "builtin-unavailable" ? t("settings.mcp.builtinUnavailable", "Built-in entry is unavailable in this installation.") : FUSION_MEMORY_MCP_DESCRIPTION}</p> : <p>{serverSummary(server)}</p>}
<p className={`mcp-validation-status mcp-validation-status--${validation.status}`} data-testid={`mcp-validation-${server.name}`} aria-live="polite"><span className={getValidateDotClass(validation.status)} aria-hidden="true" /> <span className="mcp-validation-status__badge">{validation.status === "idle" ? t("settings.mcp.notTested", "Not tested") : getValidationLabel(validation.status)}</span>{validation.message ? <span>{validation.message}</span> : null}</p>
</div>
<div className="mcp-server-row__actions">
<button type="button" className="btn btn-sm touch-target" onClick={() => void validateServer(server)} disabled={validation.status === "pending"}><Play aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {validation.status === "pending" ? t("settings.mcp.testing", "Testing…") : t("settings.mcp.test", "Test")}</button>
{isBuiltInPlaceholder ? <button type="button" className="btn btn-sm touch-target" onClick={() => toggleBuiltIn(state === "builtin-disabled")} disabled={state === "builtin-unavailable"}>{state === "builtin-disabled" ? t("settings.mcp.enableBuiltin", "Enable") : t("settings.mcp.disableBuiltin", "Disable")}</button> : <button type="button" className="btn btn-sm touch-target" onClick={() => void validateServer(server)} disabled={validation.status === "pending"}><Play aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {validation.status === "pending" ? t("settings.mcp.testing", "Testing…") : t("settings.mcp.test", "Test")}</button>}
{(state === "inherited" || state === "plugin") ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("settings.mcp.override", "Override")}</button> : null}
{(state === "inherited" || state === "plugin") ? <button type="button" className="btn btn-warning btn-sm touch-target" onClick={() => disableInheritedServer(server.name)}>{t("settings.mcp.disableInherited", "Disable")}</button> : null}
{editable ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("actions.edit", "Edit")}</button> : null}
{editable ? <button type="button" className="btn btn-icon touch-target" aria-label={t("settings.mcp.removeServer", "Remove {{name}}", { name: server.name })} onClick={() => removeServer(server.name)}><Trash2 aria-hidden="true" /></button> : null}
{editable && !isBuiltInPlaceholder ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("actions.edit", "Edit")}</button> : null}
{editable && !isBuiltInPlaceholder ? <button type="button" className="btn btn-icon touch-target" aria-label={t("settings.mcp.removeServer", "Remove {{name}}", { name: server.name })} onClick={() => removeServer(server.name)}><Trash2 aria-hidden="true" /></button> : null}
</div>
</article>
);

View File

@@ -18,9 +18,9 @@ export interface ProjectMcpSectionProps {
export function ProjectMcpSection({ form, setForm, globalSettings, projectId, pluginServers, addToast }: ProjectMcpSectionProps) {
const { t } = useTranslation("app");
const [loadedPluginServers, setLoadedPluginServers] = useState<Array<{ pluginId: string; server: PluginMcpServerContribution }>>([]);
const [builtInAvailable, setBuiltInAvailable] = useState(false);
useEffect(() => {
if (pluginServers) return;
const controller = new AbortController();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
/*
@@ -32,17 +32,22 @@ export function ProjectMcpSection({ form, setForm, globalSettings, projectId, pl
void fetch(`/api/mcp/plugin-servers${query}`, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error("Failed to load project plugin MCP servers");
return response.json() as Promise<{ servers?: Array<{ pluginId: string; server: PluginMcpServerContribution }> }>;
return response.json() as Promise<{ servers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>; fusionMemoryMcpAvailable?: boolean }>;
})
.then((payload) => { if (!controller.signal.aborted) setLoadedPluginServers(Array.isArray(payload.servers) ? payload.servers : []); })
.catch(() => { if (!controller.signal.aborted) setLoadedPluginServers([]); });
.then((payload) => {
if (!controller.signal.aborted) {
if (!pluginServers) setLoadedPluginServers(Array.isArray(payload.servers) ? payload.servers : []);
setBuiltInAvailable(payload.fusionMemoryMcpAvailable === true);
}
})
.catch(() => { if (!controller.signal.aborted) { if (!pluginServers) setLoadedPluginServers([]); setBuiltInAvailable(false); } });
return () => controller.abort();
}, [pluginServers, projectId]);
return (
<>
<h4 className="settings-section-heading">{t("settings.nav.mcp", "MCP Servers")}</h4>
<McpServersCard scope="project" form={form} setForm={setForm} globalSettings={globalSettings} projectId={projectId} pluginServers={pluginServers ?? loadedPluginServers} addToast={addToast} />
<McpServersCard scope="project" form={form} setForm={setForm} globalSettings={globalSettings} projectId={projectId} pluginServers={pluginServers ?? loadedPluginServers} builtInAvailable={builtInAvailable} addToast={addToast} />
</>
);
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Settings } from "@fusion/core";
import { getFusionMemoryMcpDisplayDefinition, McpServersCard } from "../McpServersCard";
function renderCard(scope: "global" | "project", form: Settings, globalSettings?: Settings) {
let current = form;
const setForm = vi.fn((updater: Settings | ((previous: Settings) => Settings)) => {
current = typeof updater === "function" ? updater(current) : updater;
});
const view = render(<McpServersCard scope={scope} form={current} setForm={setForm} globalSettings={globalSettings} builtInAvailable addToast={vi.fn()} />);
setForm.mockImplementation((updater: Settings | ((previous: Settings) => Settings)) => {
current = typeof updater === "function" ? updater(current) : updater;
view.rerender(<McpServersCard scope={scope} form={current} setForm={setForm} globalSettings={globalSettings} builtInAvailable addToast={vi.fn()} />);
});
return { form: () => current };
}
describe("McpServersCard built-in memory server", () => {
it("uses only a descriptor display row with no spawn command", () => {
expect(getFusionMemoryMcpDisplayDefinition()).toMatchObject({ name: "fusion-memory", command: "", args: [] });
});
it("renders an uneditable built-in row and writes a tombstone then deletes it", () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ servers: [] }) }));
const result = renderCard("global", { mcpServers: { enabled: true, servers: [] } } as Settings);
expect(screen.getByTestId("mcp-server-row-fusion-memory")).toHaveTextContent("Fusion memory");
expect(screen.queryByLabelText("Remove fusion-memory")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Disable" }));
expect(result.form().mcpServers?.servers).toEqual([{ name: "fusion-memory", enabled: false }]);
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
expect(result.form().mcpServers?.servers).toEqual([]);
vi.unstubAllGlobals();
});
it("uses the project marker only when cancelling a global tombstone", () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ servers: [] }) }));
const result = renderCard("project", { mcpServers: { enabled: true, servers: [{ name: "fusion-memory", enabled: false } as never] } as never } as Settings, { mcpServers: { enabled: true, servers: [{ name: "fusion-memory", enabled: false } as never] } as never } as Settings);
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
expect(result.form().mcpServers?.servers).toEqual([{ name: "fusion-memory", enabled: true }]);
expect(screen.getByTestId("mcp-server-row-fusion-memory")).toHaveTextContent("built-in");
expect(screen.getByTestId("mcp-server-row-fusion-memory")).not.toHaveTextContent("built-in disabled");
vi.unstubAllGlobals();
});
});

View File

@@ -249,6 +249,30 @@ describe("ChatManager.sendMessage", () => {
});
});
it("forwards the injected fusion-memory built-in through the dashboard chat resolver", async () => {
const previousEntry = process.env.FUSION_MEMORY_MCP_ENTRY;
process.env.FUSION_MEMORY_MCP_ENTRY = join(process.cwd(), "../..", "package.json");
let resolvedOptions: { mcpServers?: Array<{ name: string }> } | undefined;
__setCreateResolvedAgentSession(async (options: { mcpServers?: Array<{ name: string }> }) => {
resolvedOptions = options;
return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [] } } } as any;
});
const taskStore = {
getSettingsByScope: vi.fn(async () => ({ global: { mcpServers: { enabled: true } }, project: { mcpServers: { enabled: true } } })),
getSecretsStore: vi.fn(() => ({ revealSecret: async () => { throw new Error("unexpected secret"); } })),
getRootDir: () => "/fixture",
getSettings: vi.fn().mockResolvedValue({}),
emitUsageEvent: vi.fn(),
};
try {
await new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, undefined, undefined, undefined, taskStore as any).sendMessage("chat-001", "resolve memory tools");
expect(resolvedOptions?.mcpServers).toContainEqual(expect.objectContaining({ name: "fusion-memory" }));
} finally {
if (previousEntry === undefined) delete process.env.FUSION_MEMORY_MCP_ENTRY;
else process.env.FUSION_MEMORY_MCP_ENTRY = previousEntry;
}
});
it("does not require a TaskStore to persist a human chat turn", async () => {
__setCreateResolvedAgentSession(async () => ({
session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [] } },

View File

@@ -1,5 +1,6 @@
import type { McpServerDefinition, PluginMcpServerContribution, TaskStore } from "@fusion/core";
import { mapPluginMcpServerContribution, validateMcpServerDefinitionDetailed } from "@fusion/core";
import { resolveFusionMemoryMcpEntry } from "@fusion/core/mcp-builtin-servers";
import {
discoverMcpServers,
resolveMcpServersForRuntime,
@@ -73,17 +74,28 @@ function stripMcpSecretDescriptor(secret: { field: "env" | "headers" | "token";
};
}
/*
* FNXC:MemoryMcp 2026-08-11-00:19:
* Validation supplies the route's explicit project root to the runtime resolver. This keeps the
* direct probe path behaviorally aligned with session resolution without exposing Node entry
* resolution to the browser.
*/
export async function resolveMcpServersForMcpProbe(options: Parameters<typeof resolveMcpServersForRuntime>[0]) {
return resolveMcpServersForRuntime(options);
}
async function resolveMcpServerForValidation(
scopedStore: TaskStore,
request: { name?: string; definition?: McpServerDefinition },
) {
if (request.definition) {
const secrets = await scopedStore.getSecretsStore();
const resolved = await resolveMcpServersForRuntime({
const resolved = await resolveMcpServersForMcpProbe({
globalSettings: { mcpServers: { enabled: true, servers: [request.definition] } },
projectSettings: undefined,
secrets,
reader: {},
projectRoot: scopedStore.getRootDir(),
});
if (resolved.errors.length > 0 || resolved.servers.length === 0) {
throw badRequest("Unable to resolve MCP server secrets", { errors: resolved.errors.map((error) => ({ serverName: error.serverName, path: error.path, message: error.message })) });
@@ -130,10 +142,16 @@ export const registerConfigMcpPiSettingsRoutes: ApiRouteRegistrar = (ctx) => {
const entries = typeof provider.getProjectScopedPluginMcpServers === "function"
? await provider.getProjectScopedPluginMcpServers()
: [];
/*
FNXC:MemoryMcp 2026-08-10-19:45:
The browser receives only whether Fusion can resolve its built-in MCP entry.
Entry resolution uses Node filesystem state and must remain outside the SPA bundle.
*/
res.json({
servers: entries
.filter((entry) => mapPluginMcpServerContribution(entry?.server))
.map((entry) => ({ pluginId: entry.pluginId, server: entry.server })),
fusionMemoryMcpAvailable: resolveFusionMemoryMcpEntry() !== null,
});
} catch (error) {
rethrowAsApiError(error, "Failed to load project plugin MCP servers");

View File

@@ -533,6 +533,8 @@ export default defineConfig({
*/
"@fusion/core/task-delete-attribution": resolve(__dirname, "../core/src/task-delete-attribution.ts"),
"@fusion/core/column-roles": resolve(__dirname, "../core/src/column-roles.ts"),
// FNXC:MemoryMcp 2026-08-11-00:19: Route tests use the Node-only factory subpath; browser components remain on the pure descriptor barrel.
"@fusion/core/mcp-builtin-servers": resolve(__dirname, "../core/src/config/mcp-builtin-servers.ts"),
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
"@fusion/engine": resolve(__dirname, "../engine/src/index.ts"),
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),

View File

@@ -0,0 +1,163 @@
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { McpSecretReader, TaskStore } from "@fusion/core";
import { buildFusionMemoryBuiltIns } from "@fusion/core/mcp-builtin-servers";
import {
resolveMcpExportEffectiveServers,
resolveMcpListEffectiveServers,
resolveMcpValidateEffectiveServers,
} from "../../../cli/src/commands/mcp.js";
import { resolveMcpServersForMcpProbe } from "../../../dashboard/src/routes/register-config-mcp-pi-settings-routes.js";
import { resolveMcpServers } from "../executor/resolve-mcp-servers.js";
import { resolveHeartbeatMcpForAgent } from "../agent-heartbeat.js";
import { resolveMcpServersForRuntime, resolveMcpServersForStore, type McpSettingsAndSecretsStore } from "../mcp/mcp-resolution.js";
const repoRoot = resolve(process.cwd(), "../..");
const roots = ["packages/engine/src", "packages/dashboard/src", "packages/dashboard/app", "packages/cli/src", "packages/core/src"];
const needles = ["resolveMcpServersForStore(", "resolveMcpServersForRuntime(", "resolveEffectiveMcpServers("] as const;
const fixtureEntry = resolve(repoRoot, "package.json");
type Bucket = "A" | "B" | "C" | "D" | "R";
type LedgerRow = { file: string; line: number; bucket: Bucket; expression: string; type?: "TaskStore" | "nullable-root"; reason: string; passesBuiltIns?: true | "descriptor-only" };
/*
* FNXC:MemoryMcpCoverage 2026-08-11-00:05:
* This is intentionally a per-call-site ledger, not a file/count inventory. A resolver forwarding
* scan cannot prove a lane passes a root-capable store, so each A row names its real expression and
* type while D records direct callers that would otherwise bypass the runtime resolver.
*/
const LEDGER: LedgerRow[] = [
["packages/engine/src/executor/resolve-mcp-servers.ts",32,"A","deps.store","TaskStore","executor TaskStore"],
["packages/engine/src/execution/reviewer.ts",543,"A","options.store","TaskStore","reviewer TaskStore"],
["packages/engine/src/merger.ts",362,"A","store","TaskStore","merger TaskStore"],
["packages/engine/src/merge/merger-ai.ts",469,"A","store","TaskStore","merger AI TaskStore"],
["packages/engine/src/merge/merger-ai.ts",542,"A","store","TaskStore","merger AI TaskStore"],
["packages/engine/src/merge/pr-response-run-ops.ts",112,"A","store","TaskStore","PR response TaskStore"],
["packages/engine/src/agent-heartbeat.ts",177,"A","taskStore","nullable-root","heartbeat accepts a nullable root"],
["packages/engine/src/triage.ts",3136,"A","this.store","TaskStore","triage TaskStore"],
["packages/engine/src/scheduling/cron-runner.ts",1073,"A","store","TaskStore","cron TaskStore"],
["packages/engine/src/missions/mission-execution-loop.ts",965,"A","this.taskStore","TaskStore","mission TaskStore"],
["packages/engine/src/agents/agent-reflection.ts",157,"A","this.taskStore","TaskStore","reflection TaskStore"],
["packages/engine/src/eval/evaluator.ts",170,"A","this.deps.store","TaskStore","evaluator TaskStore"],
["packages/dashboard/src/chat.ts",2871,"A","this.taskStore","TaskStore","chat scoped TaskStore"],
["packages/dashboard/src/planning.ts",128,"A","store","TaskStore","planning TaskStore"],
["packages/dashboard/src/milestone-slice-interview.ts",826,"A","store","TaskStore","milestone TaskStore"],
["packages/dashboard/src/mission-interview.ts",938,"A","store","TaskStore","mission interview TaskStore"],
["packages/dashboard/src/subtask-breakdown.ts",410,"A","store ?? {}","TaskStore","optional store resolves as TaskStore when present"],
["packages/dashboard/src/subtask-breakdown.ts",545,"A","store ?? {}","TaskStore","optional store resolves as TaskStore when present"],
["packages/dashboard/src/agent-generation.ts",504,"A","store ?? {}","TaskStore","optional store resolves as TaskStore when present"],
["packages/dashboard/src/pr-conflict-resolver.ts",182,"A","store","TaskStore","conflict resolver TaskStore"],
["packages/dashboard/src/routes/automation-step-execution.ts",181,"A","taskStore","TaskStore","automation TaskStore"],
["packages/dashboard/src/ai-refine.ts",340,"B","store ?? {}",undefined,"pre-existing no-store degrade"],
["packages/dashboard/src/ai-refine.ts",422,"B","store ?? {}",undefined,"pre-existing no-store degrade"],
["packages/dashboard/src/ai-translate.ts",342,"B","store ?? {}",undefined,"pre-existing no-store degrade"],
["packages/dashboard/src/agent-onboarding.ts",358,"B","store ?? {}",undefined,"pre-existing no-store degrade"],
["packages/dashboard/src/pr-metadata-generator.ts",302,"B","store ?? {}",undefined,"pre-existing no-store degrade"],
["packages/dashboard/src/insights-routes.ts",187,"B","params.taskStore ?? {}",undefined,"pre-existing no-store degrade"],
["packages/engine/src/agent-heartbeat.ts",177,"C","taskStore","nullable-root","undefined or empty root omits only built-in"],
["packages/cli/src/commands/mcp.ts",136,"D","builtIns",undefined,"CLI list direct resolver",true],
["packages/cli/src/commands/mcp.ts",140,"D","builtIns",undefined,"CLI export direct resolver",true],
["packages/cli/src/commands/mcp.ts",144,"D","builtIns",undefined,"CLI validate direct resolver",true],
["packages/dashboard/app/components/settings/sections/McpServersCard.tsx",354,"D","descriptor display",undefined,"browser has no spawn entry","descriptor-only"],
["packages/engine/src/mcp/mcp-resolution.ts",38,"D","runtime resolver",undefined,"runtime entrypoint",true],
["packages/engine/src/mcp/mcp-resolution.ts",41,"D","built-in factory",undefined,"runtime calls effective resolver",true],
["packages/engine/src/mcp/mcp-resolution.ts",76,"D","store resolver",undefined,"store entrypoint",true],
["packages/engine/src/mcp/mcp-resolution.ts",94,"D","projectRoot",undefined,"store forwards root",true],
["packages/core/src/config/mcp-config.ts",93,"D","effective resolver",undefined,"resolution chokepoint",true],
["packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts",84,"R","explicit projectRoot",undefined,"server probe root"],
["packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts",106,"R","scopedStore",undefined,"server settings root"],
].map(([file, line, bucket, expression, type, reason, passesBuiltIns]) => ({ file, line, bucket, expression, type, reason, passesBuiltIns }));
const secretReader: McpSecretReader = { revealSecret: async () => { throw new Error("unexpected secret"); } };
const rootStore = {
async getSettingsByScope() { return { global: { mcpServers: { enabled: true } }, project: { mcpServers: { enabled: true } } }; },
getSecretsStore: async () => secretReader,
getRootDir: () => "/fixture",
};
type NullableRootStore = Omit<McpSettingsAndSecretsStore, "getRootDir"> & { getRootDir(): string | undefined };
const taskStoreWitness = null as unknown as TaskStore satisfies McpSettingsAndSecretsStore;
const nullableRootWitness = null as unknown as NullableRootStore satisfies McpSettingsAndSecretsStore;
void taskStoreWitness;
void nullableRootWitness;
function sourceCalls(): Array<{ file: string; line: number }> {
const found: Array<{ file: string; line: number }> = [];
const walk = (directory: string) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const full = resolve(directory, entry.name);
if (entry.isDirectory()) { if (entry.name !== "__tests__" && entry.name !== "mocks") walk(full); continue; }
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) continue;
for (const [index, line] of readFileSync(full, "utf8").split("\n").entries()) if (needles.some((needle) => line.includes(needle))) found.push({ file: full.slice(repoRoot.length + 1), line: index + 1 });
}
};
for (const root of roots) walk(resolve(repoRoot, root));
return found;
}
function hasMemoryServer(result: { servers: Array<{ name: string; args?: string[] }> }) {
expect(result.servers).toContainEqual(expect.objectContaining({ name: "fusion-memory", args: expect.arrayContaining([fixtureEntry]) }));
}
describe("fusion-memory MCP lane ledger", () => {
const priorEntry = process.env.FUSION_MEMORY_MCP_ENTRY;
afterEach(() => {
if (priorEntry === undefined) delete process.env.FUSION_MEMORY_MCP_ENTRY;
else process.env.FUSION_MEMORY_MCP_ENTRY = priorEntry;
});
it("maps every resolver call across all roots to one required bucket", () => {
const calls = sourceCalls();
const mappedCalls = [...new Map(LEDGER.map(({ file, line }) => [`${file}:${line}`, { file, line }])).values()];
expect(calls.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)).toEqual(
mappedCalls.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
);
expect(LEDGER.filter((row) => row.bucket === "A").map((row) => row.file)).toEqual(expect.arrayContaining([
expect.stringContaining("executor"), expect.stringContaining("reviewer"), expect.stringContaining("merger"), expect.stringContaining("triage"), expect.stringContaining("agent-heartbeat"), expect.stringContaining("chat"), expect.stringContaining("planning"),
]));
expect(LEDGER.filter((row) => row.bucket === "D" && row.passesBuiltIns === true).filter((row) => row.file === "packages/cli/src/commands/mcp.ts")).toHaveLength(3);
const cli = readFileSync(resolve(repoRoot, "packages/cli/src/commands/mcp.ts"), "utf8");
for (const row of LEDGER.filter((entry) => entry.file === "packages/cli/src/commands/mcp.ts")) expect(cli.split("\n")[row.line - 1]).toContain("builtIns");
});
it("proves bucket behavior with an injected entry and no ambient build dependency", async () => {
const enabled = { mcpServers: { enabled: true, servers: [{ name: "other", transport: "stdio" as const, command: "other" }] } };
hasMemoryServer(await resolveMcpServersForRuntime({ globalSettings: enabled, projectSettings: null, secrets: secretReader, projectRoot: "/fixture", memoryMcpEntry: fixtureEntry }));
await expect(resolveMcpServersForStore({})).resolves.toEqual({ servers: [], errors: [] });
for (const root of [undefined, ""]) {
const result = await resolveMcpServersForRuntime({ globalSettings: enabled, projectSettings: null, secrets: secretReader, projectRoot: root, memoryMcpEntry: fixtureEntry });
expect(result.servers.map((server) => server.name)).toEqual(["other"]);
}
const missing = await resolveMcpServersForRuntime({ globalSettings: enabled, projectSettings: null, secrets: secretReader, projectRoot: "/fixture", memoryMcpEntry: null });
expect(missing.servers.map((server) => server.name)).toEqual(["other"]);
});
it("drives every CLI direct resolver and the explicit-root probe with an injected entry", async () => {
const global = { enabled: true, servers: [{ name: "other", transport: "stdio" as const, command: "other" }] };
const builtIns = buildFusionMemoryBuiltIns("/fixture", fixtureEntry);
const directResolvers = [resolveMcpListEffectiveServers, resolveMcpExportEffectiveServers, resolveMcpValidateEffectiveServers];
for (const resolveDirect of directResolvers) {
hasMemoryServer({ servers: resolveDirect(global, undefined, builtIns) });
}
const probe = await resolveMcpServersForMcpProbe({
globalSettings: { mcpServers: global },
projectSettings: null,
secrets: secretReader,
projectRoot: "/fixture",
memoryMcpEntry: fixtureEntry,
});
hasMemoryServer(probe);
});
it("drives executor and heartbeat wrappers with a root and validates heartbeat degradation", async () => {
process.env.FUSION_MEMORY_MCP_ENTRY = fixtureEntry;
const store = rootStore as unknown as TaskStore;
const executor = await resolveMcpServers({ store });
expect(executor).toContainEqual(expect.objectContaining({ name: "fusion-memory" }));
const heartbeat = await resolveHeartbeatMcpForAgent(store, "agent");
expect(heartbeat.servers).toContainEqual(expect.objectContaining({ name: "fusion-memory" }));
const noRoot = { ...rootStore, getRootDir: () => undefined } as unknown as TaskStore;
await expect(resolveHeartbeatMcpForAgent(noRoot, "agent")).resolves.toMatchObject({ servers: [] });
});
});

View File

@@ -9,6 +9,7 @@ import {
type PluginMcpServerContribution,
type ResolvedMcpServerDefinition,
} from "@fusion/core";
import { buildFusionMemoryBuiltIns } from "@fusion/core/mcp-builtin-servers";
export interface ResolveMcpServersForRuntimeOptions {
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null;
@@ -17,6 +18,8 @@ export interface ResolveMcpServersForRuntimeOptions {
reader?: McpSecretReaderIdentity;
/** Already project-scoped plugin entries. Raw runner/loader output is forbidden here. */
pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>;
projectRoot?: string | undefined;
memoryMcpEntry?: string | null;
}
export interface ResolvedMcpServersForRuntime {
@@ -35,7 +38,7 @@ export interface ResolvedMcpServersForRuntime {
export async function resolveMcpServersForRuntime(
options: ResolveMcpServersForRuntimeOptions,
): Promise<ResolvedMcpServersForRuntime> {
const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings, options.pluginServers);
const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings, options.pluginServers, buildFusionMemoryBuiltIns(options.projectRoot, options.memoryMcpEntry));
if (effective.length === 0) return { servers: [], errors: [] };
const materialized = await materializeMcpServersSecrets(
@@ -60,6 +63,7 @@ export interface McpSettingsAndSecretsStore {
}>;
getSecretsStore?(): Promise<McpSecretReader> | McpSecretReader;
/** Shared provider hook; implementations must filter project_plugin_states. */
getRootDir?(): string | undefined | Promise<string | undefined>;
getProjectScopedPluginMcpServers?(): Promise<Array<{ pluginId: string; server: PluginMcpServerContribution }>> | Array<{ pluginId: string; server: PluginMcpServerContribution }>;
}
@@ -81,10 +85,11 @@ export async function resolveMcpServersForStore(
return { servers: [], errors: [] };
}
const [settings, secrets, pluginServers] = await Promise.all([
const [settings, secrets, pluginServers, projectRoot] = await Promise.all([
store.getSettingsByScope(),
typeof store.getSecretsStore === "function" ? store.getSecretsStore() : emptyMcpSecretReader,
typeof store.getProjectScopedPluginMcpServers === "function" ? store.getProjectScopedPluginMcpServers() : [],
typeof store.getRootDir === "function" ? Promise.resolve(store.getRootDir()).catch(() => undefined) : undefined,
]);
return resolveMcpServersForRuntime({
globalSettings: settings.global,
@@ -92,5 +97,6 @@ export async function resolveMcpServersForStore(
secrets,
reader,
pluginServers,
projectRoot,
});
}

View File

@@ -16,6 +16,8 @@ export default defineConfig({
*/
"@fusion/core/column-roles": resolve(__dirname, "../core/src/column-roles.ts"),
"@fusion/core/task-delete-attribution": resolve(__dirname, "../core/src/task-delete-attribution.ts"),
// FNXC:MemoryMcp 2026-08-11-00:19: Preserve the Node-only factory subpath without leaking it through the browser-safe core barrel.
"@fusion/core/mcp-builtin-servers": resolve(__dirname, "../core/src/config/mcp-builtin-servers.ts"),
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
"@fusion/engine": resolve(__dirname, "./src/index.ts"),