FN-7022: add MCP server configuration foundation

Add core MCP configuration primitives for secure server declarations and resolution.

- Add MCP server setting types, schema entries, validation, and project-over-global resolution.
- Add secret-reference materialization seams plus Claude Desktop import/export helpers.
- Cover MCP config behavior with core unit tests and document settings and secret handling.
- Add a changeset for the published CLI package.

Files changed:
 .changeset/fn-7022-mcp-core-foundation.md      |   7 +
 docs/secrets.md                                |   3 +-
 docs/settings-reference.md                     |  23 ++
 packages/core/src/__tests__/mcp-config.test.ts | 199 ++++++++++++++
 packages/core/src/index.ts                     |  30 +-
 packages/core/src/mcp-config.ts                | 366 +++++++++++++++++++++++++
 packages/core/src/settings-schema.ts           |  90 +++++-
 packages/core/src/settings-validation.ts       | 172 +++++++++++-
 packages/core/src/types.ts                     |  62 +++++
 9 files changed, 947 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7022

Fusion-Task-Lineage: 7bafd0b8-e4a5-4bc2-9a2a-43bfb934845c
This commit is contained in:
gsxdsm
2026-06-25 21:01:15 -07:00
parent b663eebcb3
commit 79c602d9d4
9 changed files with 947 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add core MCP server settings model with project/global precedence and secret references.
category: feature
dev: New @fusion/core MCP config types, validators, resolveEffectiveMcpServers, secret-resolver seam, and Claude Desktop import/export. Secret material stored only as Fusion-managed secret references.

View File

@@ -38,6 +38,7 @@ Threat-model baseline:
- Secret plaintext is **not** stored in SQLite. - Secret plaintext is **not** stored in SQLite.
- Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal. - Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal.
- Secret values must never be logged. - Secret values must never be logged.
- MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings.
See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md). See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md).
@@ -133,7 +134,7 @@ Fusion can materialize env-exportable secrets into each acquired task worktree w
- Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\n` (mode `0o600`) so teardown can verify file integrity before deletion. - Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\n` (mode `0o600`) so teardown can verify file integrity before deletion.
- Teardown cleanup: when a worktree is removed, Fusion deletes the managed env file only when the on-disk fingerprint still matches; edited files are preserved and only the sidecar is removed. - Teardown cleanup: when a worktree is removed, Fusion deletes the managed env file only when the on-disk fingerprint still matches; edited files are preserved and only the sidecar is removed.
Settings shape is split by scope: project-level secrets settings are limited to `ProjectSettings.secretsEnv`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry the plaintext passphrase. Settings shape is split by scope: project-level secrets settings include `ProjectSettings.secretsEnv` and MCP secret references in `ProjectSettings.mcpServers`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry plaintext passphrases or MCP credentials; MCP env/header/token fields use `{ secretRef, scope }` and materialize through `SecretsStore.revealSecret(...)` only at the runtime use seam.
### Test locations ### Test locations

View File

