Files
fusion/packages/core/src/plugin-mcp-servers.ts
gsxdsm af897d9e3c FN-8596: isolate cross-root plugin MCP discovery
Prevent cross-root MCP discovery from unloading active plugin runtimes.

- Isolate discovery loader lifecycle and runtime-state persistence.
- Preserve shared plugin owners when non-owner loader participants stop.
- Cover core, dashboard, and engine cross-root discovery behavior.

Files changed:
 .changeset/fn-8596-plugin-discovery-isolation.md   |   7 ++
 .../plugin-loader-lifecycle-scope.test.ts          |  12 +++
 .../plugin-mcp-servers-discovery-isolation.test.ts | 115 +++++++++++++++++++++
 packages/core/src/plugin-loader.ts                 |  34 +++++-
 packages/core/src/plugin-mcp-servers.ts            |   8 +-
 .../context-plugin-mcp-discovery-isolation.test.ts |  48 +++++++++
 packages/dashboard/src/routes/context.ts           |  39 ++++++-
 ...-runtime-plugin-mcp-discovery-isolation.test.ts |  69 +++++++++++++
 packages/engine/src/runtimes/in-process-runtime.ts |  47 +++++++--
 9 files changed, 364 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-8596

Fusion-Task-Lineage: 231e53b6-a9a3-4a65-9732-3dabe44da198

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 08:35:04 -07:00

85 lines
3.7 KiB
TypeScript

import { resolve as resolvePath } from "node:path";
import { PluginLoader } from "./plugin-loader.js";
import type { PluginMcpServerContribution } from "./plugin-types.js";
export type PluginMcpServerEntry = { pluginId: string; server: PluginMcpServerContribution };
/** Minimal project seams required by the shared MCP contribution provider. */
export interface ProjectPluginMcpStore {
getRootDir(): string;
getPluginStore(): {
init(): Promise<void>;
listPlugins(filter?: { enabled?: boolean }): Promise<Array<{ id: string; updatedAt: string | number | Date }>>;
};
}
export interface ProjectScopedPluginMcpProviderOptions {
hostRootDir: string;
hostLoader: Pick<PluginLoader, "getPluginMcpServers">;
/** Creates a non-persisting loader for a project other than the host root. */
createScopedLoader?: (store: ProjectPluginMcpStore) => Pick<PluginLoader, "loadAllPlugins" | "stopAllPlugins" | "getPluginMcpServers">;
}
/**
* Creates the only project-aware plugin MCP provider. The caller supplies the
* target project store, allowing daemon, dashboard, CLI, and engine adapters to
* share the exact same enablement and cache invariant without raw enumeration.
*
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 / #2401 requires plugin MCP declarations only in projects where the
* plugin is enabled. Cache keys include normalized project root AND enabled
* id:updatedAt state, preventing project A's contributions leaking into B.
*/
export function createProjectScopedPluginMcpProvider(options: ProjectScopedPluginMcpProviderOptions): {
get(store: ProjectPluginMcpStore): Promise<PluginMcpServerEntry[]>;
} {
const cache = new Map<string, { enabledKey: string; entries: PluginMcpServerEntry[] }>();
const normalizedHostRoot = resolvePath(options.hostRootDir);
return {
async get(store): Promise<PluginMcpServerEntry[]> {
const root = resolvePath(store.getRootDir());
const pluginStore = store.getPluginStore();
await pluginStore.init();
const enabled = await pluginStore.listPlugins({ enabled: true });
const enabledKey = enabled.map((plugin) => `${plugin.id}:${plugin.updatedAt}`).sort().join("\0");
const existing = cache.get(root);
if (existing?.enabledKey === enabledKey) return existing.entries;
if (enabled.length === 0) {
const entries: PluginMcpServerEntry[] = [];
cache.set(root, { enabledKey, entries });
return entries;
}
const enabledIds = new Set(enabled.map((plugin) => plugin.id));
/*
FNXC:PluginMcpServers 2026-07-23-12:00:
FN-8596 requires other-root discovery to use an isolated loader: it may
read enabled MCP contributions and run private teardown, but must never
join or mutate shared lifecycle state or persist runtime transitions.
*/
// Same-root callers reuse their active loader. Other roots load only their
// own enabled plugins through the caller's isolated construction seam.
if (root === normalizedHostRoot) {
const entries = options.hostLoader.getPluginMcpServers().filter((entry) => enabledIds.has(entry.pluginId));
cache.set(root, { enabledKey, entries });
return entries;
}
if (!options.createScopedLoader) {
const entries: PluginMcpServerEntry[] = [];
cache.set(root, { enabledKey, entries });
return entries;
}
const loader = options.createScopedLoader(store);
try {
await loader.loadAllPlugins();
const entries = loader.getPluginMcpServers().filter((entry) => enabledIds.has(entry.pluginId));
cache.set(root, { enabledKey, entries });
return entries;
} finally {
await loader.stopAllPlugins();
}
},
};
}