From 79c602d9d40833bfa7e92f0ecd480b6e78e71405 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 21:01:15 -0700 Subject: [PATCH] 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 --- .changeset/fn-7022-mcp-core-foundation.md | 7 + docs/secrets.md | 3 +- docs/settings-reference.md | 23 ++ .../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(-) create mode 100644 .changeset/fn-7022-mcp-core-foundation.md create mode 100644 packages/core/src/__tests__/mcp-config.test.ts create mode 100644 packages/core/src/mcp-config.ts diff --git a/.changeset/fn-7022-mcp-core-foundation.md b/.changeset/fn-7022-mcp-core-foundation.md new file mode 100644 index 0000000000..47a7168d33 --- /dev/null +++ b/.changeset/fn-7022-mcp-core-foundation.md @@ -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. diff --git a/docs/secrets.md b/docs/secrets.md index 4cb59c2985..ec3147b669 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -38,6 +38,7 @@ Threat-model baseline: - Secret plaintext is **not** stored in SQLite. - Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal. - 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). @@ -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 `\n\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. -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 diff --git a/docs/settings-reference.md b/docs/settings-reference.md index b2e930774d..5461376501 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | | `experimentalFeatures` | `Record` | `{}` | 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. | +| `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. | +### 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) 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). | | `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). | +| `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. | | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | diff --git a/packages/core/src/__tests__/mcp-config.test.ts b/packages/core/src/__tests__/mcp-config.test.ts new file mode 100644 index 0000000000..d2cbb83de6 --- /dev/null +++ b/packages/core/src/__tests__/mcp-config.test.ts @@ -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)?.env).not.toHaveProperty("MISSING"); + expect(resolved.errors).toHaveLength(1); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4df0f26a9e..cd842db8b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 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 { 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, 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 { resolveEntryPointBranchAssignment, @@ -37,6 +37,25 @@ export { validateWorktrunkSettings, DEFAULT_WORKTRUNK_SETTINGS, } 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 { resolveAgentMemoryInclusionMode, type AgentMemoryInclusionModeSource, @@ -918,8 +937,15 @@ export { validateSandboxFailureMode, validateSandboxPolicy, validateSandboxProjectSettings, + validateMcpServerDefinition, + validateMcpServerDefinitionDetailed, + validateMcpServerDefinitions, + validateMcpServerDefinitionsDetailed, + validateMcpServersSettings, + validateMcpServersSettingsDetailed, validateUnavailableNodePolicy, } from "./settings-validation.js"; +export type { McpValidationError, McpValidationResult } from "./settings-validation.js"; export { parseSandboxPromptOverride, resolveSandboxBackend } from "./sandbox-prompt-override.js"; diff --git a/packages/core/src/mcp-config.ts b/packages/core/src/mcp-config.ts new file mode 100644 index 0000000000..aef147595a --- /dev/null +++ b/packages/core/src/mcp-config.ts @@ -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 { + env?: Record; +} + +export interface ResolvedMcpSseTransport extends Omit { + headers?: Record; +} + +export interface ResolvedMcpStreamableHttpTransport extends Omit { + headers?: Record; +} + +export type ResolvedMcpServerDefinition = { + name: string; + enabled?: boolean; +} & (ResolvedMcpStdioTransport | ResolvedMcpSseTransport | ResolvedMcpStreamableHttpTransport); + +export interface McpSecretResolutionError { + serverName: string; + path: string; + secretRef: McpSecretRef; + message: string; +} + +export interface McpSecretResolutionResult { + 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 | null, + projectSettings?: Pick | 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(); + 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; + secrets: McpSecretReader; + reader: McpSecretReaderIdentity; +}): Promise | undefined>> { + const { values, secrets, reader, serverName, path } = params; + if (!values) return { value: undefined, errors: [] }; + const resolved: Record = {}; + 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> { + 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> { + 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 | 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 = {}; + for (const [key, raw] of Object.entries(value as Record)) { + 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).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(); + for (const [name, rawServer] of Object.entries(servers as Record)) { + if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer)) { + errors.push(`${name} must be an object`); + continue; + } + const raw = rawServer as Record; + 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 | undefined): Record | undefined { + if (!values) return undefined; + const out: Record = {}; + 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 } { + const mcpServers: Record = {}; + 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 }; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 817a576160..0de95f06e8 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -1,5 +1,5 @@ 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 { mergeRequestContractShadowEnabled?: boolean; @@ -200,6 +200,10 @@ export const DEFAULT_GLOBAL_SETTINGS = { researchGlobalMaxSearchResults: 10, researchGlobalFetchTimeoutMs: 30_000, researchGlobalUserAgent: "FusionResearchBot/1.0", + mcpServers: { + enabled: false, + servers: [], + }, remoteAccess: { activeProvider: null, providers: { @@ -295,6 +299,10 @@ export const DEFAULT_PROJECT_SETTINGS = { owningNodeHandoffPolicy: "reassign-to-local", defaultNodeId: undefined, secretsEnv: undefined, + mcpServers: { + enabled: false, + servers: [], + }, worktreeInitCommand: undefined, /* FNXC:WorktreeCopyFiles 2026-06-24-00:00: @@ -698,3 +706,83 @@ export function sanitizeCliAgentsSettings(value: unknown): Record; + 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 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const out: Record = {}; + for (const [rawKey, rawValue] of Object.entries(value as Record)) { + 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; + 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; + const byName = new Map(); + 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()], + }; +} diff --git a/packages/core/src/settings-validation.ts b/packages/core/src/settings-validation.ts index 2ca592b6a9..a9e050b092 100644 --- a/packages/core/src/settings-validation.ts +++ b/packages/core/src/settings-validation.ts @@ -4,13 +4,16 @@ import type { HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, Locale, + McpSensitiveValue, + McpServerDefinition, + McpServersSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, UnavailableNodePolicy, } 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 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 } : {}), }; } + +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 { + 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 { + 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 | 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 = {}; + const errors: McpValidationError[] = []; + for (const [key, entry] of Object.entries(value as Record)) { + 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 { + 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; + 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 { + 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(); + 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 { + 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; + 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; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 40af737a30..b1f06a1b3f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5,6 +5,7 @@ import type { StalePausedReviewSignal } from "./stale-paused-review.js"; import type { StalePausedTodoSignal } from "./stale-paused-todo.js"; import type { StalledReviewSignal } from "./stalled-review-detector.js"; import type { TaskAgeStalenessSignal } from "./task-age-staleness.js"; +import type { SecretScope } from "./secrets-store.js"; export { computeCapacityRisk, @@ -3100,6 +3101,58 @@ export interface WorktrunkSettings { 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; + 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; +} + +export interface McpSseTransport { + transport: "sse"; + url: string; + headers?: Record; +} + +export interface McpStreamableHttpTransport { + transport: "streamable-http"; + url: string; + headers?: Record; +} + +export type McpTransport = McpStdioTransport | McpSseTransport | McpStreamableHttpTransport; + +export type McpServerDefinition = { + name: string; + enabled?: boolean; +} & McpTransport; + +export interface McpServersSettings { + enabled?: boolean; + servers?: McpServerDefinition[]; +} + export interface GlobalSettings { /** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */ themeMode?: ThemeMode; @@ -3492,6 +3545,10 @@ export interface GlobalSettings { * Stores both provider configs, active provider selection, token strategy, * and lifecycle restart metadata for remote tunnel orchestration. */ 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. * Merged with project-level `worktrunk` field-by-field in `getSettings()`/ * `getSettingsFast()` so partial project overrides inherit unspecified fields. */ @@ -3792,6 +3849,10 @@ export interface ProjectSettings { researchSettings?: ResearchProjectSettings; /** Optional per-project `.env` materialization settings for exportable secrets. */ 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. * When omitted, runtime behavior is preserved via native passthrough defaults. */ sandbox?: SandboxProjectSettings; @@ -4584,6 +4645,7 @@ export { resolvePersistAgentThinkingLog, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, + sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, } from "./settings-schema.js";