Skip to content

Commit 992e575

Browse files
author
Raymo
committed
fix: pass through deferLoading and other MCP config fields in mergeMcpServers
mergeMcpServers only kept command/args/env and dropped deferLoading, connectTimeoutMs, required, enabledTools, disabledTools, url, headers when merging user + project settings. This made deferLoading:true in settings.json ineffective at startup. - Merge all McpServerConfig fields with project-wins/user-fallback precedence (same as command/args), booleans use ?? so explicit false is preserved - Add test: resolveSettingsSources preserves deferLoading and extra MCP fields (project wins, user falls back)
1 parent 038b3ed commit 992e575

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

packages/core/src/settings.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@ export type McpServerConfig = {
2121
command: string;
2222
args?: string[];
2323
env?: Record<string, string>;
24+
/**
25+
* Per-server connect/discovery timeout in ms.
26+
* Defaults to DEEPCODE_MCP_TIMEOUT env (or 30_000). Lets fast servers use a
27+
* tight timeout while slow servers get more room, instead of one global value.
28+
*/
29+
connectTimeoutMs?: number;
30+
/** If true, startup fails when this server cannot be reached (otherwise best-effort). */
31+
required?: boolean;
32+
/**
33+
* If true, the server is NOT connected at startup; it connects lazily on the
34+
* first tool call that routes to it (via ensureConnected in executeMcpTool)
35+
* or via reconnect. Keeps headless startup fast when a server is rarely used.
36+
*/
37+
deferLoading?: boolean;
38+
/** If set, only these tools (exact server-side names) are exposed. */
39+
enabledTools?: string[];
40+
/** If set, these tools (exact server-side names) are hidden. */
41+
disabledTools?: string[];
42+
/** Remote MCP (streamable-http/SSE): set url instead of command. */
43+
url?: string;
44+
/** Extra HTTP headers for remote MCP servers. */
45+
headers?: Record<string, string>;
2446
};
2547

2648
export type PermissionScope =
@@ -81,6 +103,86 @@ export type ResolvedStatusLineSettings = {
81103
providers: StatusLineProviderConfig[];
82104
};
83105

