FN-8491: add declarative plugin MCP server registrations

Enable plugins to declare per-project MCP server registrations.

- Add plugin MCP server contribution types, loading, and resolution across core and engine runtimes.
- Expose resolved plugin registrations through project configuration APIs and MCP settings UI.
- Document the declarative contribution API and add release metadata and regression coverage.

Files changed:
 .changeset/plugin-mcp-servers.md                   |  7 ++
 docs/PLUGIN_AUTHORING.md                           | 17 +++++
 docs/mcp.md                                        |  4 ++
 docs/settings-reference.md                         |  4 ++
 packages/core/src/__tests__/mcp-config.test.ts     | 33 +++++++++
 .../__tests__/plugin-contribution-types.test.ts    | 16 +++++
 .../__tests__/plugin-loader-single-load.test.ts    | 23 +++++++
 packages/core/src/index.gate.ts                    |  3 +
 packages/core/src/index.ts                         |  3 +
 packages/core/src/mcp-config.ts                    | 37 ++++++++--
 packages/core/src/plugin-loader.ts                 | 24 +++++++
 packages/core/src/plugin-mcp-servers.ts            | 78 ++++++++++++++++++++++
 packages/core/src/plugin-types.ts                  | 15 ++++-
 packages/core/src/types.ts                         |  2 +-
 .../__tests__/SettingsModal.mcp.test.tsx           | 34 +++++++++-
 .../settings/sections/McpServersCard.tsx           | 52 ++++++++++-----
 .../settings/sections/ProjectMcpSection.tsx        | 31 ++++++++-
 .../register-config-mcp-pi-settings-routes.test.ts | 18 ++++-
 packages/dashboard/src/routes/context.ts           | 71 +++++++++++++++++++-
 .../register-config-mcp-pi-settings-routes.ts      | 30 ++++++++-
 .../engine/src/__tests__/mcp-resolution.test.ts    | 20 ++++++
 packages/engine/src/mcp-resolution.ts              | 15 ++++-
 packages/engine/src/plugin-runner.ts               | 38 +++++++++++
 packages/engine/src/runtimes/in-process-runtime.ts | 23 +++++++
 packages/plugin-sdk/src/index.ts                   |  1 +
 25 files changed, 563 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-8491

Fusion-Task-Lineage: be7e22fa-5776-4b3d-9fd1-a873799e6427

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 15:25:20 -07:00
parent 8cb12b95a9
commit 3cd023fa43
25 changed files with 563 additions and 36 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let enabled plugins declaratively provide project MCP servers.
category: feature
dev: Plugin `mcpServers` resolve between global and project settings; project overrides and tombstones win.

View File

