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:
7
.changeset/fn-7022-mcp-core-foundation.md
Normal file
7
.changeset/fn-7022-mcp-core-foundation.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
199
packages/core/src/__tests__/mcp-config.test.ts
Normal file
199
packages/core/src/__tests__/mcp-config.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
366
packages/core/src/mcp-config.ts
Normal file
366
packages/core/src/mcp-config.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user