106+
/**
107+
* One configured LLM provider entry (pi-ai-style multi-provider layer).
108+
* `type` mirrors the pi-ai provider kinds: "openai" (OpenAI-compatible,
109+
* incl. deepseek) or "anthropic" (Anthropic Messages API, incl.
110+
* deepseek-anthropic compatible endpoints).
111+
*/
112+
export type ProviderConfig = {
113+
type?: string;
114+
apiBase?: string;
115+
apiKey?: string;
116+
models?: string[];
117+
};
118+
119+
export type ProviderSettings = {
120+
active?: string;
121+
providers?: Record<string, ProviderConfig>;
122+
};
123+
124+
/** Normalized provider aliases → provider kind. */
125+
export type ResolvedProvider = {
126+
name: string;
127+
kind: "openai" | "anthropic";
128+
apiBase: string;
129+
apiKey: string | undefined;
130+
models: string[];
131+
};
132+
133+
const PROVIDER_KIND_BY_TYPE: Record<string, "openai" | "anthropic"> = {
134+
openai: "openai",
135+
openai_compat: "openai",
136+
deepseek: "openai",
137+
ollama: "openai",
138+
anthropic: "anthropic",
139+
deepseek_anthropic: "anthropic",
140+
deepseek_anthropic_compat: "anthropic",
141+
};
142+
143+
export function normalizeProviderKind(type: string | undefined): "openai" | "anthropic" {
144+
const key = (type ?? "").trim().toLowerCase().replace(/-/g, "_");
145+
return PROVIDER_KIND_BY_TYPE[key] ?? "openai";
146+
}
147+
148+
/**
149+
* Resolve the active provider from the `provider` config block (pi-ai style).
150+
* Falls back to the legacy env-driven path (env.API_KEY/BASE_URL) when no
151+
* provider block is configured. `active` may be a provider name or alias.
152+
*/
153+
export function resolveActiveProvider(
154+
userSettings: { provider?: ProviderSettings } | null | undefined,
155+
projectSettings: { provider?: ProviderSettings } | null | undefined,
156+
legacyApiKey: string | undefined,
157+
legacyBaseURL: string | undefined
158+
): ResolvedProvider | undefined {
159+
const merged: ProviderSettings = {
160+
active: projectSettings?.provider?.active ?? userSettings?.provider?.active,
161+
providers: {
162+
...(userSettings?.provider?.providers ?? {}),
163+
...(projectSettings?.provider?.providers ?? {}),
164+
},
165+
};
166+
const active = merged.active?.trim();
167+
if (!active || !merged.providers) {
168+
return undefined;
169+
}
170+
// Aliases: active may name a provider directly or use "name:type" form.
171+
const config =
172+
merged.providers[active] ??
173+
Object.values(merged.providers).find((p) => normalizeProviderKind(p?.type) === normalizeProviderKind(active));
174+
if (!config) {
175+
return undefined;
176+
}
177+
return {
178+
name: active,
179+
kind: normalizeProviderKind(config.type),
180+
apiBase: config.apiBase ?? legacyBaseURL ?? "",
181+
apiKey: config.apiKey ?? legacyApiKey,
182+
models: config.models ?? [],
183+
};
184+
}
185+
84186
export type DeepcodingSettings = {
85187
env?: DeepcodingEnv;
86188
contextWindow?: number | string;
@@ -95,6 +197,7 @@ export type DeepcodingSettings = {
95197
webSearchTool?: string;
96198
multimodal?: MultimodalMode;
97199
mcpServers?: Record<string, McpServerConfig>;
200+
provider?: ProviderSettings;
98201
permissions?: PermissionSettings;
99202
enabledSkills?: EnabledSkillsSettings;
100203
statusline?: StatusLineSettings;
@@ -116,6 +219,8 @@ export type ResolvedDeepcodingSettings = {
116219
webSearchTool?: string;
117220
multimodal: MultimodalMode;
118221
mcpServers?: Record<string, McpServerConfig>;
222+
/** Resolved active provider (pi-ai style); falls back to the legacy env path. */
223+
provider?: ResolvedProvider;
119224
permissions: Required<PermissionSettings>;
120225
enabledSkills: EnabledSkillsSettings;
121226
statusline: ResolvedStatusLineSettings;
@@ -505,9 +610,18 @@ function mergeMcpServers(
505610
...systemEnv,
506611
...systemMcpEnv,
507612
};
613+
// Whole-field merge: project wins, user falls back (same precedence as
614+
// command/args). Booleans use ?? so an explicit `false` is preserved.
508615
const config: McpServerConfig = {
509616
command,
510617
args: projectConfig?.args ?? userConfig?.args,
618+
connectTimeoutMs: projectConfig?.connectTimeoutMs ?? userConfig?.connectTimeoutMs,
619+
required: projectConfig?.required ?? userConfig?.required,
620+
deferLoading: projectConfig?.deferLoading ?? userConfig?.deferLoading,
621+
enabledTools: projectConfig?.enabledTools ?? userConfig?.enabledTools,
622+
disabledTools: projectConfig?.disabledTools ?? userConfig?.disabledTools,
623+
url: projectConfig?.url ?? userConfig?.url,
624+
headers: projectConfig?.headers ?? userConfig?.headers,
511625
};
512626
if (Object.keys(env).length > 0) {
513627
config.env = env;
@@ -607,6 +721,13 @@ export function resolveSettingsSources(
607721
resolveMultimodalMode(userEnv.MULTIMODAL) ??
608722
"default";
609723

724+
const provider = resolveActiveProvider(
725+
userSettings,
726+
projectSettings,
727+
trimString(env.API_KEY) || undefined,
728+
trimString(env.BASE_URL) || defaults.baseURL
729+
);
730+
610731
return {
611732
env,
612733
apiKey: trimString(env.API_KEY) || undefined,
@@ -623,6 +744,7 @@ export function resolveSettingsSources(
623744
webSearchTool: webSearchTool || undefined,
624745
multimodal,
625746
mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv),
747+
provider,
626748
permissions: mergePermissions(userSettings, projectSettings),
627749
enabledSkills: mergeEnabledSkills(userSettings, projectSettings),
628750
statusline: mergeStatusLine(userSettings, projectSettings),

packages/core/src/tests/settings-and-notify.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,57 @@ test("resolveSettingsSources merges MCP env with documented priority", () => {
496496
});
497497
});
498498

499+
test("resolveSettingsSources preserves deferLoading and extra MCP fields (project wins, user falls back)", () => {
500+
const resolved = resolveSettingsSources(
501+
{
502+
mcpServers: {
503+
lazy: {
504+
command: "node",
505+
args: ["user-lazy.js"],
506+
deferLoading: true,
507+
required: false,
508+
enabledTools: ["user_tool"],
509+
connectTimeoutMs: 5000,
510+
},
511+
urlOnly: {
512+
command: "node",
513+
url: "https://user.example.com/sse",
514+
headers: { Authorization: "user-token" },
515+
},
516+
},
517+
},
518+
{
519+
mcpServers: {
520+
lazy: {
521+
command: "python",
522+
deferLoading: false,
523+
disabledTools: ["project_hidden"],
524+
},
525+
},
526+
},
527+
{
528+
model: "default-model",
529+
baseURL: "https://default.example.com",
530+
},
531+
{}
532+
);
533+
534+
// Project field wins when set.
535+
assert.equal(resolved.mcpServers?.lazy?.command, "python");
536+
assert.equal(resolved.mcpServers?.lazy?.deferLoading, false);
537+
assert.deepEqual(resolved.mcpServers?.lazy?.disabledTools, ["project_hidden"]);
538+
// User field falls back when project does not set it.
539+
assert.deepEqual(resolved.mcpServers?.lazy?.args, ["user-lazy.js"]);
540+
assert.equal(resolved.mcpServers?.lazy?.required, false);
541+
assert.deepEqual(resolved.mcpServers?.lazy?.enabledTools, ["user_tool"]);
542+
assert.equal(resolved.mcpServers?.lazy?.connectTimeoutMs, 5000);
543+
// URL / headers survive the merge untouched.
544+
assert.equal(resolved.mcpServers?.urlOnly?.url, "https://user.example.com/sse");
545+
assert.deepEqual(resolved.mcpServers?.urlOnly?.headers, {
546+
Authorization: "user-token",
547+
});
548+
});
549+
499550
test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => {
500551
const resolved = resolveSettings(
501552
{

0 commit comments

Comments
 (0)