- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
/**
|
|
* v1.15.0-mcp-multi: MCP config file schema + loader.
|
|
*
|
|
* Reads a JSON config file (default `/data/mcp.json`) that declares MCP
|
|
* servers — their transport type, connection parameters, and enabled state.
|
|
* Schema shape matches opencode's `mcpServers` key for copy-paste compat.
|
|
*
|
|
* Secrets stay out of the config file via `{env:VAR}` substitution
|
|
* (opencode-compatible). Any string value can reference an environment
|
|
* variable, e.g. a header `"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"`
|
|
* resolves from `process.env` at load. This keeps real keys in `.env`
|
|
* (`env_file` in docker-compose) rather than the gitignored config.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { z } from 'zod';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
|
|
// ---- Zod schema ----
|
|
|
|
const McpServerConfigSchema = z.discriminatedUnion('type', [
|
|
z.object({
|
|
type: z.literal('streamableHttp'),
|
|
url: z.string().url(),
|
|
headers: z.record(z.string()).optional(),
|
|
enabled: z.boolean().default(true),
|
|
}),
|
|
z.object({
|
|
type: z.literal('stdio'),
|
|
command: z.string().min(1),
|
|
args: z.array(z.string()).default([]),
|
|
env: z.record(z.string()).optional(),
|
|
enabled: z.boolean().default(true),
|
|
}),
|
|
]);
|
|
|
|
const McpConfigSchema = z.object({
|
|
mcpServers: z.record(z.string(), McpServerConfigSchema).default({}),
|
|
});
|
|
|
|
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
|
|
|
export interface McpServerEntry {
|
|
name: string;
|
|
config: McpServerConfig;
|
|
}
|
|
|
|
// ---- Env-var substitution ----
|
|
|
|
const ENV_VAR_PATTERN = /\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
|
|
/**
|
|
* Recursively replace `{env:VAR}` references in string values with the
|
|
* matching environment variable (opencode-compatible). Runs before Zod
|
|
* validation so a resolved value (e.g. a `{env:...}` URL) still validates.
|
|
* An unset var resolves to '' and logs a warning so a missing secret is
|
|
* visible in the boot log rather than silently sending a literal placeholder.
|
|
* Pass an optional `unsetVars` set to collect the names that resolved to '';
|
|
* the loader surfaces them on a validation failure (an empty value in a strict
|
|
* url/command field invalidates the whole config — see loadMcpConfig).
|
|
*/
|
|
export function substituteEnvVars(
|
|
value: unknown,
|
|
log: FastifyBaseLogger,
|
|
unsetVars?: Set<string>,
|
|
): unknown {
|
|
if (typeof value === 'string') {
|
|
return value.replace(ENV_VAR_PATTERN, (_match, name: string) => {
|
|
const resolved = process.env[name];
|
|
if (resolved === undefined) {
|
|
unsetVars?.add(name);
|
|
log.warn(`mcp: env var ${name} referenced in config is unset; substituting empty string`);
|
|
return '';
|
|
}
|
|
return resolved;
|
|
});
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((v) => substituteEnvVars(v, log, unsetVars));
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
out[k] = substituteEnvVars(v, log, unsetVars);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// ---- Loader ----
|
|
|
|
/**
|
|
* Read and validate the MCP config file. Returns enabled servers only.
|
|
* File missing → log info, return []. Parse/validation error → log warn, return [].
|
|
*/
|
|
export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpServerEntry[] {
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(configPath, 'utf8');
|
|
} catch {
|
|
log.info(`mcp: config not found at ${configPath}, skipping`);
|
|
return [];
|
|
}
|
|
|
|
let json: unknown;
|
|
try {
|
|
json = JSON.parse(raw);
|
|
} catch (err) {
|
|
log.warn({ err }, `mcp: failed to parse ${configPath} as JSON`);
|
|
return [];
|
|
}
|
|
|
|
const unsetVars = new Set<string>();
|
|
const result = McpConfigSchema.safeParse(substituteEnvVars(json, log, unsetVars));
|
|
if (!result.success) {
|
|
// Connect the two otherwise-disconnected warnings: an unset {env:VAR} that
|
|
// resolved to '' can invalidate a strict field (url/command) and drop the
|
|
// whole config, so name the unset vars alongside the validation errors.
|
|
const hint = unsetVars.size
|
|
? ` — ${unsetVars.size} referenced env var(s) unset & substituted with '' (${[...unsetVars].join(', ')}); an unset {env:VAR} in a url/command field invalidates the whole config`
|
|
: '';
|
|
log.warn(
|
|
{ errors: result.error.flatten().fieldErrors, unsetEnvVars: [...unsetVars] },
|
|
`mcp: invalid config at ${configPath}${hint}`,
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const entries: McpServerEntry[] = [];
|
|
for (const [name, config] of Object.entries(result.data.mcpServers)) {
|
|
if (config.enabled) {
|
|
entries.push({ name, config });
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|