From 3cd023fa43a63f51f008912c90edcc52c66f9597 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 15:25:20 -0700 Subject: [PATCH] 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) --- .changeset/plugin-mcp-servers.md | 7 ++ docs/PLUGIN_AUTHORING.md | 17 ++++ docs/mcp.md | 4 + docs/settings-reference.md | 4 + .../core/src/__tests__/mcp-config.test.ts | 33 ++++++++ .../plugin-contribution-types.test.ts | 16 ++++ .../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 +++++++- ...ster-config-mcp-pi-settings-routes.test.ts | 18 ++++- packages/dashboard/src/routes/context.ts | 71 ++++++++++++++++- .../register-config-mcp-pi-settings-routes.ts | 30 ++++++- .../src/__tests__/mcp-resolution.test.ts | 20 +++++ packages/engine/src/mcp-resolution.ts | 15 +++- packages/engine/src/plugin-runner.ts | 38 +++++++++ .../engine/src/runtimes/in-process-runtime.ts | 23 ++++++ packages/plugin-sdk/src/index.ts | 1 + 25 files changed, 563 insertions(+), 36 deletions(-) create mode 100644 .changeset/plugin-mcp-servers.md create mode 100644 packages/core/src/plugin-mcp-servers.ts diff --git a/.changeset/plugin-mcp-servers.md b/.changeset/plugin-mcp-servers.md new file mode 100644 index 0000000000..9dba15693e --- /dev/null +++ b/.changeset/plugin-mcp-servers.md @@ -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. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 749cdfe176..d646c74dbf 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1894,3 +1894,20 @@ const setupHooks: PluginSetupHooks = { ``` `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. diff --git a/docs/mcp.md b/docs/mcp.md index f5b37e3c69..11e0c22097 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. 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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 5b9717bbf6..12cc212cfd 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1806,3 +1806,7 @@ Escalation is enabled only when the toggle is true and either a complete provide ### `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. + +### 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:` and writes only project overrides or tombstones. diff --git a/packages/core/src/__tests__/mcp-config.test.ts b/packages/core/src/__tests__/mcp-config.test.ts index d2cbb83de6..b696f3e70b 100644 --- a/packages/core/src/__tests__/mcp-config.test.ts +++ b/packages/core/src/__tests__/mcp-config.test.ts @@ -39,6 +39,39 @@ describe("MCP core config", () => { ).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", () => { const globalServer: McpServerDefinition = { name: "global-only", diff --git a/packages/core/src/__tests__/plugin-contribution-types.test.ts b/packages/core/src/__tests__/plugin-contribution-types.test.ts index 5e5352f456..260b75f9cc 100644 --- a/packages/core/src/__tests__/plugin-contribution-types.test.ts +++ b/packages/core/src/__tests__/plugin-contribution-types.test.ts @@ -9,6 +9,7 @@ import type { PluginSetupHooks, PluginSetupManifest, PluginSkillContribution, + PluginMcpServerContribution, PluginWorkflowStepContribution, } from "../plugin-types.js"; import type { @@ -127,6 +128,21 @@ describe("plugin contribution type constraints", () => { 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(); + }); + it("accepts prompt surface union and prompt contribution records", () => { const surfaces: PluginPromptSurface[] = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"]; const byPlugin: Record = { diff --git a/packages/core/src/__tests__/plugin-loader-single-load.test.ts b/packages/core/src/__tests__/plugin-loader-single-load.test.ts index 5ae1a1b839..8b78edf01b 100644 --- a/packages/core/src/__tests__/plugin-loader-single-load.test.ts +++ b/packages/core/src/__tests__/plugin-loader-single-load.test.ts @@ -49,6 +49,29 @@ describe("PluginLoader process single-load lifecycle", () => { delete (globalThis as Record).__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 () => { const fixture = await createFixture("async () => { globalThis.__fusionPluginOnLoadCount = (globalThis.__fusionPluginOnLoadCount || 0) + 1; await new Promise(resolve => setTimeout(resolve, 20)); }"); roots.push(fixture.root); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 5cf636dcd1..ea89d7cde3 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -130,11 +130,13 @@ export { } from "./worktrunk-settings.js"; export { resolveEffectiveMcpServers, + mapPluginMcpServerContribution, materializeMcpServerSecrets, materializeMcpServersSecrets, importMcpServersJson, exportMcpServersJson, } from "./mcp-config.js"; +export { createProjectScopedPluginMcpProvider } from "./plugin-mcp-servers.js"; export type { McpSecretReaderIdentity, McpSecretReader, @@ -1252,6 +1254,7 @@ export type { CreateInteractiveAiSessionFactory, PluginLogger, PluginSkillContribution, + PluginMcpServerContribution, PluginWorkflowStepContribution, PluginTraitContribution, PluginTraitHookDescriptor, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 78cea9f0d3..e61f38fd3d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -155,11 +155,13 @@ export { } from "./worktrunk-settings.js"; export { resolveEffectiveMcpServers, + mapPluginMcpServerContribution, materializeMcpServerSecrets, materializeMcpServersSecrets, importMcpServersJson, exportMcpServersJson, } from "./mcp-config.js"; +export { createProjectScopedPluginMcpProvider } from "./plugin-mcp-servers.js"; export type { McpSecretReaderIdentity, McpSecretReader, @@ -1379,6 +1381,7 @@ export type { CreateInteractiveAiSessionFactory, PluginLogger, PluginSkillContribution, + PluginMcpServerContribution, PluginWorkflowStepContribution, PluginTraitContribution, PluginTraitHookDescriptor, diff --git a/packages/core/src/mcp-config.ts b/packages/core/src/mcp-config.ts index aef147595a..ae9957620f 100644 --- a/packages/core/src/mcp-config.ts +++ b/packages/core/src/mcp-config.ts @@ -8,6 +8,7 @@ import type { McpStreamableHttpTransport, ProjectSettings, } from "./types.js"; +import type { PluginMcpServerContribution } from "./plugin-types.js"; import { isMcpSecretRef } from "./types.js"; import type { SecretScope } from "./secrets-store.js"; import { validateMcpServerDefinition } from "./settings-validation.js"; @@ -74,20 +75,31 @@ function normalizeMcpServersSettings(settings?: McpServersSettings): McpServersS } function validServers(settings?: McpServersSettings): McpServerDefinition[] { - return ( - normalizeMcpServersSettings(settings).servers - ?.map(validateMcpServerDefinition) - .filter((server): server is McpServerDefinition => Boolean(server)) ?? [] - ); + 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; + const input = server as Record; + // 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: - * 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( globalSettings?: Pick | null, projectSettings?: Pick | null, + pluginServers: Array<{ pluginId: string; server: PluginMcpServerContribution }> = [], ): McpServerDefinition[] { try { const globalMcp = normalizeMcpServersSettings(globalSettings?.mcpServers); @@ -100,6 +112,19 @@ export function resolveEffectiveMcpServers( if (server.enabled === false) continue; 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)) { if (server.enabled === false) { byName.delete(server.name); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 8632d58962..8476569bda 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -31,6 +31,7 @@ import type { PluginInstallation, PluginManifest, PluginSkillContribution, + PluginMcpServerContribution, PluginWorkflowStepContribution, PluginTraitContribution, PluginPromptContribution, @@ -1523,6 +1524,29 @@ export class PluginLoader extends EventEmitter<{ 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. */ diff --git a/packages/core/src/plugin-mcp-servers.ts b/packages/core/src/plugin-mcp-servers.ts new file mode 100644 index 0000000000..d454422cda --- /dev/null +++ b/packages/core/src/plugin-mcp-servers.ts @@ -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; + listPlugins(filter?: { enabled?: boolean }): Promise>; + }; +} + +export interface ProjectScopedPluginMcpProviderOptions { + hostRootDir: string; + hostLoader: Pick; + /** Creates a non-persisting loader for a project other than the host root. */ + createScopedLoader?: (store: ProjectPluginMcpStore) => Pick; +} + +/** + * 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; +} { + const cache = new Map(); + const normalizedHostRoot = resolvePath(options.hostRootDir); + + return { + async get(store): Promise { + 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(); + } + }, + }; +} diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 4e1b455c38..bc111e9816 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -13,7 +13,7 @@ import type { Database } from "./db.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 { WorkflowExtensionContribution, WorkflowExtensionFallback, @@ -690,6 +690,17 @@ export interface CliProviderContribution { /** * 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 & { + /** Opt out of automatic wiring while remaining available for a project override. */ + enabledByDefault?: boolean; +}; + export interface PluginSkillContribution { /** Unique skill identifier within the plugin namespace (kebab-case). */ skillId: string; @@ -1163,6 +1174,8 @@ export interface FusionPlugin { cliProviders?: CliProviderContribution[]; /** Plugin-contributed skills surfaced by the skill resolver. */ skills?: PluginSkillContribution[]; + /** Declarative MCP servers, scoped to projects where this plugin is enabled. */ + mcpServers?: PluginMcpServerContribution[]; /** Plugin-contributed workflow step templates. */ workflowSteps?: PluginWorkflowStepContribution[]; /** Plugin-contributed column traits (U8). */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1a21fbdb14..a1db3b827d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -27,7 +27,7 @@ export { 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. -export { exportMcpServersJson, importMcpServersJson, resolveEffectiveMcpServers } from "./mcp-config.js"; +export { exportMcpServersJson, importMcpServersJson, mapPluginMcpServerContribution, resolveEffectiveMcpServers } from "./mcp-config.js"; export { DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx index 8435dcdc88..b01ff568af 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx @@ -38,9 +38,12 @@ function discoveredResponse(scope: McpSettingsScope) { }; } -function mockFetch(statusByName: Record = {}, discoveryByScope?: Partial>) { +function mockFetch(statusByName: Record = {}, discoveryByScope?: Partial>, pluginServers: unknown[] = []) { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { 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")) { 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" } }); @@ -71,7 +74,7 @@ function expectButtonIconSize(button: HTMLElement, size: "14" | "16") { expect(icon).toHaveAttribute("height", size); } -function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick | null }) { +function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick | null; pluginServers?: Array<{ pluginId: string; server: { name: string; transport: "stdio"; command: string; enabledByDefault?: boolean } }> }) { let currentForm: Settings = options.form ?? ({} as Settings); const addToast = vi.fn(); function Harness() { @@ -82,6 +85,7 @@ function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalS scope={options.scope} form={form} globalSettings={options.globalSettings} + pluginServers={options.pluginServers} addToast={addToast} setForm={(next) => { setFormState((previous) => { @@ -151,6 +155,15 @@ describe("MCP Settings UI", () => { 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(); + + 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) => { mockFetch({}, { [scope]: discoveredResponse(scope) }); renderCard({ @@ -224,6 +237,23 @@ describe("MCP Settings UI", () => { 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 () => { mockFetch({ ok: { status: "valid", message: "probe ok" }, diff --git a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx index 8f1f53c255..6256ba2423 100644 --- a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx +++ b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx @@ -8,6 +8,7 @@ import { exportMcpServersJson, importMcpServersJson, isMcpSecretRef, + mapPluginMcpServerContribution, resolveEffectiveMcpServers, validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed, @@ -15,6 +16,7 @@ import { type McpSecretRef, type McpServerDefinition, type McpServersSettings, + type PluginMcpServerContribution, type Settings, } from "@fusion/core"; @@ -23,7 +25,7 @@ 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"; +type DisplayState = "configured" | "disabled" | "inherited" | "overridden" | "project-local" | "disabled-global" | "plugin" | "plugin-overridden" | "plugin-disabled"; type FormSetter = Dispatch>; @@ -92,6 +94,8 @@ export interface McpServersCardProps { setForm: FormSetter; globalSettings?: Pick | null; projectId?: string; + /** Already project-scoped contributions supplied by the API/provider. */ + pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>; addToast: (message: string, type?: ToastKind) => void; } @@ -231,6 +235,8 @@ function getValidateDotClass(status: ValidationStatus): string { function getStateLabel(state: DisplayState): string { if (state === "disabled-global") return "disabled global"; if (state === "project-local") return "project local"; + if (state === "plugin-overridden") return "plugin overridden"; + if (state === "plugin-disabled") return "plugin disabled"; 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. * * 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. */ -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 fileInputRef = useRef(null); const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS); @@ -300,9 +310,14 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId }, [scanDiscoveredServers]); const effectiveServers = useMemo( - () => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }) : configuredServers.filter((server) => server.enabled !== false), - [configuredServers, form.mcpServers, globalMcp, scope], + () => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }, pluginServers) : configuredServers.filter((server) => server.enabled !== false), + [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 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" })); const effectiveByName = new Set(effectiveServers.map((server) => server.name)); const rows: Array<{ server: McpServerDefinition; state: DisplayState }> = []; - for (const globalServer of globalServers) { - const projectServer = projectByName.get(globalServer.name); + // FNXC:PluginMcpServers 2026-07-22-12:00: + // 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(); + 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) { - rows.push({ server: projectServer, state: "disabled-global" }); + rows.push({ server: projectServer, state: inherited.plugin ? "plugin-disabled" : "disabled-global" }); } 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 { - 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) { - 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; - }, [configuredServers, effectiveServers, globalByName, globalServers, projectByName, scope]); + }, [configuredServers, effectiveServers, globalServers, pluginByName, projectByName, scope]); const updateMcpSettings = (next: McpServersSettings) => { setForm((current) => ({ ...current, mcpServers: next })); @@ -395,7 +417,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId }; 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" ? { name, enabled: false, transport: inherited.transport, url: inherited.url } : { name, enabled: false, transport: "stdio", command: inherited?.transport === "stdio" ? inherited.command : "disabled" }; @@ -591,7 +613,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
{server.name} - {getStateLabel(state)} + {getStateLabel(state)}{scope === "project" && pluginByName.has(server.name) ? {`plugin:${pluginByName.get(server.name)!.pluginId}`} : null} {server.transport}

{serverSummary(server)}

@@ -599,8 +621,8 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
- {state === "inherited" ? : null} - {state === "inherited" ? : null} + {(state === "inherited" || state === "plugin") ? : null} + {(state === "inherited" || state === "plugin") ? : null} {editable ? : null} {editable ? : null}
diff --git a/packages/dashboard/app/components/settings/sections/ProjectMcpSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectMcpSection.tsx index 1ab9c17526..e41486bdef 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectMcpSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectMcpSection.tsx @@ -1,6 +1,7 @@ import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; 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 { McpServersCard } from "./McpServersCard"; @@ -9,15 +10,39 @@ export interface ProjectMcpSectionProps { setForm: Dispatch>; globalSettings?: Pick | null; projectId?: string; + /** Project-scoped plugin entries; global MCP settings never receive these. */ + pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>; 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 [loadedPluginServers, setLoadedPluginServers] = useState>([]); + + 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 ( <>

{t("settings.nav.mcp", "MCP Servers")}

- + ); } diff --git a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts index a8c7bd0979..de7df42873 100644 --- a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts @@ -7,12 +7,16 @@ import { request } from "../../test-request.js"; import { registerConfigMcpPiSettingsRoutes } from "../register-config-mcp-pi-settings-routes.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(); app.use(express.json()); const store = { getRootDir: () => "/workspace", getSettingsFast: async () => settings, + getProjectScopedPluginMcpServers: async () => pluginServers, }; const context = { router: app, @@ -45,6 +49,18 @@ describe("registerConfigMcpPiSettingsRoutes", () => { 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 () => { const response = await request(createApp(), "POST", "/mcp/validate", JSON.stringify({ timeoutMs: 1000 }), { "content-type": "application/json" }); diff --git a/packages/dashboard/src/routes/context.ts b/packages/dashboard/src/routes/context.ts index 6aec151821..b2f8a59a48 100644 --- a/packages/dashboard/src/routes/context.ts +++ b/packages/dashboard/src/routes/context.ts @@ -1,6 +1,11 @@ import { Router, type Request } from "express"; 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 { ApiError, internalError } from "../api-error.js"; import { getOrCreateProjectStore } from "../project-store-resolver.js"; @@ -279,7 +284,69 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions } const resolveScopedStore = (req: Request): Promise => getScopedStore(req, store, options); - const resolveProjectContext = (req: Request): Promise => getProjectContext(req, store, options); + const fallbackMcpLoaders = new WeakMap }>(); + const projectMcpProviders = new WeakMap>(); + + const bindProjectScopedPluginMcpProvider = async (context: ProjectContext): Promise => { + const scopedStore = context.store as TaskStore & { + getProjectScopedPluginMcpServers?: () => Promise; + }; + // 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 => { + const context = await getProjectContext(req, store, options); + await bindProjectScopedPluginMcpProvider(context); + return context; + }; const disposeCallbacks: Array<() => void> = []; function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void { diff --git a/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts index 238a3d1164..138f11f461 100644 --- a/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts +++ b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts @@ -1,5 +1,5 @@ -import type { McpServerDefinition, TaskStore } from "@fusion/core"; -import { validateMcpServerDefinitionDetailed } from "@fusion/core"; +import type { McpServerDefinition, PluginMcpServerContribution, TaskStore } from "@fusion/core"; +import { mapPluginMcpServerContribution, validateMcpServerDefinitionDetailed } from "@fusion/core"; import { discoverMcpServers, resolveMcpServersForRuntime, @@ -108,12 +108,36 @@ export const registerConfigMcpPiSettingsRoutes: ApiRouteRegistrar = (ctx) => { const settings = await scopedStore.getSettingsFast(); res.json({ maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2, + maxTriageConcurrent: settings.maxTriageConcurrent ?? 2, maxWorktrees: settings.maxWorktrees ?? 4, rootDir: scopedStore.getRootDir(), }); } catch { 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 }>; + }; + // 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"); } }); diff --git a/packages/engine/src/__tests__/mcp-resolution.test.ts b/packages/engine/src/__tests__/mcp-resolution.test.ts index b5319d0a5f..9980355588 100644 --- a/packages/engine/src/__tests__/mcp-resolution.test.ts +++ b/packages/engine/src/__tests__/mcp-resolution.test.ts @@ -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 () => { const { resolveMcpServersForStore } = await import("../mcp-resolution.js"); const result = await resolveMcpServersForStore({ diff --git a/packages/engine/src/mcp-resolution.ts b/packages/engine/src/mcp-resolution.ts index b61e06355f..6c1881091f 100644 --- a/packages/engine/src/mcp-resolution.ts +++ b/packages/engine/src/mcp-resolution.ts @@ -6,6 +6,7 @@ import { type McpSecretReaderIdentity, type McpSecretResolutionError, type ProjectSettings, + type PluginMcpServerContribution, type ResolvedMcpServerDefinition, } from "@fusion/core"; @@ -14,6 +15,8 @@ export interface ResolveMcpServersForRuntimeOptions { projectSettings?: Pick | null; secrets: McpSecretReader; reader?: McpSecretReaderIdentity; + /** Already project-scoped plugin entries. Raw runner/loader output is forbidden here. */ + pluginServers?: Array<{ pluginId: string; server: PluginMcpServerContribution }>; } export interface ResolvedMcpServersForRuntime { @@ -23,12 +26,16 @@ export interface ResolvedMcpServersForRuntime { /** * 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. */ export async function resolveMcpServersForRuntime( options: ResolveMcpServersForRuntimeOptions, ): Promise { - const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings); + const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings, options.pluginServers); if (effective.length === 0) return { servers: [], errors: [] }; const materialized = await materializeMcpServersSecrets( @@ -52,6 +59,8 @@ export interface McpSettingsAndSecretsStore { project: Partial>; }>; getSecretsStore?(): Promise | McpSecretReader; + /** Shared provider hook; implementations must filter project_plugin_states. */ + getProjectScopedPluginMcpServers?(): Promise> | Array<{ pluginId: string; server: PluginMcpServerContribution }>; } const emptyMcpSecretReader: McpSecretReader = { @@ -72,14 +81,16 @@ export async function resolveMcpServersForStore( return { servers: [], errors: [] }; } - const [settings, secrets] = await Promise.all([ + const [settings, secrets, pluginServers] = await Promise.all([ store.getSettingsByScope(), typeof store.getSecretsStore === "function" ? store.getSecretsStore() : emptyMcpSecretReader, + typeof store.getProjectScopedPluginMcpServers === "function" ? store.getProjectScopedPluginMcpServers() : [], ]); return resolveMcpServersForRuntime({ globalSettings: settings.global, projectSettings: settings.project, secrets, reader, + pluginServers, }); } diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index 31cfa6ca4f..3f1b417d80 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -20,6 +20,7 @@ import type { CliProviderContribution, PluginContext, PluginSkillContribution, + PluginMcpServerContribution, PluginWorkflowStepContribution, WorkflowExtensionContribution, PluginTraitContribution, @@ -143,6 +144,11 @@ interface CachedWorkflowStepTemplates { version: number; } +interface CachedMcpServers { + servers: Array<{ pluginId: string; server: PluginMcpServerContribution }>; + version: number; +} + interface CachedTraits { traits: Array<{ pluginId: string; trait: PluginTraitContribution }>; version: number; @@ -173,6 +179,7 @@ export class PluginRunner { private cachedRuntimes: CachedRuntimes | null = null; private cachedCliProviderContributions: CachedCliProviderContributions | null = null; private cachedSkills: CachedSkills | null = null; + private cachedMcpServers: CachedMcpServers | null = null; private cachedWorkflowSteps: CachedWorkflowSteps | null = null; private cachedWorkflowExtensions: CachedWorkflowExtensions | null = null; private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null; @@ -186,6 +193,7 @@ export class PluginRunner { private runtimesCacheVersion = 0; private cliProviderContributionsCacheVersion = 0; private skillsCacheVersion = 0; + private mcpServersCacheVersion = 0; private workflowStepsCacheVersion = 0; private workflowExtensionsCacheVersion = 0; private workflowStepTemplatesCacheVersion = 0; @@ -267,6 +275,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -399,6 +408,21 @@ export class PluginRunner { 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 }> { if (!this.cachedWorkflowSteps || this.cachedWorkflowSteps.version !== this.workflowStepsCacheVersion) { this.cachedWorkflowSteps = { @@ -929,6 +953,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -952,6 +977,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -980,6 +1006,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -1008,6 +1035,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -1035,6 +1063,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -1054,6 +1083,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -1073,6 +1103,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -1092,6 +1123,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -1111,6 +1143,7 @@ export class PluginRunner { this.invalidateRuntimesCache(); this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); + this.invalidateMcpServersCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); @@ -1354,6 +1387,11 @@ export class PluginRunner { 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 { this.workflowStepsCacheVersion++; this.log.log(`Workflow steps cache invalidated (version: ${this.workflowStepsCacheVersion})`); diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index f276209432..e546fb6632 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -395,6 +395,7 @@ export class InProcessRuntime // InProcessRuntime.start(). When the factory returns a backend result, // the engine owns the result's shutdown() for process teardown. createTaskStoreForBackend, + createProjectScopedPluginMcpProvider, } = await import("@fusion/core"); if (this.config.externalTaskStore) { this.taskStore = this.config.externalTaskStore; @@ -477,6 +478,28 @@ export class InProcessRuntime rootDir: this.config.workingDirectory, }); 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> }).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`); await yieldEventLoop(); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 10268a925f..c9e3e2cbba 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -79,6 +79,7 @@ export type { PluginContext, PluginLogger, PluginSkillContribution, + PluginMcpServerContribution, PluginWorkflowStepContribution, PluginTraitContribution, PluginTraitHookDescriptor,