@@ -122,8 +122,30 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. | | `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools), and `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution). | | `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools), and `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution). |
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. | | `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Global MCP server declarations shared across projects. Project `mcpServers` can enable/disable the effective set, override a same-named global server, or disable a global server with a same-named `enabled:false` entry. Sensitive env/header/token values must be `{ secretRef, scope }` references to Fusion-managed secrets, never plaintext. |
| `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. | | `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. |
### MCP server settings
`mcpServers` is available in both global and project settings:
```ts
type McpServersSettings = {
enabled?: boolean;
servers?: McpServerDefinition[];
};
```
Each server is named and uses one transport:
- `stdio`: `{ name, enabled?, transport: "stdio", command, args?, env? }`
- `sse`: `{ name, enabled?, transport: "sse", url, headers? }`
- `streamable-http`: `{ name, enabled?, transport: "streamable-http", url, headers? }`
Resolution uses project-over-global precedence by server name. The project-level `enabled` flag overrides the global flag when set; if the effective flag is false, no MCP servers are active. When enabled, global servers are loaded first, project servers with the same `name` replace them, and a project server with `enabled:false` removes the inherited server.
Secret rule: `env` and `headers` maps are sensitive. Values must be Fusion secret references such as `{ "secretRef": "sec_...", "scope": "project" }` or `{ "secretRef": "sec_...", "scope": "global" }`. Write-boundary sanitizers and validators reject plaintext strings in these fields. Claude Desktop-style imports return `secretsToCreate` descriptors for plaintext env/header values and replace those values with secret refs in the imported definitions.
### Notification providers (pluggable) ### Notification providers (pluggable)
Fusion now supports a provider-list notification model via `notificationProviders` while keeping legacy flat ntfy/webhook settings intact. Fusion now supports a provider-list notification model via `notificationProviders` while keeping legacy flat ntfy/webhook settings intact.
@@ -324,6 +346,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). | | `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). |
| `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). | | `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). |
| `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). | | `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). |
| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Project-scoped MCP server settings. Project entries override global entries by `name`; `enabled:false` on a same-named project entry disables the inherited global server. Sensitive env/header/token material must be Fusion secret references only. See [MCP server settings](#mcp-server-settings). |
| `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. | | `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. |
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import {
exportMcpServersJson,
importMcpServersJson,
materializeMcpServerSecrets,
resolveEffectiveMcpServers,
} from "../mcp-config.js";
import {
validateMcpServerDefinition,
validateMcpServerDefinitions,
validateMcpServerDefinitionsDetailed,
} from "../settings-validation.js";
import type { McpServerDefinition } from "../types.js";
const projectSecret = { secretRef: "project-token", scope: "project" as const };
const globalSecret = { secretRef: "global-token", scope: "global" as const };
describe("MCP core config", () => {
it("resolves project servers over global servers by name", () => {
const globalServer: McpServerDefinition = {
name: "github",
transport: "stdio",
command: "global-gh",
env: { TOKEN: globalSecret },
};
const projectServer: McpServerDefinition = {
name: "github",
transport: "stdio",
command: "project-gh",
args: ["serve"],
env: { TOKEN: projectSecret },
};
expect(
resolveEffectiveMcpServers(
{ mcpServers: { enabled: true, servers: [globalServer] } },
{ mcpServers: { enabled: true, servers: [projectServer] } },
),
).toEqual([projectServer]);
});
it("lets a project disabled entry remove a global server", () => {
const globalServer: McpServerDefinition = {
name: "global-only",
transport: "stdio",
command: "global-command",
};
const disabledProjectOverride: McpServerDefinition = {
name: "global-only",
enabled: false,
transport: "stdio",
command: "ignored",
};
expect(
resolveEffectiveMcpServers(
{ mcpServers: { enabled: true, servers: [globalServer] } },
{ mcpServers: { enabled: true, servers: [disabledProjectOverride] } },
),
).toEqual([]);
});
it("rejects plaintext sensitive env and header values while accepting secret refs", () => {
expect(
validateMcpServerDefinition({
name: "bad-env",
transport: "stdio",
command: "node",
env: { TOKEN: "plaintext" },
}),
).toBeUndefined();
expect(
validateMcpServerDefinition({
name: "bad-header",
transport: "sse",
url: "https://example.test/sse",
headers: { Authorization: "Bearer plaintext" },
}),
).toBeUndefined();
expect(
validateMcpServerDefinition({
name: "good",
transport: "sse",
url: "https://example.test/sse",
headers: { Authorization: projectSecret },
}),
).toEqual({
name: "good",
transport: "sse",
url: "https://example.test/sse",
headers: { Authorization: projectSecret },
});
});
it("validates required fields by transport and rejects duplicate names", () => {
expect(validateMcpServerDefinition({ name: "stdio", transport: "stdio" })).toBeUndefined();
expect(validateMcpServerDefinition({ name: "sse", transport: "sse" })).toBeUndefined();
expect(validateMcpServerDefinition({ name: "http", transport: "streamable-http" })).toBeUndefined();
const duplicateResult = validateMcpServerDefinitionsDetailed([
{ name: "dup", transport: "stdio", command: "one" },
{ name: "dup", transport: "stdio", command: "two" },
]);
expect(duplicateResult.value).toBeUndefined();
expect(duplicateResult.errors.map((error) => error.code)).toContain("duplicate-name");
expect(
validateMcpServerDefinitions([
{ name: "one", transport: "stdio", command: "one" },
{ name: "two", transport: "streamable-http", url: "https://example.test/mcp" },
]),
).toHaveLength(2);
});
it("imports plaintext sensitive values as secret descriptors and round-trips exported refs", () => {
const imported = importMcpServersJson({
mcpServers: {
github: {
command: "github-mcp-server",
args: ["stdio"],
env: { GITHUB_TOKEN: "ghp_secret" },
},
docs: {
transport: "streamable-http",
url: "https://docs.example.test/mcp",
headers: { Authorization: globalSecret },
},
},
});
expect(imported.errors).toEqual([]);
expect(imported.secretsToCreate).toEqual([
{
serverName: "github",
field: "env",
key: "GITHUB_TOKEN",
scope: "project",
suggestedKey: "mcp.github.env.GITHUB_TOKEN",
plaintextValue: "ghp_secret",
},
]);
expect(imported.definitions[0]).toMatchObject({
name: "github",
transport: "stdio",
env: { GITHUB_TOKEN: { secretRef: "mcp.github.env.GITHUB_TOKEN", scope: "project" } },
});
const original: McpServerDefinition[] = [
{
name: "docs",
transport: "streamable-http",
url: "https://docs.example.test/mcp",
headers: { Authorization: globalSecret },
},
];
const roundTrip = importMcpServersJson(exportMcpServersJson(original));
expect(roundTrip.errors).toEqual([]);
expect(roundTrip.secretsToCreate).toEqual([]);
expect(roundTrip.definitions).toEqual(original);
});
it("materializes secret refs through an injected reader and omits failed refs", async () => {
const calls: Array<{ id: string; scope: string; userId?: string | null }> = [];
const server: McpServerDefinition = {
name: "secure",
transport: "stdio",
command: "secure-mcp",
env: {
OK: { secretRef: "ok", scope: "project" },
MISSING: { secretRef: "missing", scope: "global" },
},
};
const resolved = await materializeMcpServerSecrets(
server,
{
async revealSecret(id, scope, reader) {
calls.push({ id, scope, userId: reader.userId });
if (id === "missing") throw new Error("not found");
return { key: id, plaintextValue: "resolved-value" };
},
},
{ userId: "tester" },
);
expect(calls).toEqual([
{ id: "ok", scope: "project", userId: "tester" },
{ id: "missing", scope: "global", userId: "tester" },
]);
expect(resolved.value).toMatchObject({
name: "secure",
transport: "stdio",
env: { OK: "resolved-value" },
});
expect((resolved.value as Extract<typeof resolved.value, { transport: "stdio" }>)?.env).not.toHaveProperty("MISSING");
expect(resolved.errors).toHaveLength(1);
});
});

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
export { export {
resolveEntryPointBranchAssignment, resolveEntryPointBranchAssignment,
@@ -37,6 +37,25 @@ export {
validateWorktrunkSettings, validateWorktrunkSettings,
DEFAULT_WORKTRUNK_SETTINGS, DEFAULT_WORKTRUNK_SETTINGS,
} from "./worktrunk-settings.js"; } from "./worktrunk-settings.js";
export {
resolveEffectiveMcpServers,
materializeMcpServerSecrets,
materializeMcpServersSecrets,
importMcpServersJson,
exportMcpServersJson,
} from "./mcp-config.js";
export type {
McpSecretReaderIdentity,
McpSecretReader,
ResolvedMcpStdioTransport,
ResolvedMcpSseTransport,
ResolvedMcpStreamableHttpTransport,
ResolvedMcpServerDefinition,
McpSecretResolutionError,
McpSecretResolutionResult,
McpSecretImportDescriptor,
McpServersImportResult,
} from "./mcp-config.js";
export { export {
resolveAgentMemoryInclusionMode, resolveAgentMemoryInclusionMode,
type AgentMemoryInclusionModeSource, type AgentMemoryInclusionModeSource,
@@ -918,8 +937,15 @@ export {
validateSandboxFailureMode, validateSandboxFailureMode,
validateSandboxPolicy, validateSandboxPolicy,
validateSandboxProjectSettings, validateSandboxProjectSettings,
validateMcpServerDefinition,
validateMcpServerDefinitionDetailed,
validateMcpServerDefinitions,
validateMcpServerDefinitionsDetailed,
validateMcpServersSettings,
validateMcpServersSettingsDetailed,
validateUnavailableNodePolicy, validateUnavailableNodePolicy,
} from "./settings-validation.js"; } from "./settings-validation.js";
export type { McpValidationError, McpValidationResult } from "./settings-validation.js";
export { parseSandboxPromptOverride, resolveSandboxBackend } from "./sandbox-prompt-override.js"; export { parseSandboxPromptOverride, resolveSandboxBackend } from "./sandbox-prompt-override.js";

View File

@@ -0,0 +1,366 @@
import type {
GlobalSettings,
McpSecretRef,
McpServerDefinition,
McpServersSettings,
McpStdioTransport,
McpSseTransport,
McpStreamableHttpTransport,
ProjectSettings,
} from "./types.js";
import { isMcpSecretRef } from "./types.js";
import type { SecretScope } from "./secrets-store.js";
import { validateMcpServerDefinition } from "./settings-validation.js";
export interface McpSecretImportDescriptor {
serverName: string;
field: "env" | "headers" | "token";
key: string;
scope: SecretScope;
suggestedKey: string;
plaintextValue: string;
}
export interface McpServersImportResult {
definitions: McpServerDefinition[];
secretsToCreate: McpSecretImportDescriptor[];
errors: string[];
}
export type McpSecretReaderIdentity = { agentId?: string | null; userId?: string | null };
export interface McpSecretReader {
revealSecret(
id: string,
scope: SecretScope,
reader: McpSecretReaderIdentity,
): Promise<{ key: string; plaintextValue: string }>;
}
export interface ResolvedMcpStdioTransport extends Omit<McpStdioTransport, "env"> {
env?: Record<string, string>;
}
export interface ResolvedMcpSseTransport extends Omit<McpSseTransport, "headers"> {
headers?: Record<string, string>;
}
export interface ResolvedMcpStreamableHttpTransport extends Omit<McpStreamableHttpTransport, "headers"> {
headers?: Record<string, string>;
}
export type ResolvedMcpServerDefinition = {
name: string;
enabled?: boolean;
} & (ResolvedMcpStdioTransport | ResolvedMcpSseTransport | ResolvedMcpStreamableHttpTransport);
export interface McpSecretResolutionError {
serverName: string;
path: string;
secretRef: McpSecretRef;
message: string;
}
export interface McpSecretResolutionResult<T> {
value?: T;
errors: McpSecretResolutionError[];
}
function normalizeMcpServersSettings(settings?: McpServersSettings): McpServersSettings {
return {
enabled: settings?.enabled === true,
servers: Array.isArray(settings?.servers) ? settings.servers : [],
};
}
function validServers(settings?: McpServersSettings): McpServerDefinition[] {
return (
normalizeMcpServersSettings(settings).servers
?.map(validateMcpServerDefinition)
.filter((server): server is McpServerDefinition => Boolean(server)) ?? []
);
}
/**
* 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.
*/
export function resolveEffectiveMcpServers(
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null,
projectSettings?: Pick<ProjectSettings, "mcpServers"> | null,
): McpServerDefinition[] {
try {
const globalMcp = normalizeMcpServersSettings(globalSettings?.mcpServers);
const projectMcp = projectSettings?.mcpServers;
const effectiveEnabled = typeof projectMcp?.enabled === "boolean" ? projectMcp.enabled : globalMcp.enabled;
if (!effectiveEnabled) return [];
const byName = new Map<string, McpServerDefinition>();
for (const server of validServers(globalSettings?.mcpServers)) {
if (server.enabled === false) continue;
byName.set(server.name, server);
}
for (const server of validServers(projectMcp)) {
if (server.enabled === false) {
byName.delete(server.name);
continue;
}
byName.set(server.name, server);
}
return [...byName.values()].filter((server) => server.enabled !== false);
} catch {
return [];
}
}
async function materializeSensitiveMap(params: {
serverName: string;
path: string;
values?: Record<string, McpSecretRef | string>;
secrets: McpSecretReader;
reader: McpSecretReaderIdentity;
}): Promise<McpSecretResolutionResult<Record<string, string> | undefined>> {
const { values, secrets, reader, serverName, path } = params;
if (!values) return { value: undefined, errors: [] };
const resolved: Record<string, string> = {};
const errors: McpSecretResolutionError[] = [];
for (const [key, value] of Object.entries(values)) {
if (!isMcpSecretRef(value)) {
errors.push({
serverName,
path: `${path}.${key}`,
secretRef: { secretRef: "", scope: "project" },
message: "MCP sensitive values must be secret references; plaintext was not materialized",
});
continue;
}
try {
const revealed = await secrets.revealSecret(value.secretRef, value.scope, reader);
resolved[key] = revealed.plaintextValue;
} catch (error) {
errors.push({
serverName,
path: `${path}.${key}`,
secretRef: value,
message: error instanceof Error ? error.message : String(error),
});
}
}
return { value: Object.keys(resolved).length > 0 ? resolved : undefined, errors };
}
/**
* FNXC:McpConfig 2026-06-25-00:00:
* MCP secret materialization happens only at the use seam by calling the injected SecretsStore-compatible revealSecret method. Failed references are reported and omitted; the function never logs or returns unresolved secret material as plaintext.
*/
export async function materializeMcpServerSecrets(
server: McpServerDefinition,
secrets: McpSecretReader,
reader: McpSecretReaderIdentity,
): Promise<McpSecretResolutionResult<ResolvedMcpServerDefinition>> {
if (server.transport === "stdio") {
const env = await materializeSensitiveMap({
serverName: server.name,
path: "env",
values: server.env,
secrets,
reader,
});
return {
value: {
name: server.name,
...(server.enabled !== undefined ? { enabled: server.enabled } : {}),
transport: "stdio",
command: server.command,
...(server.args ? { args: server.args } : {}),
...(env.value ? { env: env.value } : {}),
},
errors: env.errors,
};
}
const headers = await materializeSensitiveMap({
serverName: server.name,
path: "headers",
values: server.headers,
secrets,
reader,
});
return {
value: {
name: server.name,
...(server.enabled !== undefined ? { enabled: server.enabled } : {}),
transport: server.transport,
url: server.url,
...(headers.value ? { headers: headers.value } : {}),
},
errors: headers.errors,
};
}
export async function materializeMcpServersSecrets(
servers: McpServerDefinition[],
secrets: McpSecretReader,
reader: McpSecretReaderIdentity,
): Promise<McpSecretResolutionResult<ResolvedMcpServerDefinition[]>> {
const values: ResolvedMcpServerDefinition[] = [];
const errors: McpSecretResolutionError[] = [];
for (const server of servers) {
const resolved = await materializeMcpServerSecrets(server, secrets, reader);
if (resolved.value) values.push(resolved.value);
errors.push(...resolved.errors);
}
return { value: values, errors };
}
function parseMcpJson(json: string | unknown): { data?: unknown; error?: string } {
if (typeof json !== "string") return { data: json };
try {
return { data: JSON.parse(json) as unknown };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
function suggestedSecretKey(serverName: string, field: "env" | "headers" | "token", key: string): string {
const clean = (value: string): string => value.trim().replace(/[^A-Za-z0-9_.-]+/gu, "_").replace(/^_+|_+$/gu, "");
return ["mcp", clean(serverName), clean(field), clean(key)].filter(Boolean).join(".");
}
function importSensitiveMap(params: {
value: unknown;
serverName: string;
field: "env" | "headers";
scope: SecretScope;
secretsToCreate: McpSecretImportDescriptor[];
errors: string[];
}): Record<string, McpSecretRef> | undefined {
const { value, serverName, field, scope, secretsToCreate, errors } = params;
if (value === undefined) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) {
errors.push(`${serverName}.${field} must be an object`);
return undefined;
}
const out: Record<string, McpSecretRef> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (isMcpSecretRef(raw)) {
out[key] = { secretRef: raw.secretRef.trim(), scope: raw.scope };
continue;
}
if (typeof raw === "string") {
const secretRef = suggestedSecretKey(serverName, field, key);
out[key] = { secretRef, scope };
secretsToCreate.push({
serverName,
field,
key,
scope,
suggestedKey: secretRef,
plaintextValue: raw,
});
continue;
}
errors.push(`${serverName}.${field}.${key} must be a string or MCP secret reference`);
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Import Claude Desktop-style `{ mcpServers: { [name]: ... } }` JSON into Fusion
* definitions. Plain env/header strings are surfaced as secret creation
* descriptors and replaced with secret references; plaintext is never stored in
* the returned definitions.
*/
export function importMcpServersJson(json: string | unknown, options: { scope?: SecretScope } = {}): McpServersImportResult {
const parsed = parseMcpJson(json);
if (parsed.error) return { definitions: [], secretsToCreate: [], errors: [parsed.error] };
const errors: string[] = [];
const secretsToCreate: McpSecretImportDescriptor[] = [];
const scope = options.scope ?? "project";
const root = parsed.data;
if (!root || typeof root !== "object" || Array.isArray(root)) {
return { definitions: [], secretsToCreate, errors: ["MCP import data must be an object"] };
}
const servers = (root as Record<string, unknown>).mcpServers;
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
return { definitions: [], secretsToCreate, errors: ["MCP import data must contain an mcpServers object"] };
}
const definitions: McpServerDefinition[] = [];
const names = new Set<string>();
for (const [name, rawServer] of Object.entries(servers as Record<string, unknown>)) {
if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer)) {
errors.push(`${name} must be an object`);
continue;
}
const raw = rawServer as Record<string, unknown>;
const enabled = typeof raw.enabled === "boolean" ? raw.enabled : undefined;
const base = { name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : name, ...(enabled !== undefined ? { enabled } : {}) };
const transport = typeof raw.transport === "string" ? raw.transport : typeof raw.command === "string" ? "stdio" : undefined;
let candidate: McpServerDefinition | undefined;
if (transport === "stdio") {
candidate = validateMcpServerDefinition({
...base,
transport: "stdio",
command: raw.command,
args: raw.args,
env: importSensitiveMap({ value: raw.env, serverName: base.name, field: "env", scope, secretsToCreate, errors }),
});
} else if (transport === "sse" || transport === "streamable-http") {
candidate = validateMcpServerDefinition({
...base,
transport,
url: raw.url,
headers: importSensitiveMap({ value: raw.headers, serverName: base.name, field: "headers", scope, secretsToCreate, errors }),
});
} else {
errors.push(`${name}.transport must be stdio, sse, or streamable-http`);
}
if (!candidate) {
errors.push(`${name} is not a valid MCP server definition`);
continue;
}
if (names.has(candidate.name)) {
errors.push(`Duplicate MCP server name: ${candidate.name}`);
continue;
}
names.add(candidate.name);
definitions.push(candidate);
}
return { definitions, secretsToCreate, errors };
}
function exportSensitiveMap(values: Record<string, McpSecretRef | string> | undefined): Record<string, McpSecretRef> | undefined {
if (!values) return undefined;
const out: Record<string, McpSecretRef> = {};
for (const [key, value] of Object.entries(values)) {
if (isMcpSecretRef(value)) out[key] = { secretRef: value.secretRef, scope: value.scope };
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Export Fusion MCP definitions as JSON-safe `mcpServers` data with secret refs preserved and never resolved. */
export function exportMcpServersJson(definitions: McpServerDefinition[]): { mcpServers: Record<string, unknown> } {
const mcpServers: Record<string, unknown> = {};
for (const definition of definitions) {
const server = validateMcpServerDefinition(definition);
if (!server) continue;
if (server.transport === "stdio") {
mcpServers[server.name] = {
transport: "stdio",
...(server.enabled !== undefined ? { enabled: server.enabled } : {}),
command: server.command,
...(server.args ? { args: server.args } : {}),
...(server.env ? { env: exportSensitiveMap(server.env) } : {}),
};
continue;
}
mcpServers[server.name] = {
transport: server.transport,
...(server.enabled !== undefined ? { enabled: server.enabled } : {}),
url: server.url,
...(server.headers ? { headers: exportSensitiveMap(server.headers) } : {}),
};
}
return { mcpServers };
}

View File

@@ -1,5 +1,5 @@
import { DEFAULT_MAX_AUTO_MERGE_RETRIES } from "./in-review-stall.js"; import { DEFAULT_MAX_AUTO_MERGE_RETRIES } from "./in-review-stall.js";
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js"; import type { CliAgentSettings, GlobalSettings, McpSecretRef, McpServerDefinition, ProjectSettings, Settings } from "./types.js";
export interface MergeRequestContractShadowSettingsSource { export interface MergeRequestContractShadowSettingsSource {
mergeRequestContractShadowEnabled?: boolean; mergeRequestContractShadowEnabled?: boolean;
@@ -200,6 +200,10 @@ export const DEFAULT_GLOBAL_SETTINGS = {
researchGlobalMaxSearchResults: 10, researchGlobalMaxSearchResults: 10,
researchGlobalFetchTimeoutMs: 30_000, researchGlobalFetchTimeoutMs: 30_000,
researchGlobalUserAgent: "FusionResearchBot/1.0", researchGlobalUserAgent: "FusionResearchBot/1.0",
mcpServers: {
enabled: false,
servers: [],
},
remoteAccess: { remoteAccess: {
activeProvider: null, activeProvider: null,
providers: { providers: {
@@ -295,6 +299,10 @@ export const DEFAULT_PROJECT_SETTINGS = {
owningNodeHandoffPolicy: "reassign-to-local", owningNodeHandoffPolicy: "reassign-to-local",
defaultNodeId: undefined, defaultNodeId: undefined,
secretsEnv: undefined, secretsEnv: undefined,
mcpServers: {
enabled: false,
servers: [],
},
worktreeInitCommand: undefined, worktreeInitCommand: undefined,
/* /*
FNXC:WorktreeCopyFiles 2026-06-24-00:00: FNXC:WorktreeCopyFiles 2026-06-24-00:00:
@@ -698,3 +706,83 @@ export function sanitizeCliAgentsSettings(value: unknown): Record<string, CliAge
} }
return out; return out;
} }
function sanitizeMcpSecretRef(value: unknown): McpSecretRef | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const input = value as Record<string, unknown>;
if (typeof input.secretRef !== "string") return undefined;
const secretRef = input.secretRef.trim();
if (!secretRef || (input.scope !== "project" && input.scope !== "global")) return undefined;
return { secretRef, scope: input.scope };
}
function sanitizeMcpSensitiveMap(value: unknown): Record<string, McpSecretRef> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const out: Record<string, McpSecretRef> = {};
for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
const key = rawKey.trim();
if (!key) continue;
const ref = sanitizeMcpSecretRef(rawValue);
if (ref) out[key] = ref;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function sanitizeMcpServerDefinition(value: unknown): McpServerDefinition | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const input = value as Record<string, unknown>;
if (typeof input.name !== "string") return undefined;
const name = input.name.trim();
if (!name) return undefined;
const enabled = typeof input.enabled === "boolean" ? input.enabled : undefined;
const base = { name, ...(enabled !== undefined ? { enabled } : {}) };
if (input.transport === "stdio") {
if (typeof input.command !== "string" || input.command.trim().length === 0) return undefined;
const args = sanitizeStringArray(input.args);
const env = sanitizeMcpSensitiveMap(input.env);
return {
...base,
transport: "stdio",
command: input.command.trim(),
...(args ? { args } : {}),
...(env ? { env } : {}),
};
}
if (input.transport === "sse" || input.transport === "streamable-http") {
if (typeof input.url !== "string" || input.url.trim().length === 0) return undefined;
const headers = sanitizeMcpSensitiveMap(input.headers);
return {
...base,
transport: input.transport,
url: input.url.trim(),
...(headers ? { headers } : {}),
};
}
return undefined;
}
/**
* Sanitize MCP settings at the write boundary. Malformed server declarations are
* dropped, duplicate names collapse to the last valid declaration, and sensitive
* env/header values survive only as Fusion secret references. Pure — no I/O.
*/
export function sanitizeMcpServers(value: unknown): { enabled?: boolean; servers: McpServerDefinition[] } {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { enabled: false, servers: [] };
}
const input = value as Record<string, unknown>;
const byName = new Map<string, McpServerDefinition>();
if (Array.isArray(input.servers)) {
for (const rawServer of input.servers) {
const server = sanitizeMcpServerDefinition(rawServer);
if (server) byName.set(server.name, server);
}
}
return {
enabled: typeof input.enabled === "boolean" ? input.enabled : false,
servers: [...byName.values()],
};
}

View File

@@ -4,13 +4,16 @@ import type {
HeartbeatPromptTemplate, HeartbeatPromptTemplate,
HeartbeatScopeDisciplineMode, HeartbeatScopeDisciplineMode,
Locale, Locale,
McpSensitiveValue,
McpServerDefinition,
McpServersSettings,
SandboxBackendName, SandboxBackendName,
SandboxFailureMode, SandboxFailureMode,
SandboxPolicy, SandboxPolicy,
SandboxProjectSettings, SandboxProjectSettings,
UnavailableNodePolicy, UnavailableNodePolicy,
} from "./types.js"; } from "./types.js";
import { isLocale } from "./types.js"; import { isLocale, isMcpSecretRef } from "./types.js";
const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const; const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const;
const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const; const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const;
@@ -204,3 +207,170 @@ export function validateSandboxProjectSettings(value: unknown): SandboxProjectSe
...(failureMode !== undefined ? { failureMode } : {}), ...(failureMode !== undefined ? { failureMode } : {}),
}; };
} }
export interface McpValidationError {
path: string;
code:
| "invalid-shape"
| "invalid-name"
| "duplicate-name"
| "invalid-transport"
| "missing-command"
| "missing-url"
| "invalid-args"
| "invalid-sensitive-map"
| "plaintext-secret";
message: string;
}
export interface McpValidationResult<T> {
value?: T;
errors: McpValidationError[];
}
function mcpError(path: string, code: McpValidationError["code"], message: string): McpValidationError {
return { path, code, message };
}
function validateMcpStringArray(value: unknown, path: string): McpValidationResult<string[] | undefined> {
if (value === undefined) return { value: undefined, errors: [] };
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim().length > 0)) {
return { errors: [mcpError(path, "invalid-args", "Expected an array of non-empty strings")] };
}
return { value: value.map((entry) => entry.trim()), errors: [] };
}
function validateMcpSensitiveMap(
value: unknown,
path: string,
): McpValidationResult<Record<string, McpSensitiveValue> | undefined> {
if (value === undefined) return { value: undefined, errors: [] };
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { errors: [mcpError(path, "invalid-sensitive-map", "Expected an object whose values are secret references")] };
}
const out: Record<string, McpSensitiveValue> = {};
const errors: McpValidationError[] = [];
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (!key.trim()) {
errors.push(mcpError(`${path}.${key}`, "invalid-sensitive-map", "Sensitive field names must be non-empty"));
continue;
}
if (typeof entry === "string") {
errors.push(mcpError(`${path}.${key}`, "plaintext-secret", "Sensitive MCP values must be Fusion secret references, never plaintext strings"));
continue;
}
if (!isMcpSecretRef(entry)) {
errors.push(mcpError(`${path}.${key}`, "invalid-sensitive-map", "Sensitive MCP values must be { secretRef, scope } objects"));
continue;
}
out[key.trim()] = { secretRef: entry.secretRef.trim(), scope: entry.scope };
}
return errors.length > 0 ? { errors } : { value: out, errors: [] };
}
export function validateMcpServerDefinitionDetailed(value: unknown, path = "server"): McpValidationResult<McpServerDefinition> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { errors: [mcpError(path, "invalid-shape", "MCP server definition must be an object")] };
}
const input = value as Record<string, unknown>;
const errors: McpValidationError[] = [];
if (typeof input.name !== "string" || input.name.trim().length === 0) {
errors.push(mcpError(`${path}.name`, "invalid-name", "MCP server name is required"));
}
const enabled = typeof input.enabled === "boolean" ? input.enabled : undefined;
if (input.transport === "stdio") {
if (typeof input.command !== "string" || input.command.trim().length === 0) {
errors.push(mcpError(`${path}.command`, "missing-command", "stdio MCP servers require a command"));
}
const args = validateMcpStringArray(input.args, `${path}.args`);
const env = validateMcpSensitiveMap(input.env, `${path}.env`);
errors.push(...args.errors, ...env.errors);
if (errors.length > 0) return { errors };
return {
value: {
name: (input.name as string).trim(),
...(enabled !== undefined ? { enabled } : {}),
transport: "stdio",
command: (input.command as string).trim(),
...(args.value ? { args: args.value } : {}),
...(env.value ? { env: env.value } : {}),
},
errors: [],
};
}
if (input.transport === "sse" || input.transport === "streamable-http") {
if (typeof input.url !== "string" || input.url.trim().length === 0) {
errors.push(mcpError(`${path}.url`, "missing-url", `${input.transport} MCP servers require a url`));
}
const headers = validateMcpSensitiveMap(input.headers, `${path}.headers`);
errors.push(...headers.errors);
if (errors.length > 0) return { errors };
return {
value: {
name: (input.name as string).trim(),
...(enabled !== undefined ? { enabled } : {}),
transport: input.transport,
url: (input.url as string).trim(),
...(headers.value ? { headers: headers.value } : {}),
},
errors: [],
};
}
errors.push(mcpError(`${path}.transport`, "invalid-transport", "MCP transport must be stdio, sse, or streamable-http"));
return { errors };
}
/** Returns a normalized MCP server definition, or undefined with rejection details available from validateMcpServerDefinitionDetailed. */
export function validateMcpServerDefinition(value: unknown): McpServerDefinition | undefined {
return validateMcpServerDefinitionDetailed(value).value;
}
export function validateMcpServerDefinitionsDetailed(value: unknown, path = "servers"): McpValidationResult<McpServerDefinition[]> {
if (!Array.isArray(value)) {
return { errors: [mcpError(path, "invalid-shape", "MCP servers must be an array")] };
}
const errors: McpValidationError[] = [];
const out: McpServerDefinition[] = [];
const names = new Set<string>();
value.forEach((entry, index) => {
const result = validateMcpServerDefinitionDetailed(entry, `${path}.${index}`);
errors.push(...result.errors);
if (!result.value) return;
if (names.has(result.value.name)) {
errors.push(mcpError(`${path}.${index}.name`, "duplicate-name", `Duplicate MCP server name: ${result.value.name}`));
return;
}
names.add(result.value.name);
out.push(result.value);
});
return errors.length > 0 ? { errors } : { value: out, errors: [] };
}
/** Returns unique normalized MCP server definitions, otherwise undefined. */
export function validateMcpServerDefinitions(value: unknown): McpServerDefinition[] | undefined {
return validateMcpServerDefinitionsDetailed(value).value;
}
export function validateMcpServersSettingsDetailed(value: unknown, path = "mcpServers"): McpValidationResult<McpServersSettings> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { errors: [mcpError(path, "invalid-shape", "MCP settings must be an object")] };
}
const input = value as Record<string, unknown>;
const servers = input.servers === undefined ? { value: [], errors: [] } : validateMcpServerDefinitionsDetailed(input.servers, `${path}.servers`);
if (servers.errors.length > 0) return { errors: servers.errors };
return {
value: {
enabled: typeof input.enabled === "boolean" ? input.enabled : undefined,
servers: servers.value ?? [],
},
errors: [],
};
}
/** Returns normalized MCP settings, otherwise undefined. */
export function validateMcpServersSettings(value: unknown): McpServersSettings | undefined {
return validateMcpServersSettingsDetailed(value).value;
}