@@ -1894,3 +1894,20 @@ const setupHooks: PluginSetupHooks = {
``` ```
`checkSetup` is required. `install` and `uninstall` are optional. `checkSetup` is required. `install` and `uninstall` are optional.
## Declarative MCP servers
Plugins may declare MCP servers with `mcpServers`. Declarations are active only in projects where the plugin is enabled; Fusion does not install the referenced binary.
```ts
mcpServers: [{
name: "roslyn-navigator",
transport: "stdio",
command: "cwm-roslyn-navigator",
args: [],
env: { TOKEN: { secretRef: "roslyn-token", scope: "project" } },
enabledByDefault: true,
}]
```
`enabledByDefault` defaults to `true`. Plugin declarations cannot set `enabled`: project settings own enablement. Effective precedence is global settings, enabled plugin declarations, then project settings by name. A project definition overrides a plugin declaration and a same-named project `enabled:false` entry tombstones it. Use Fusion secret references for sensitive `env` or `headers`; never ship plaintext credentials. Missing commands retain normal per-server MCP spawn-failure isolation.

View File

@@ -287,3 +287,7 @@ Read-only sessions do not receive MCP tools automatically. Interactive planning
Expected outcome: enabling a server makes it available to subsequent supported AI sessions and explicitly opted-in planning/mission read-only sessions, while unsupported sessions and read-only sessions without the opt-in continue without MCP tools and without logging secret-bearing server definitions. Expected outcome: enabling a server makes it available to subsequent supported AI sessions and explicitly opted-in planning/mission read-only sessions, while unsupported sessions and read-only sessions without the opt-in continue without MCP tools and without logging secret-bearing server definitions.
See [Settings Reference](./settings-reference.md) for the `mcpServers` settings contract and [Agents](./agents.md) for runtime/model lane behavior. See [Settings Reference](./settings-reference.md) for the `mcpServers` settings contract and [Agents](./agents.md) for runtime/model lane behavior.
## Plugin-provided servers
A plugin can declaratively contribute an MCP server without modifying project settings. Fusion includes it only when that plugin is enabled for the active project, then applies normal project override and `enabled:false` tombstone semantics by name. The server binary remains the consuming project's responsibility; an unavailable command produces the normal isolated MCP spawn failure. Plugin declarations may contain only Fusion secret references for sensitive values.

View File

@@ -1806,3 +1806,7 @@ Escalation is enabled only when the toggle is true and either a complete provide
### `mobileNavPrimaryItems` ### `mobileNavPrimaryItems`
Project-scoped ordered list of up to six mobile footer quick actions. The default remains `command-center`, `tasks`, `agents`, `missions`, `chat`, `mailbox`. Settings shows selected items in order with move/remove controls and an add dropdown for eligible navigation destinations (including More-sheet actions and gated views); edits preview in the live footer and auto-save after editing. Unknown ids plus `more`, Terminal/scripts, shell controls, plugin views, and separators are ignored. Omitted available destinations remain reachable in More, whose trailing footer tab is always present. Disabled feature-gated destinations render nowhere until their feature is enabled. Project-scoped ordered list of up to six mobile footer quick actions. The default remains `command-center`, `tasks`, `agents`, `missions`, `chat`, `mailbox`. Settings shows selected items in order with move/remove controls and an add dropdown for eligible navigation destinations (including More-sheet actions and gated views); edits preview in the live footer and auto-save after editing. Unknown ids plus `more`, Terminal/scripts, shell controls, plugin views, and separators are ignored. Omitted available destinations remain reachable in More, whose trailing footer tab is always present. Disabled feature-gated destinations render nowhere until their feature is enabled.
### Plugin-provided MCP servers
Enabled plugins may declare `mcpServers` declaratively. The contribution is project-scoped: only plugins enabled in that project's plugin state participate. Resolution is global → enabled plugin declarations → project settings, by server `name`; later declarations win and a project `enabled:false` entry removes an inherited plugin server. The Global MCP card never includes plugin declarations. Project MCP UI identifies them as `plugin:<id>` and writes only project overrides or tombstones.

View File

@@ -39,6 +39,39 @@ describe("MCP core config", () => {
).toEqual([projectServer]); ).toEqual([projectServer]);
}); });
it("merges enabled plugin contributions between global and project settings", () => {
const plugin = { pluginId: "roslyn", server: { name: "navigator", transport: "stdio" as const, command: "cwm-roslyn-navigator" } };
const override: McpServerDefinition = { name: "navigator", transport: "stdio", command: "project-navigator" };
expect(resolveEffectiveMcpServers(
{ mcpServers: { enabled: true, servers: [{ name: "global", transport: "stdio", command: "global" }] } },
{ mcpServers: { enabled: true, servers: [override] } }, [plugin],
)).toEqual([
{ name: "global", transport: "stdio", command: "global" }, override,
]);
expect(resolveEffectiveMcpServers(
{ mcpServers: { enabled: true } },
{ mcpServers: { enabled: true, servers: [{ ...override, enabled: false }] } }, [plugin],
)).toEqual([]);
expect(resolveEffectiveMcpServers(
{ mcpServers: { enabled: true } }, { mcpServers: { enabled: true } },
[{ pluginId: "off", server: { ...plugin.server, enabledByDefault: false } }],
)).toEqual([]);
});
it("skips malformed runtime plugin entries without disabling healthy settings", () => {
const validGlobal: McpServerDefinition = { name: "global", transport: "stdio", command: "global" };
const validProject: McpServerDefinition = { name: "project", transport: "stdio", command: "project" };
expect(resolveEffectiveMcpServers(
{ mcpServers: { enabled: true, servers: [validGlobal] } },
{ mcpServers: { enabled: true, servers: [validProject] } },
[
null as unknown as { pluginId: string; server: never },
{ pluginId: "bad-null", server: null as unknown as never },
{ pluginId: "bad-default", server: { name: "bad", transport: "stdio", command: "bad", enabledByDefault: "no" } as unknown as never },
],
)).toEqual([validGlobal, validProject]);
});
it("lets a project disabled entry remove a global server", () => { it("lets a project disabled entry remove a global server", () => {
const globalServer: McpServerDefinition = { const globalServer: McpServerDefinition = {
name: "global-only", name: "global-only",

View File

@@ -9,6 +9,7 @@ import type {
PluginSetupHooks, PluginSetupHooks,
PluginSetupManifest, PluginSetupManifest,
PluginSkillContribution, PluginSkillContribution,
PluginMcpServerContribution,
PluginWorkflowStepContribution, PluginWorkflowStepContribution,
} from "../plugin-types.js"; } from "../plugin-types.js";
import type { import type {
@@ -127,6 +128,21 @@ describe("plugin contribution type constraints", () => {
expect((await minimalHooks.checkSetup({} as never)).status).toBe("not-installed"); expect((await minimalHooks.checkSetup({} as never)).status).toBe("not-installed");
}); });
it("accepts declarative MCP contributions without a per-server enabled flag", () => {
const server: PluginMcpServerContribution = {
name: "roslyn-navigator",
transport: "stdio",
command: "cwm-roslyn-navigator",
env: { TOKEN: { secretRef: "roslyn-token", scope: "project" } },
enabledByDefault: true,
};
const plugin: FusionPlugin = {
manifest: { id: "roslyn", name: "Roslyn", version: "1.0.0" }, state: "installed", hooks: {}, mcpServers: [server],
};
expect(plugin.mcpServers?.[0]?.name).toBe("roslyn-navigator");
expectTypeOf(server.enabledByDefault).toEqualTypeOf<boolean | undefined>();
});
it("accepts prompt surface union and prompt contribution records", () => { it("accepts prompt surface union and prompt contribution records", () => {
const surfaces: PluginPromptSurface[] = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"]; const surfaces: PluginPromptSurface[] = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"];
const byPlugin: Record<string, PluginPromptContributions> = { const byPlugin: Record<string, PluginPromptContributions> = {

View File

@@ -49,6 +49,29 @@ describe("PluginLoader process single-load lifecycle", () => {
delete (globalThis as Record<string, unknown>).__fusionPluginOnUnloadCount; delete (globalThis as Record<string, unknown>).__fusionPluginOnUnloadCount;
}); });
it("skips malformed MCP contribution containers without blocking healthy plugins", async () => {
const root = await mkdtemp(join(tmpdir(), "fusion-plugin-mcp-container-"));
roots.push(root);
const malformedEntry = join(root, "malformed.mjs");
const healthyEntry = join(root, "healthy.mjs");
await writeFile(malformedEntry, `export default { manifest: { id: "malformed", name: "Malformed", version: "1.0.0", description: "fixture" }, state: "installed", hooks: {}, mcpServers: {} };`);
await writeFile(healthyEntry, `export default { manifest: { id: "healthy", name: "Healthy", version: "1.0.0", description: "fixture" }, state: "installed", hooks: {}, mcpServers: [{ name: "navigator", transport: "stdio", command: "navigator" }] };`);
const malformed = createStore(root, malformedEntry);
const installations = new Map([
["malformed", { ...(await malformed.pluginStore.getPlugin("single-load")), id: "malformed", name: "Malformed", path: malformedEntry }],
["healthy", { ...(await malformed.pluginStore.getPlugin("single-load")), id: "healthy", name: "Healthy", path: healthyEntry }],
]);
malformed.pluginStore.getPlugin = vi.fn(async (id: string) => installations.get(id)!);
malformed.pluginStore.listPlugins = vi.fn(async () => [...installations.values()]);
const loader = new PluginLoader({ pluginStore: malformed.pluginStore as any, taskStore: malformed.taskStore as any });
await loader.loadAllPlugins();
expect(loader.getPluginMcpServers()).toEqual([
{ pluginId: "healthy", server: { name: "navigator", transport: "stdio", command: "navigator" } },
]);
});
it("coalesces concurrent host and engine-style loaders for one project", async () => { it("coalesces concurrent host and engine-style loaders for one project", async () => {
const fixture = await createFixture("async () => { globalThis.__fusionPluginOnLoadCount = (globalThis.__fusionPluginOnLoadCount || 0) + 1; await new Promise(resolve => setTimeout(resolve, 20)); }"); const fixture = await createFixture("async () => { globalThis.__fusionPluginOnLoadCount = (globalThis.__fusionPluginOnLoadCount || 0) + 1; await new Promise(resolve => setTimeout(resolve, 20)); }");
roots.push(fixture.root); roots.push(fixture.root);

View File

@@ -130,11 +130,13 @@ export {
} from "./worktrunk-settings.js"; } from "./worktrunk-settings.js";
export { export {
resolveEffectiveMcpServers, resolveEffectiveMcpServers,
mapPluginMcpServerContribution,
materializeMcpServerSecrets, materializeMcpServerSecrets,
materializeMcpServersSecrets, materializeMcpServersSecrets,
importMcpServersJson, importMcpServersJson,
exportMcpServersJson, exportMcpServersJson,
} from "./mcp-config.js"; } from "./mcp-config.js";
export { createProjectScopedPluginMcpProvider } from "./plugin-mcp-servers.js";
export type { export type {
McpSecretReaderIdentity, McpSecretReaderIdentity,
McpSecretReader, McpSecretReader,
@@ -1252,6 +1254,7 @@ export type {
CreateInteractiveAiSessionFactory, CreateInteractiveAiSessionFactory,
PluginLogger, PluginLogger,
PluginSkillContribution, PluginSkillContribution,
PluginMcpServerContribution,
PluginWorkflowStepContribution, PluginWorkflowStepContribution,
PluginTraitContribution, PluginTraitContribution,
PluginTraitHookDescriptor, PluginTraitHookDescriptor,

View File

@@ -155,11 +155,13 @@ export {
} from "./worktrunk-settings.js"; } from "./worktrunk-settings.js";
export { export {
resolveEffectiveMcpServers, resolveEffectiveMcpServers,
mapPluginMcpServerContribution,
materializeMcpServerSecrets, materializeMcpServerSecrets,
materializeMcpServersSecrets, materializeMcpServersSecrets,
importMcpServersJson, importMcpServersJson,
exportMcpServersJson, exportMcpServersJson,
} from "./mcp-config.js"; } from "./mcp-config.js";
export { createProjectScopedPluginMcpProvider } from "./plugin-mcp-servers.js";
export type { export type {
McpSecretReaderIdentity, McpSecretReaderIdentity,
McpSecretReader, McpSecretReader,
@@ -1379,6 +1381,7 @@ export type {
CreateInteractiveAiSessionFactory, CreateInteractiveAiSessionFactory,
PluginLogger, PluginLogger,
PluginSkillContribution, PluginSkillContribution,
PluginMcpServerContribution,
PluginWorkflowStepContribution, PluginWorkflowStepContribution,
PluginTraitContribution, PluginTraitContribution,
PluginTraitHookDescriptor, PluginTraitHookDescriptor,

View File

@@ -8,6 +8,7 @@ import type {
McpStreamableHttpTransport, McpStreamableHttpTransport,
ProjectSettings, ProjectSettings,
} from "./types.js"; } from "./types.js";
import type { PluginMcpServerContribution } from "./plugin-types.js";
import { isMcpSecretRef } from "./types.js"; import { isMcpSecretRef } from "./types.js";
import type { SecretScope } from "./secrets-store.js"; import type { SecretScope } from "./secrets-store.js";
import { validateMcpServerDefinition } from "./settings-validation.js"; import { validateMcpServerDefinition } from "./settings-validation.js";
@@ -74,20 +75,31 @@ function normalizeMcpServersSettings(settings?: McpServersSettings): McpServersS
} }
function validServers(settings?: McpServersSettings): McpServerDefinition[] { function validServers(settings?: McpServersSettings): McpServerDefinition[] {
return ( return normalizeMcpServersSettings(settings).servers
normalizeMcpServersSettings(settings).servers ?.map(validateMcpServerDefinition)
?.map(validateMcpServerDefinition) .filter((server): server is McpServerDefinition => Boolean(server)) ?? [];
.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;
const input = server as Record<string, unknown>;
// Plugin declarations have no enabled field: project settings own per-project
// enablement. Reject malformed runtime values individually before reading them.
if ("enabled" in input || (input.enabledByDefault !== undefined && typeof input.enabledByDefault !== "boolean")) return undefined;
return validateMcpServerDefinition(input);
} }
/** /**
* FNXC:McpConfig 2026-06-25-00:00: * FNXC:McpConfig 2026-06-25-00:00:
* Effective MCP configuration is project-over-global by server name. A project server with enabled:false removes the inherited global declaration, while a project enabled declaration replaces it. The resolver is pure and never throws so settings reads cannot break task scheduling. * FN-8491 / #2401 resolves global → enabled plugin → project by server name.
* A project enabled:false tombstone removes inherited global or plugin declarations;
* invalid plugin entries are ignored and this pure resolver never throws.
*/ */
export function resolveEffectiveMcpServers( export function resolveEffectiveMcpServers(
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null, globalSettings?: Pick<GlobalSettings, "mcpServers"> | null,
projectSettings?: Pick<ProjectSettings, "mcpServers"> | null, projectSettings?: Pick<ProjectSettings, "mcpServers"> | null,
pluginServers: Array<{ pluginId: string; server: PluginMcpServerContribution }> = [],
): McpServerDefinition[] { ): McpServerDefinition[] {
try { try {
const globalMcp = normalizeMcpServersSettings(globalSettings?.mcpServers); const globalMcp = normalizeMcpServersSettings(globalSettings?.mcpServers);
@@ -100,6 +112,19 @@ export function resolveEffectiveMcpServers(
if (server.enabled === false) continue; if (server.enabled === false) continue;
byName.set(server.name, 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.
for (const contribution of pluginServers) {
// Runtime plugin output is untrusted even though TypeScript callers use the
// public contribution type. A malformed entry must not disable healthy
// global/project MCP servers (FN-8491 / #2401).
if (!contribution || typeof contribution !== "object" || Array.isArray(contribution)) continue;
const rawServer = (contribution as { server?: unknown }).server;
if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer)) continue;
if ((rawServer as { enabledByDefault?: unknown }).enabledByDefault === false) continue;
const server = mapPluginMcpServerContribution(rawServer);
if (server) byName.set(server.name, server);
}
for (const server of validServers(projectMcp)) { for (const server of validServers(projectMcp)) {
if (server.enabled === false) { if (server.enabled === false) {
byName.delete(server.name); byName.delete(server.name);

View File

@@ -31,6 +31,7 @@ import type {
PluginInstallation, PluginInstallation,
PluginManifest, PluginManifest,
PluginSkillContribution, PluginSkillContribution,
PluginMcpServerContribution,
PluginWorkflowStepContribution, PluginWorkflowStepContribution,
PluginTraitContribution, PluginTraitContribution,
PluginPromptContribution, PluginPromptContribution,
@@ -1523,6 +1524,29 @@ export class PluginLoader extends EventEmitter<{
return skills; return skills;
} }
/**
* Get raw MCP contributions from loaded plugins. Consumers must still apply
* project_plugin_states before session or UI use.
*
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 keeps loader enumeration intentionally raw so one project-scoped
* provider can enforce enablement consistently for every caller.
*/
getPluginMcpServers(): Array<{ pluginId: string; server: PluginMcpServerContribution }> {
const servers: Array<{ pluginId: string; server: PluginMcpServerContribution }> = [];
for (const [pluginId, plugin] of this.plugins) {
/*
* FNXC:PluginMcpServers 2026-07-22-15:35:
* FN-8491 must isolate malformed runtime plugin contribution containers;
* only arrays are iterable, while individual malformed servers are
* filtered by the shared mapper later in MCP resolution.
*/
if (!Array.isArray(plugin.mcpServers)) continue;
for (const server of plugin.mcpServers) servers.push({ pluginId, server });
}
return servers;
}
/** /**
* Get all workflow step contributions from loaded plugins. * Get all workflow step contributions from loaded plugins.
*/ */

View File

@@ -0,0 +1,78 @@
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));
// Same-root callers reuse their active loader. Other roots load only their
// own enabled plugins and never persist lifecycle state during discovery.
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();
}
},
};
}

View File

@@ -13,7 +13,7 @@
import type { Database } from "./db.js"; import type { Database } from "./db.js";
import type { TaskStore } from "./store.js"; import type { TaskStore } from "./store.js";
import type { PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; import type { McpServerDefinition, PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
import type { import type {
WorkflowExtensionContribution, WorkflowExtensionContribution,
WorkflowExtensionFallback, WorkflowExtensionFallback,
@@ -690,6 +690,17 @@ export interface CliProviderContribution {
/** /**
* Plugin-contributed skill surfaced in agent sessions via the skill-selection system. * Plugin-contributed skill surfaced in agent sessions via the skill-selection system.
*/ */
/*
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 / #2401 lets plugins declare MCP wiring without writing consumer settings.
* Contributions deliberately omit `enabled`: project settings own enablement and can
* override or tombstone a server by name; sensitive values remain secret references.
*/
export type PluginMcpServerContribution = Omit<McpServerDefinition, "enabled"> & {
/** Opt out of automatic wiring while remaining available for a project override. */
enabledByDefault?: boolean;
};
export interface PluginSkillContribution { export interface PluginSkillContribution {
/** Unique skill identifier within the plugin namespace (kebab-case). */ /** Unique skill identifier within the plugin namespace (kebab-case). */
skillId: string; skillId: string;
@@ -1163,6 +1174,8 @@ export interface FusionPlugin {
cliProviders?: CliProviderContribution[]; cliProviders?: CliProviderContribution[];
/** Plugin-contributed skills surfaced by the skill resolver. */ /** Plugin-contributed skills surfaced by the skill resolver. */
skills?: PluginSkillContribution[]; skills?: PluginSkillContribution[];
/** Declarative MCP servers, scoped to projects where this plugin is enabled. */
mcpServers?: PluginMcpServerContribution[];
/** Plugin-contributed workflow step templates. */ /** Plugin-contributed workflow step templates. */
workflowSteps?: PluginWorkflowStepContribution[]; workflowSteps?: PluginWorkflowStepContribution[];
/** Plugin-contributed column traits (U8). */ /** Plugin-contributed column traits (U8). */

View File

@@ -27,7 +27,7 @@ export {
export type { CapacityRiskSignal } from "./capacity.js"; export type { CapacityRiskSignal } from "./capacity.js";
// FNXC:McpConfig 2026-06-26-02:10: The dashboard Vite build aliases @fusion/core to this browser-safe module, so the pure MCP config helpers are re-exported here for Settings UI import/export, validation, and project-over-global resolution without pulling Node-only stores into the client bundle. // FNXC:McpConfig 2026-06-26-02:10: The dashboard Vite build aliases @fusion/core to this browser-safe module, so the pure MCP config helpers are re-exported here for Settings UI import/export, validation, and project-over-global resolution without pulling Node-only stores into the client bundle.
export { exportMcpServersJson, importMcpServersJson, resolveEffectiveMcpServers } from "./mcp-config.js"; export { exportMcpServersJson, importMcpServersJson, mapPluginMcpServerContribution, resolveEffectiveMcpServers } from "./mcp-config.js";
export { export {
DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_API_BASE_URL,
DEFAULT_GITLAB_INSTANCE_URL, DEFAULT_GITLAB_INSTANCE_URL,

View File

@@ -38,9 +38,12 @@ function discoveredResponse(scope: McpSettingsScope) {
}; };
} }
function mockFetch(statusByName: Record<string, { status: "valid" | "unreachable" | "error"; message: string }> = {}, discoveryByScope?: Partial<Record<McpSettingsScope, unknown>>) { function mockFetch(statusByName: Record<string, { status: "valid" | "unreachable" | "error"; message: string }> = {}, discoveryByScope?: Partial<Record<McpSettingsScope, unknown>>, pluginServers: unknown[] = []) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (url.startsWith("/api/mcp/plugin-servers")) {
return new Response(JSON.stringify({ servers: pluginServers }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/mcp/discovered")) { if (url.startsWith("/api/mcp/discovered")) {
const scope = (new URL(url, "https://fusion.test").searchParams.get("scope") === "global" ? "global" : "project") as McpSettingsScope; const scope = (new URL(url, "https://fusion.test").searchParams.get("scope") === "global" ? "global" : "project") as McpSettingsScope;
return new Response(JSON.stringify(discoveryByScope?.[scope] ?? { sources: [], servers: [], errors: [] }), { status: 200, headers: { "Content-Type": "application/json" } }); return new Response(JSON.stringify(discoveryByScope?.[scope] ?? { sources: [], servers: [], errors: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
@@ -71,7 +74,7 @@ function expectButtonIconSize(button: HTMLElement, size: "14" | "16") {
expect(icon).toHaveAttribute("height", size); expect(icon).toHaveAttribute("height", size);
} }
function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick<GlobalSettings, "mcpServers"> | null }) { function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick<GlobalSettings, "mcpServers"> | null; pluginServers?: Array<{ pluginId: string; server: { name: string; transport: "stdio"; command: string; enabledByDefault?: boolean } }> }) {
let currentForm: Settings = options.form ?? ({} as Settings); let currentForm: Settings = options.form ?? ({} as Settings);
const addToast = vi.fn(); const addToast = vi.fn();
function Harness() { function Harness() {
@@ -82,6 +85,7 @@ function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalS
scope={options.scope} scope={options.scope}
form={form} form={form}
globalSettings={options.globalSettings} globalSettings={options.globalSettings}
pluginServers={options.pluginServers}
addToast={addToast} addToast={addToast}
setForm={(next) => { setForm={(next) => {
setFormState((previous) => { setFormState((previous) => {
@@ -151,6 +155,15 @@ describe("MCP Settings UI", () => {
expect(screen.getByText("No MCP servers configured.")).toBeInTheDocument(); expect(screen.getByText("No MCP servers configured.")).toBeInTheDocument();
}); });
it("loads active-project plugin servers into the project MCP card", async () => {
mockFetch({}, undefined, [{ pluginId: "roslyn", server: { name: "navigator", transport: "stdio", command: "plugin-command" } }]);
render(<ProjectMcpSection form={{ mcpServers: { enabled: true, servers: [] } } as Settings} setForm={vi.fn()} globalSettings={{ mcpServers: { enabled: true, servers: [] } }} projectId="project-a" addToast={vi.fn()} />);
const row = await screen.findByTestId("mcp-server-row-navigator");
expect(row).toHaveTextContent("plugin-command");
expect(screen.getByTestId("mcp-plugin-provenance-navigator")).toHaveTextContent("plugin:roslyn");
});
it.each(["global", "project"] as const)("sizes MCP card inline button icons in %s scope", async (scope) => { it.each(["global", "project"] as const)("sizes MCP card inline button icons in %s scope", async (scope) => {
mockFetch({}, { [scope]: discoveredResponse(scope) }); mockFetch({}, { [scope]: discoveredResponse(scope) });
renderCard({ renderCard({
@@ -224,6 +237,23 @@ describe("MCP Settings UI", () => {
expect(screen.getByTestId("mcp-server-row-local-only")).toHaveTextContent("project local"); expect(screen.getByTestId("mcp-server-row-local-only")).toHaveTextContent("project local");
}); });
it("displays the plugin winner when plugin and global servers share a name", async () => {
const { getForm } = renderCard({
scope: "project",
form: {} as Settings,
globalSettings: { mcpServers: { enabled: true, servers: [{ name: "navigator", transport: "stdio", command: "global-command" }] } },
pluginServers: [{ pluginId: "roslyn", server: { name: "navigator", transport: "stdio", command: "plugin-command" } }],
});
const row = await screen.findByTestId("mcp-server-row-navigator");
expect(row).toHaveTextContent("plugin-command");
expect(row).not.toHaveTextContent("global-command");
expect(row).toHaveTextContent("plugin");
expect(screen.getByTestId("mcp-plugin-provenance-navigator")).toHaveTextContent("plugin:roslyn");
fireEvent.click(within(row).getByRole("button", { name: /Disable/i }));
expect(getForm().mcpServers?.servers).toEqual([{ name: "navigator", enabled: false, transport: "stdio", command: "plugin-command" }]);
});
it("validates servers and renders valid, unreachable, and error status surfaces", async () => { it("validates servers and renders valid, unreachable, and error status surfaces", async () => {
mockFetch({ mockFetch({
ok: { status: "valid", message: "probe ok" }, ok: { status: "valid", message: "probe ok" },

View File

@@ -8,6 +8,7 @@ import {
exportMcpServersJson, exportMcpServersJson,
importMcpServersJson, importMcpServersJson,
isMcpSecretRef, isMcpSecretRef,
mapPluginMcpServerContribution,
resolveEffectiveMcpServers, resolveEffectiveMcpServers,
validateMcpServerDefinitionDetailed, validateMcpServerDefinitionDetailed,
validateMcpServerDefinitionsDetailed, validateMcpServerDefinitionsDetailed,
@@ -15,6 +16,7 @@ import {
type McpSecretRef, type McpSecretRef,
type McpServerDefinition, type McpServerDefinition,
type McpServersSettings, type McpServersSettings,
type PluginMcpServerContribution,
type Settings, type Settings,
} from "@fusion/core"; } from "@fusion/core";
@@ -23,7 +25,7 @@ type ToastKind = "info" | "success" | "error";
type SecretScope = "project" | "global"; type SecretScope = "project" | "global";
type Transport = McpServerDefinition["transport"]; type Transport = McpServerDefinition["transport"];
type ValidationStatus = "idle" | "pending" | "valid" | "unreachable" | "error"; type ValidationStatus = "idle" | "pending" | "valid" | "unreachable" | "error";
type DisplayState = "configured" | "disabled" | "inherited" | "overridden" | "project-local" | "disabled-global"; type DisplayState = "configured" | "disabled" | "inherited" | "overridden" | "project-local" | "disabled-global" | "plugin" | "plugin-overridden" | "plugin-disabled";
type FormSetter = Dispatch<SetStateAction<Settings>>; type FormSetter = Dispatch<SetStateAction<Settings>>;
@@ -92,6 +94,8 @@ export interface McpServersCardProps {
setForm: FormSetter; setForm: FormSetter;
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null; globalSettings?: Pick<GlobalSettings, "mcpServers"> | null;
projectId?: string; projectId?: string;
/** Already project-scoped contributions supplied by the API/provider. */
pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>;
addToast: (message: string, type?: ToastKind) => void; addToast: (message: string, type?: ToastKind) => void;
} }
@@ -231,6 +235,8 @@ function getValidateDotClass(status: ValidationStatus): string {
function getStateLabel(state: DisplayState): string { function getStateLabel(state: DisplayState): string {
if (state === "disabled-global") return "disabled global"; if (state === "disabled-global") return "disabled global";
if (state === "project-local") return "project local"; if (state === "project-local") return "project local";
if (state === "plugin-overridden") return "plugin overridden";
if (state === "plugin-disabled") return "plugin disabled";
return state; return state;
} }
@@ -245,9 +251,13 @@ function getValidationLabel(status: ValidationStatus): string {
* MCP settings are edited through one card for global and project scopes. Sensitive env/header/token-like values are modeled only as Fusion secret references; this component never writes plaintext sensitive values into the settings form. * MCP settings are edited through one card for global and project scopes. Sensitive env/header/token-like values are modeled only as Fusion secret references; this component never writes plaintext sensitive values into the settings form.
* *
* FNXC:McpConfig 2026-06-26-01:17: * FNXC:McpConfig 2026-06-26-01:17:
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 renders plugin provenance only for the project card. Global settings
* remain plugin-free; project actions persist only local overrides or tombstones.
*
* 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. * 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, addToast }: McpServersCardProps) { export function McpServersCard({ scope, form, setForm, globalSettings, projectId, pluginServers = [], addToast }: McpServersCardProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS); const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS);
@@ -300,9 +310,14 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
}, [scanDiscoveredServers]); }, [scanDiscoveredServers]);
const effectiveServers = useMemo( const effectiveServers = useMemo(
() => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }) : configuredServers.filter((server) => server.enabled !== false), () => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }, pluginServers) : configuredServers.filter((server) => server.enabled !== false),
[configuredServers, form.mcpServers, globalMcp, scope], [configuredServers, form.mcpServers, globalMcp, pluginServers, scope],
); );
const pluginByName = useMemo(() => new Map(pluginServers
.filter((entry) => entry.server.enabledByDefault !== false)
.map((entry) => ({ ...entry, definition: mapPluginMcpServerContribution(entry.server) }))
.filter((entry): entry is { pluginId: string; server: PluginMcpServerContribution; definition: McpServerDefinition } => Boolean(entry.definition))
.map((entry) => [entry.definition.name, entry])), [pluginServers]);
const globalByName = useMemo(() => new Map(globalServers.map((server) => [server.name, server])), [globalServers]); const globalByName = useMemo(() => new Map(globalServers.map((server) => [server.name, server])), [globalServers]);
const projectByName = useMemo(() => new Map(configuredServers.map((server) => [server.name, server])), [configuredServers]); const projectByName = useMemo(() => new Map(configuredServers.map((server) => [server.name, server])), [configuredServers]);
@@ -321,21 +336,28 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
if (scope === "global") return configuredServers.map((server): { server: McpServerDefinition; state: DisplayState } => ({ server, state: server.enabled === false ? "disabled" : "configured" })); if (scope === "global") return configuredServers.map((server): { server: McpServerDefinition; state: DisplayState } => ({ server, state: server.enabled === false ? "disabled" : "configured" }));
const effectiveByName = new Set(effectiveServers.map((server) => server.name)); const effectiveByName = new Set(effectiveServers.map((server) => server.name));
const rows: Array<{ server: McpServerDefinition; state: DisplayState }> = []; const rows: Array<{ server: McpServerDefinition; state: DisplayState }> = [];
for (const globalServer of globalServers) { // FNXC:PluginMcpServers 2026-07-22-12:00:
const projectServer = projectByName.get(globalServer.name); // The project card must display the same global → plugin → project winner
// 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 });
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);
if (projectServer?.enabled === false) { if (projectServer?.enabled === false) {
rows.push({ server: projectServer, state: "disabled-global" }); rows.push({ server: projectServer, state: inherited.plugin ? "plugin-disabled" : "disabled-global" });
} else if (projectServer) { } else if (projectServer) {
rows.push({ server: projectServer, state: effectiveByName.has(projectServer.name) ? "overridden" : "disabled" }); rows.push({ server: projectServer, state: inherited.plugin ? "plugin-overridden" : effectiveByName.has(name) ? "overridden" : "disabled" });
} else { } else {
rows.push({ server: globalServer, state: effectiveByName.has(globalServer.name) ? "inherited" : "disabled" }); rows.push({ server: inherited.server, state: inherited.plugin ? (effectiveByName.has(name) ? "plugin" : "plugin-disabled") : (effectiveByName.has(name) ? "inherited" : "disabled") });
} }
} }
for (const server of configuredServers) { for (const server of configuredServers) {
if (!globalByName.has(server.name)) rows.push({ server, state: server.enabled === false || !effectiveByName.has(server.name) ? "disabled" : "project-local" }); if (!inheritedByName.has(server.name)) rows.push({ server, state: server.enabled === false || !effectiveByName.has(server.name) ? "disabled" : "project-local" });
} }
return rows; return rows;
}, [configuredServers, effectiveServers, globalByName, globalServers, projectByName, scope]); }, [configuredServers, effectiveServers, globalServers, pluginByName, projectByName, scope]);
const updateMcpSettings = (next: McpServersSettings) => { const updateMcpSettings = (next: McpServersSettings) => {
setForm((current) => ({ ...current, mcpServers: next })); setForm((current) => ({ ...current, mcpServers: next }));
@@ -395,7 +417,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
}; };
const disableInheritedServer = (name: string) => { const disableInheritedServer = (name: string) => {
const inherited = globalByName.get(name); const inherited = pluginByName.get(name)?.definition ?? globalByName.get(name);
const tombstone: McpServerDefinition = inherited?.transport === "sse" || inherited?.transport === "streamable-http" const tombstone: McpServerDefinition = inherited?.transport === "sse" || inherited?.transport === "streamable-http"
? { name, enabled: false, transport: inherited.transport, url: inherited.url } ? { name, enabled: false, transport: inherited.transport, url: inherited.url }
: { name, enabled: false, transport: "stdio", command: inherited?.transport === "stdio" ? inherited.command : "disabled" }; : { name, enabled: false, transport: "stdio", command: inherited?.transport === "stdio" ? inherited.command : "disabled" };
@@ -591,7 +613,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
<div className="mcp-server-row__main"> <div className="mcp-server-row__main">
<div className="mcp-server-row__titleline"> <div className="mcp-server-row__titleline">
<strong>{server.name}</strong> <strong>{server.name}</strong>
<span className={`mcp-state-badge mcp-state-badge--${state}`} data-state={state}>{getStateLabel(state)}</span> <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> <span className="mcp-transport-badge">{server.transport}</span>
</div> </div>
<p>{serverSummary(server)}</p> <p>{serverSummary(server)}</p>
@@ -599,8 +621,8 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
</div> </div>
<div className="mcp-server-row__actions"> <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> <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" ? <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-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" ? <button type="button" className="btn btn-warning btn-sm touch-target" onClick={() => disableInheritedServer(server.name)}>{t("settings.mcp.disableInherited", "Disable")}</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-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 ? <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> </div>

View File

@@ -1,6 +1,7 @@
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { GlobalSettings, Settings } from "@fusion/core"; import type { GlobalSettings, PluginMcpServerContribution, Settings } from "@fusion/core";
import type { ToastType } from "../../../hooks/useToast"; import type { ToastType } from "../../../hooks/useToast";
import { McpServersCard } from "./McpServersCard"; import { McpServersCard } from "./McpServersCard";
@@ -9,15 +10,39 @@ export interface ProjectMcpSectionProps {
setForm: Dispatch<SetStateAction<Settings>>; setForm: Dispatch<SetStateAction<Settings>>;
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null; globalSettings?: Pick<GlobalSettings, "mcpServers"> | null;
projectId?: string; projectId?: string;
/** Project-scoped plugin entries; global MCP settings never receive these. */
pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
} }
export function ProjectMcpSection({ form, setForm, globalSettings, projectId, addToast }: ProjectMcpSectionProps) { export function ProjectMcpSection({ form, setForm, globalSettings, projectId, pluginServers, addToast }: ProjectMcpSectionProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [loadedPluginServers, setLoadedPluginServers] = useState<Array<{ pluginId: string; server: PluginMcpServerContribution }>>([]);
useEffect(() => {
if (pluginServers) return;
const controller = new AbortController();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
/*
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 obtains settings-card contributions only from the active-project
* endpoint, which owns project_plugin_states filtering. Rendering never
* writes declarations and global MCP settings never request this endpoint.
*/
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 }> }>;
})
.then((payload) => { if (!controller.signal.aborted) setLoadedPluginServers(Array.isArray(payload.servers) ? payload.servers : []); })
.catch(() => { if (!controller.signal.aborted) setLoadedPluginServers([]); });
return () => controller.abort();
}, [pluginServers, projectId]);
return ( return (
<> <>
<h4 className="settings-section-heading">{t("settings.nav.mcp", "MCP Servers")}</h4> <h4 className="settings-section-heading">{t("settings.nav.mcp", "MCP Servers")}</h4>
<McpServersCard scope="project" form={form} setForm={setForm} globalSettings={globalSettings} projectId={projectId} addToast={addToast} /> <McpServersCard scope="project" form={form} setForm={setForm} globalSettings={globalSettings} projectId={projectId} pluginServers={pluginServers ?? loadedPluginServers} addToast={addToast} />
</> </>
); );
} }

View File

@@ -7,12 +7,16 @@ import { request } from "../../test-request.js";
import { registerConfigMcpPiSettingsRoutes } from "../register-config-mcp-pi-settings-routes.js"; import { registerConfigMcpPiSettingsRoutes } from "../register-config-mcp-pi-settings-routes.js";
import type { ApiRoutesContext } from "../types.js"; import type { ApiRoutesContext } from "../types.js";
function createApp(settings = { maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 2 }) { function createApp(
settings = { maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 2 },
pluginServers: Array<{ pluginId: string; server: unknown }> = [],
) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
const store = { const store = {
getRootDir: () => "/workspace", getRootDir: () => "/workspace",
getSettingsFast: async () => settings, getSettingsFast: async () => settings,
getProjectScopedPluginMcpServers: async () => pluginServers,
}; };
const context = { const context = {
router: app, router: app,
@@ -45,6 +49,18 @@ describe("registerConfigMcpPiSettingsRoutes", () => {
expect(response.body).toEqual({ maxConcurrent: 9, maxTriageConcurrent: 2, maxWorktrees: 4, rootDir: "/workspace" }); expect(response.body).toEqual({ maxConcurrent: 9, maxTriageConcurrent: 2, maxWorktrees: 4, rootDir: "/workspace" });
}); });
it("lists only provider-filtered valid project plugin MCP contributions", async () => {
const response = await request(createApp(undefined, [
{ pluginId: "enabled", server: { name: "navigator", transport: "stdio", command: "roslyn" } },
{ pluginId: "malformed", server: null },
]), "GET", "/mcp/plugin-servers?projectId=project-a");
expect(response.status).toBe(200);
expect(response.body).toEqual({ servers: [
{ pluginId: "enabled", server: { name: "navigator", transport: "stdio", command: "roslyn" } },
] });
});
it("rejects malformed MCP validation bodies", async () => { it("rejects malformed MCP validation bodies", async () => {
const response = await request(createApp(), "POST", "/mcp/validate", JSON.stringify({ timeoutMs: 1000 }), { "content-type": "application/json" }); const response = await request(createApp(), "POST", "/mcp/validate", JSON.stringify({ timeoutMs: 1000 }), { "content-type": "application/json" });

View File

@@ -1,6 +1,11 @@
import { Router, type Request } from "express"; import { Router, type Request } from "express";
import { resolve, sep } from "node:path"; import { resolve, sep } from "node:path";
import type { TaskStore } from "@fusion/core"; import {
createProjectScopedPluginMcpProvider,
PluginLoader,
type PluginStore,
type TaskStore,
} from "@fusion/core";
import type { ServerOptions } from "../server.js"; import type { ServerOptions } from "../server.js";
import { ApiError, internalError } from "../api-error.js"; import { ApiError, internalError } from "../api-error.js";
import { getOrCreateProjectStore } from "../project-store-resolver.js"; import { getOrCreateProjectStore } from "../project-store-resolver.js";
@@ -279,7 +284,69 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
} }
const resolveScopedStore = (req: Request): Promise<TaskStore> => getScopedStore(req, store, options); const resolveScopedStore = (req: Request): Promise<TaskStore> => getScopedStore(req, store, options);
const resolveProjectContext = (req: Request): Promise<ProjectContext> => getProjectContext(req, store, options); const fallbackMcpLoaders = new WeakMap<TaskStore, { loader: PluginLoader; initialized: Promise<void> }>();
const projectMcpProviders = new WeakMap<PluginLoader, ReturnType<typeof createProjectScopedPluginMcpProvider>>();
const bindProjectScopedPluginMcpProvider = async (context: ProjectContext): Promise<void> => {
const scopedStore = context.store as TaskStore & {
getProjectScopedPluginMcpServers?: () => Promise<unknown>;
};
// A live runtime already owns the provider for its TaskStore. Dashboard-only
// contexts install the same core provider below rather than falling back to
// raw plugin enumeration.
if (typeof scopedStore.getProjectScopedPluginMcpServers === "function") return;
const engineLoader = (context.engine as { getPluginRunner?: () => { getLoader?: () => PluginLoader } | undefined } | undefined)
?.getPluginRunner?.()?.getLoader?.();
// The host loader is valid only for the TaskStore that owns its PluginStore;
// another root needs its own non-persisting loader before the core provider
// filters enabled IDs.
const hostLoader = context.store.getPluginStore() === options?.pluginStore ? options?.pluginLoader : undefined;
let loader = engineLoader ?? hostLoader;
if (!loader) {
let fallback = fallbackMcpLoaders.get(context.store);
if (!fallback) {
const fallbackLoader = new PluginLoader({
pluginStore: context.store.getPluginStore() as PluginStore,
taskStore: context.store,
});
fallback = {
loader: fallbackLoader,
initialized: context.store.getPluginStore().init().then(() => fallbackLoader.loadAllPlugins()).then(() => undefined),
};
fallbackMcpLoaders.set(context.store, fallback);
}
await fallback.initialized;
loader = fallback.loader;
}
let provider = projectMcpProviders.get(loader);
if (!provider) {
provider = createProjectScopedPluginMcpProvider({
hostRootDir: context.store.getRootDir(),
hostLoader: loader,
createScopedLoader: (otherStore) => new PluginLoader({
pluginStore: otherStore.getPluginStore() as PluginStore,
taskStore: otherStore as TaskStore,
}),
});
projectMcpProviders.set(loader, provider);
}
scopedStore.getProjectScopedPluginMcpServers = () => provider.get(context.store);
/*
* FNXC:PluginMcpServers 2026-07-22-15:35:
* FN-8491 / #2401 makes API and dashboard session contexts project-aware.
* Every non-runtime scoped store receives the same core provider so an
* active project other than the host cannot silently resolve no plugin MCP
* servers or inherit a different project's enabled-plugin state.
*/
};
const resolveProjectContext = async (req: Request): Promise<ProjectContext> => {
const context = await getProjectContext(req, store, options);
await bindProjectScopedPluginMcpProvider(context);
return context;
};
const disposeCallbacks: Array<() => void> = []; const disposeCallbacks: Array<() => void> = [];
function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void { function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void {

View File

@@ -1,5 +1,5 @@
import type { McpServerDefinition, TaskStore } from "@fusion/core"; import type { McpServerDefinition, PluginMcpServerContribution, TaskStore } from "@fusion/core";
import { validateMcpServerDefinitionDetailed } from "@fusion/core"; import { mapPluginMcpServerContribution, validateMcpServerDefinitionDetailed } from "@fusion/core";
import { import {
discoverMcpServers, discoverMcpServers,
resolveMcpServersForRuntime, resolveMcpServersForRuntime,
@@ -108,12 +108,36 @@ export const registerConfigMcpPiSettingsRoutes: ApiRouteRegistrar = (ctx) => {
const settings = await scopedStore.getSettingsFast(); const settings = await scopedStore.getSettingsFast();
res.json({ res.json({
maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2, maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2,
maxTriageConcurrent: settings.maxTriageConcurrent ?? 2,
maxWorktrees: settings.maxWorktrees ?? 4, maxWorktrees: settings.maxWorktrees ?? 4,
rootDir: scopedStore.getRootDir(), rootDir: scopedStore.getRootDir(),
}); });
} catch { } catch {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() }); res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxTriageConcurrent: 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() });
}
});
router.get("/mcp/plugin-servers", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const provider = scopedStore as TaskStore & {
getProjectScopedPluginMcpServers?: () => Promise<Array<{ pluginId: string; server: PluginMcpServerContribution }>> | Array<{ pluginId: string; server: PluginMcpServerContribution }>;
};
// FNXC:PluginMcpServers 2026-07-22-12:00:
// FN-8491 exposes only the active project's provider-filtered entries.
// This route must not enumerate a raw loader/runner because its output is
// not constrained by project_plugin_states and could leak across projects.
const entries = typeof provider.getProjectScopedPluginMcpServers === "function"
? await provider.getProjectScopedPluginMcpServers()
: [];
res.json({
servers: entries
.filter((entry) => mapPluginMcpServerContribution(entry?.server))
.map((entry) => ({ pluginId: entry.pluginId, server: entry.server })),
});
} catch (error) {
rethrowAsApiError(error, "Failed to load project plugin MCP servers");
} }
}); });

View File

@@ -63,6 +63,26 @@ describe("resolveMcpServersForRuntime", () => {
]); ]);
}); });
it("uses only provider-filtered plugin contributions before materialization", async () => {
const result = await resolveMcpServersForRuntime({
globalSettings: { mcpServers: { enabled: true } }, projectSettings: { mcpServers: { enabled: true } },
pluginServers: [
{ pluginId: "enabled-plugin", server: { name: "navigator", transport: "stdio", command: "navigator", env: { TOKEN: { secretRef: "token", scope: "project" } } } },
], secrets: secrets({ token: "SECRET_VALUE" }),
});
expect(result).toEqual({ servers: [{ name: "navigator", transport: "stdio", command: "navigator", env: { TOKEN: "SECRET_VALUE" } }], errors: [] });
});
it("uses the store scoped provider rather than requiring raw loader output", async () => {
const { resolveMcpServersForStore } = await import("../mcp-resolution.js");
const result = await resolveMcpServersForStore({
async getSettingsByScope() { return { global: { mcpServers: { enabled: true } }, project: { mcpServers: { enabled: true } } }; },
async getSecretsStore() { return secrets({}); },
async getProjectScopedPluginMcpServers() { return [{ pluginId: "enabled", server: { name: "scoped", transport: "stdio" as const, command: "scoped" } }]; },
});
expect(result.servers).toEqual([{ name: "scoped", transport: "stdio", command: "scoped" }]);
});
it("resolves through the TaskStore-compatible settings split seam", async () => { it("resolves through the TaskStore-compatible settings split seam", async () => {
const { resolveMcpServersForStore } = await import("../mcp-resolution.js"); const { resolveMcpServersForStore } = await import("../mcp-resolution.js");
const result = await resolveMcpServersForStore({ const result = await resolveMcpServersForStore({

View File

@@ -6,6 +6,7 @@ import {
type McpSecretReaderIdentity, type McpSecretReaderIdentity,
type McpSecretResolutionError, type McpSecretResolutionError,
type ProjectSettings, type ProjectSettings,
type PluginMcpServerContribution,
type ResolvedMcpServerDefinition, type ResolvedMcpServerDefinition,
} from "@fusion/core"; } from "@fusion/core";
@@ -14,6 +15,8 @@ export interface ResolveMcpServersForRuntimeOptions {
projectSettings?: Pick<ProjectSettings, "mcpServers"> | null; projectSettings?: Pick<ProjectSettings, "mcpServers"> | null;
secrets: McpSecretReader; secrets: McpSecretReader;
reader?: McpSecretReaderIdentity; reader?: McpSecretReaderIdentity;
/** Already project-scoped plugin entries. Raw runner/loader output is forbidden here. */
pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>;
} }
export interface ResolvedMcpServersForRuntime { export interface ResolvedMcpServersForRuntime {
@@ -23,12 +26,16 @@ export interface ResolvedMcpServersForRuntime {
/** /**
* FNXC:McpConfig 2026-06-25-21:43: * FNXC:McpConfig 2026-06-25-21:43:
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 accepts only entries that the shared project-scoped provider already
* filtered by project_plugin_states; raw loader/runner output must never cross this seam.
*
* Runtime MCP forwarding uses Fusion's trusted-once-enabled model: enabled effective servers are materialized once at session/probe creation and then forwarded without per-call prompts. Plaintext env/header values exist only in this in-memory return value and callers must log only counts/errors, never server contents. * Runtime MCP forwarding uses Fusion's trusted-once-enabled model: enabled effective servers are materialized once at session/probe creation and then forwarded without per-call prompts. Plaintext env/header values exist only in this in-memory return value and callers must log only counts/errors, never server contents.
*/ */
export async function resolveMcpServersForRuntime( export async function resolveMcpServersForRuntime(
options: ResolveMcpServersForRuntimeOptions, options: ResolveMcpServersForRuntimeOptions,
): Promise<ResolvedMcpServersForRuntime> { ): Promise<ResolvedMcpServersForRuntime> {
const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings); const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings, options.pluginServers);
if (effective.length === 0) return { servers: [], errors: [] }; if (effective.length === 0) return { servers: [], errors: [] };
const materialized = await materializeMcpServersSecrets( const materialized = await materializeMcpServersSecrets(
@@ -52,6 +59,8 @@ export interface McpSettingsAndSecretsStore {
project: Partial<Pick<ProjectSettings, "mcpServers">>; project: Partial<Pick<ProjectSettings, "mcpServers">>;
}>; }>;
getSecretsStore?(): Promise<McpSecretReader> | McpSecretReader; getSecretsStore?(): Promise<McpSecretReader> | McpSecretReader;
/** Shared provider hook; implementations must filter project_plugin_states. */
getProjectScopedPluginMcpServers?(): Promise<Array<{ pluginId: string; server: PluginMcpServerContribution }>> | Array<{ pluginId: string; server: PluginMcpServerContribution }>;
} }
const emptyMcpSecretReader: McpSecretReader = { const emptyMcpSecretReader: McpSecretReader = {
@@ -72,14 +81,16 @@ export async function resolveMcpServersForStore(
return { servers: [], errors: [] }; return { servers: [], errors: [] };
} }
const [settings, secrets] = await Promise.all([ const [settings, secrets, pluginServers] = await Promise.all([
store.getSettingsByScope(), store.getSettingsByScope(),
typeof store.getSecretsStore === "function" ? store.getSecretsStore() : emptyMcpSecretReader, typeof store.getSecretsStore === "function" ? store.getSecretsStore() : emptyMcpSecretReader,
typeof store.getProjectScopedPluginMcpServers === "function" ? store.getProjectScopedPluginMcpServers() : [],
]); ]);
return resolveMcpServersForRuntime({ return resolveMcpServersForRuntime({
globalSettings: settings.global, globalSettings: settings.global,
projectSettings: settings.project, projectSettings: settings.project,
secrets, secrets,
reader, reader,
pluginServers,
}); });
} }

View File

@@ -20,6 +20,7 @@ import type {
CliProviderContribution, CliProviderContribution,
PluginContext, PluginContext,
PluginSkillContribution, PluginSkillContribution,
PluginMcpServerContribution,
PluginWorkflowStepContribution, PluginWorkflowStepContribution,
WorkflowExtensionContribution, WorkflowExtensionContribution,
PluginTraitContribution, PluginTraitContribution,
@@ -143,6 +144,11 @@ interface CachedWorkflowStepTemplates {
version: number; version: number;
} }
interface CachedMcpServers {
servers: Array<{ pluginId: string; server: PluginMcpServerContribution }>;
version: number;
}
interface CachedTraits { interface CachedTraits {
traits: Array<{ pluginId: string; trait: PluginTraitContribution }>; traits: Array<{ pluginId: string; trait: PluginTraitContribution }>;
version: number; version: number;
@@ -173,6 +179,7 @@ export class PluginRunner {
private cachedRuntimes: CachedRuntimes | null = null; private cachedRuntimes: CachedRuntimes | null = null;
private cachedCliProviderContributions: CachedCliProviderContributions | null = null; private cachedCliProviderContributions: CachedCliProviderContributions | null = null;
private cachedSkills: CachedSkills | null = null; private cachedSkills: CachedSkills | null = null;
private cachedMcpServers: CachedMcpServers | null = null;
private cachedWorkflowSteps: CachedWorkflowSteps | null = null; private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
private cachedWorkflowExtensions: CachedWorkflowExtensions | null = null; private cachedWorkflowExtensions: CachedWorkflowExtensions | null = null;
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null; private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
@@ -186,6 +193,7 @@ export class PluginRunner {
private runtimesCacheVersion = 0; private runtimesCacheVersion = 0;
private cliProviderContributionsCacheVersion = 0; private cliProviderContributionsCacheVersion = 0;
private skillsCacheVersion = 0; private skillsCacheVersion = 0;
private mcpServersCacheVersion = 0;
private workflowStepsCacheVersion = 0; private workflowStepsCacheVersion = 0;
private workflowExtensionsCacheVersion = 0; private workflowExtensionsCacheVersion = 0;
private workflowStepTemplatesCacheVersion = 0; private workflowStepTemplatesCacheVersion = 0;
@@ -267,6 +275,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -399,6 +408,21 @@ export class PluginRunner {
return this.cachedSkills.skills; return this.cachedSkills.skills;
} }
/**
* Raw contributions only. The project-scoped provider must filter these by
* project plugin state before they enter MCP resolution.
*
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 mirrors the skill cache while preventing runner output from being
* mistaken for project-scoped configuration.
*/
getPluginMcpServers(): Array<{ pluginId: string; server: PluginMcpServerContribution }> {
if (!this.cachedMcpServers || this.cachedMcpServers.version !== this.mcpServersCacheVersion) {
this.cachedMcpServers = { servers: this.options.pluginLoader.getPluginMcpServers(), version: this.mcpServersCacheVersion };
}
return this.cachedMcpServers.servers;
}
getPluginWorkflowSteps(): Array<{ pluginId: string; step: PluginWorkflowStepContribution }> { getPluginWorkflowSteps(): Array<{ pluginId: string; step: PluginWorkflowStepContribution }> {
if (!this.cachedWorkflowSteps || this.cachedWorkflowSteps.version !== this.workflowStepsCacheVersion) { if (!this.cachedWorkflowSteps || this.cachedWorkflowSteps.version !== this.workflowStepsCacheVersion) {
this.cachedWorkflowSteps = { this.cachedWorkflowSteps = {
@@ -929,6 +953,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -952,6 +977,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -980,6 +1006,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -1008,6 +1035,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -1035,6 +1063,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -1054,6 +1083,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -1073,6 +1103,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -1092,6 +1123,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -1111,6 +1143,7 @@ export class PluginRunner {
this.invalidateRuntimesCache(); this.invalidateRuntimesCache();
this.invalidateCliProviderContributionsCache(); this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache(); this.invalidateSkillsCache();
this.invalidateMcpServersCache();
this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache(); this.invalidateWorkflowStepTemplatesCache();
@@ -1354,6 +1387,11 @@ export class PluginRunner {
this.log.log(`Skills cache invalidated (version: ${this.skillsCacheVersion})`); this.log.log(`Skills cache invalidated (version: ${this.skillsCacheVersion})`);
} }
private invalidateMcpServersCache(): void {
this.mcpServersCacheVersion++;
this.log.log(`MCP servers cache invalidated (version: ${this.mcpServersCacheVersion})`);
}
private invalidateWorkflowStepsCache(): void { private invalidateWorkflowStepsCache(): void {
this.workflowStepsCacheVersion++; this.workflowStepsCacheVersion++;
this.log.log(`Workflow steps cache invalidated (version: ${this.workflowStepsCacheVersion})`); this.log.log(`Workflow steps cache invalidated (version: ${this.workflowStepsCacheVersion})`);

View File

@@ -395,6 +395,7 @@ export class InProcessRuntime
// InProcessRuntime.start(). When the factory returns a backend result, // InProcessRuntime.start(). When the factory returns a backend result,
// the engine owns the result's shutdown() for process teardown. // the engine owns the result's shutdown() for process teardown.
createTaskStoreForBackend, createTaskStoreForBackend,
createProjectScopedPluginMcpProvider,
} = await import("@fusion/core"); } = await import("@fusion/core");
if (this.config.externalTaskStore) { if (this.config.externalTaskStore) {
this.taskStore = this.config.externalTaskStore; this.taskStore = this.config.externalTaskStore;
@@ -477,6 +478,28 @@ export class InProcessRuntime
rootDir: this.config.workingDirectory, rootDir: this.config.workingDirectory,
}); });
await this.pluginRunner.init(); await this.pluginRunner.init();
/*
* FNXC:PluginMcpServers 2026-07-22-12:00:
* FN-8491 installs the sole session-facing provider on the project store.
* resolveMcpServersForStore consumes this filtered seam across every AI
* lane; it never sees PluginRunner's raw contribution list.
*/
const projectScopedPluginMcpProvider = createProjectScopedPluginMcpProvider({
hostRootDir: this.config.workingDirectory,
hostLoader: this.pluginLoader,
createScopedLoader: (scopedStore) => new PluginLoaderClass({
pluginStore: scopedStore.getPluginStore() as PluginStore,
taskStore: scopedStore as TaskStore,
}),
});
(this.taskStore as TaskStore & { getProjectScopedPluginMcpServers?: () => Promise<ReturnType<PluginRunner["getPluginMcpServers"]>> }).getProjectScopedPluginMcpServers = () => projectScopedPluginMcpProvider.get(this.taskStore);
/*
* FNXC:PluginMcpServers 2026-07-22-15:35:
* FN-8491 / #2401 requires the runtime seam to use the core provider,
* not an ad-hoc enabled-ID filter. The provider owns same-root caching
* and non-persisting other-root discovery so all project contexts retain
* their own plugin enablement state.
*/
runtimeLog.log(`PluginRunner initialized`); runtimeLog.log(`PluginRunner initialized`);
await yieldEventLoop(); await yieldEventLoop();

View File

@@ -79,6 +79,7 @@ export type {
PluginContext, PluginContext,
PluginLogger, PluginLogger,
PluginSkillContribution, PluginSkillContribution,
PluginMcpServerContribution,
PluginWorkflowStepContribution, PluginWorkflowStepContribution,
PluginTraitContribution, PluginTraitContribution,
PluginTraitHookDescriptor, PluginTraitHookDescriptor,