Files
fusion/packages/core/src/mcp-config.ts
gsxdsm 79c602d9d4 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
2026-06-25 21:01:15 -07:00

367 lines
13 KiB
TypeScript

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 };
}