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:
7
.changeset/plugin-mcp-servers.md
Normal file
7
.changeset/plugin-mcp-servers.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
78
packages/core/src/plugin-mcp-servers.ts
Normal file
78
packages/core/src/plugin-mcp-servers.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export type {
|
|||||||
PluginContext,
|
PluginContext,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
PluginSkillContribution,
|
PluginSkillContribution,
|
||||||
|
PluginMcpServerContribution,
|
||||||
PluginWorkflowStepContribution,
|
PluginWorkflowStepContribution,
|
||||||
PluginTraitContribution,
|
PluginTraitContribution,
|
||||||
PluginTraitHookDescriptor,
|
PluginTraitHookDescriptor,
|
||||||
|
|||||||
Reference in New Issue
Block a user