Files
boocode/apps/server/src/services/mcp-config.ts
indifferentketchup 203cfd2fa8 feat: DeepSeek API integration + Whale lift (hooks, tool repair, MCP permissions, token tracking)
DeepSeek API:
- @ai-sdk/deepseek provider replaces openai-compatible for deepseek-* models
- Token tracking: cache_hit/reasoning tokens flow API → DB → WS frames → UI
- thinking effort levels (off/low/medium/high/xhigh/max) via AGENTS.md frontmatter
- V4 models: deepseek-v4-flash, deepseek-v4-pro
- Wired for both chat and coder panes

Whale lifts:
- Tool input repair (schema-based type coercion, markdown link unwrapping)
- Hooks system (6 lifecycle events, shell exec, JSON stdin/stdout contract)
- Per-MCP-server permissions (allow/ask/deny)
- token tracking UI (cache N, think N in message stats line)

Infra:
- New DB columns: messages.cache_tokens, messages.reasoning_tokens
- New WS frame fields: cache_tokens, reasoning_tokens on message_complete
- coder provider snapshot merges DeepSeek models alongside llama-swap
2026-06-08 01:24:23 +00:00

142 lines
4.7 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 McpPermissionSchema = z.enum(['allow', 'ask', 'deny']).default('allow');
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),
permission: McpPermissionSchema,
}),
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),
permission: McpPermissionSchema,
}),
]);
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;
}