feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)
- 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>
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
* 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';
|
||||
@@ -38,6 +44,49 @@ export interface McpServerEntry {
|
||||
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 ----
|
||||
|
||||
/**
|
||||
@@ -61,9 +110,19 @@ export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpSe
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = McpConfigSchema.safeParse(json);
|
||||
const unsetVars = new Set<string>();
|
||||
const result = McpConfigSchema.safeParse(substituteEnvVars(json, log, unsetVars));
|
||||
if (!result.success) {
|
||||
log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`);
|
||||
// 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 [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user