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:
7
.changeset/fn-8926-memory-mcp-server.md
Normal file
7
.changeset/fn-8926-memory-mcp-server.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
10
docs/mcp.md
10
docs/mcp.md
@@ -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.
|
||||
|
||||
@@ -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") });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
104
packages/cli/src/commands/mcp-memory-server.ts
Normal file
104
packages/cli/src/commands/mcp-memory-server.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
17
packages/core/src/__tests__/mcp-builtin-servers.test.ts
Normal file
17
packages/core/src/__tests__/mcp-builtin-servers.test.ts
Normal 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()); });
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
16
packages/core/src/config/mcp-builtin-descriptor.ts
Normal file
16
packages/core/src/config/mcp-builtin-descriptor.ts
Normal 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 };
|
||||
}
|
||||
18
packages/core/src/config/mcp-builtin-servers.ts
Normal file
18
packages/core/src/config/mcp-builtin-servers.ts
Normal 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] }];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
4
packages/core/src/memory/mcp/index.ts
Normal file
4
packages/core/src/memory/mcp/index.ts
Normal 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";
|
||||
39
packages/core/src/memory/mcp/memory-mcp-backends.ts
Normal file
39
packages/core/src/memory/mcp/memory-mcp-backends.ts
Normal 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];
|
||||
},
|
||||
};
|
||||
}
|
||||
54
packages/core/src/memory/mcp/memory-mcp-handler.ts
Normal file
54
packages/core/src/memory/mcp/memory-mcp-handler.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
48
packages/core/src/memory/mcp/memory-mcp-serialization.ts
Normal file
48
packages/core/src/memory/mcp/memory-mcp-serialization.ts
Normal 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 });
|
||||
}
|
||||
64
packages/core/src/memory/mcp/memory-mcp-tools.ts
Normal file
64
packages/core/src/memory/mcp/memory-mcp-tools.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [] } },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
163
packages/engine/src/__tests__/mcp-builtin-lane-coverage.test.ts
Normal file
163
packages/engine/src/__tests__/mcp-builtin-lane-coverage.test.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user