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>
85 lines
3.7 KiB
TypeScript
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();
|
|
}
|
|
},
|
|
};
|
|
}
|