View File

@@ -5,6 +5,7 @@ import type { StalePausedReviewSignal } from "./stale-paused-review.js";
import type { StalePausedTodoSignal } from "./stale-paused-todo.js"; import type { StalePausedTodoSignal } from "./stale-paused-todo.js";
import type { StalledReviewSignal } from "./stalled-review-detector.js"; import type { StalledReviewSignal } from "./stalled-review-detector.js";
import type { TaskAgeStalenessSignal } from "./task-age-staleness.js"; import type { TaskAgeStalenessSignal } from "./task-age-staleness.js";
import type { SecretScope } from "./secrets-store.js";
export { export {
computeCapacityRisk, computeCapacityRisk,
@@ -3100,6 +3101,58 @@ export interface WorktrunkSettings {
installedBinaryPath?: string; installedBinaryPath?: string;
} }
/**
* FNXC:McpConfig 2026-06-25-00:00:
* MCP servers are trusted once enabled because downstream runtime slices may launch local commands or connect to operator-provided URLs. Store only declarations here; sensitive env, header, and token material MUST be represented as Fusion-managed secret references, never inline plaintext.
*/
export interface McpSecretRef {
secretRef: string;
scope: SecretScope;
}
export function isMcpSecretRef(value: unknown): value is McpSecretRef {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.secretRef === "string" &&
candidate.secretRef.trim().length > 0 &&
(candidate.scope === "project" || candidate.scope === "global")
);
}
export type McpSensitiveValue = McpSecretRef | string;
export interface McpStdioTransport {
transport: "stdio";
command: string;
args?: string[];
env?: Record<string, McpSensitiveValue>;
}
export interface McpSseTransport {
transport: "sse";
url: string;
headers?: Record<string, McpSensitiveValue>;
}
export interface McpStreamableHttpTransport {
transport: "streamable-http";
url: string;
headers?: Record<string, McpSensitiveValue>;
}
export type McpTransport = McpStdioTransport | McpSseTransport | McpStreamableHttpTransport;
export type McpServerDefinition = {
name: string;
enabled?: boolean;
} & McpTransport;
export interface McpServersSettings {
enabled?: boolean;
servers?: McpServerDefinition[];
}
export interface GlobalSettings { export interface GlobalSettings {
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */ /** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode; themeMode?: ThemeMode;
@@ -3492,6 +3545,10 @@ export interface GlobalSettings {
* Stores both provider configs, active provider selection, token strategy, * Stores both provider configs, active provider selection, token strategy,
* and lifecycle restart metadata for remote tunnel orchestration. */ * and lifecycle restart metadata for remote tunnel orchestration. */
remoteAccess?: RemoteAccessProjectSettings; remoteAccess?: RemoteAccessProjectSettings;
/** Global defaults for user-configurable MCP servers.
* Project-level `mcpServers` entries override by server name and may disable
* a global server without deleting the global declaration. */
mcpServers?: McpServersSettings;
/** Global defaults for worktrunk integration. /** Global defaults for worktrunk integration.
* Merged with project-level `worktrunk` field-by-field in `getSettings()`/ * Merged with project-level `worktrunk` field-by-field in `getSettings()`/
* `getSettingsFast()` so partial project overrides inherit unspecified fields. */ * `getSettingsFast()` so partial project overrides inherit unspecified fields. */
@@ -3792,6 +3849,10 @@ export interface ProjectSettings {
researchSettings?: ResearchProjectSettings; researchSettings?: ResearchProjectSettings;
/** Optional per-project `.env` materialization settings for exportable secrets. */ /** Optional per-project `.env` materialization settings for exportable secrets. */
secretsEnv?: SecretsEnvSettings; secretsEnv?: SecretsEnvSettings;
/** Project-scoped MCP server overrides.
* Entries override global server declarations by name; `enabled: false` on a
* same-named entry disables that server for this project. */
mcpServers?: McpServersSettings;
/** Sandbox command-execution settings. /** Sandbox command-execution settings.
* When omitted, runtime behavior is preserved via native passthrough defaults. */ * When omitted, runtime behavior is preserved via native passthrough defaults. */
sandbox?: SandboxProjectSettings; sandbox?: SandboxProjectSettings;
@@ -4584,6 +4645,7 @@ export {
resolvePersistAgentThinkingLog, resolvePersistAgentThinkingLog,
sanitizeCliAgentSettings, sanitizeCliAgentSettings,
sanitizeCliAgentsSettings, sanitizeCliAgentsSettings,
sanitizeMcpServers,
CLI_AGENT_ADAPTER_IDS, CLI_AGENT_ADAPTER_IDS,
CLI_AGENT_AUTONOMY_MODES, CLI_AGENT_AUTONOMY_MODES,
} from "./settings-schema.js"; } from "./settings-schema.js";