pi-mcp-adapter 5.3.1 → 5.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.3.2] - 2026-10-17
11
+
12
+ ### Highlights
13
+
14
+ - The GitHub preset in `/mcp-adapter setup` now signs in with your GitHub CLI login, so connecting to GitHub works.
15
+ - Saving direct tools in the MCP panel no longer brings back the "Allow project MCP server?" prompt for a server you already allowed.
16
+ - The MCP panel shows a connected server's tools and your saved direct tools, even for servers like GitHub that don't let their tool list be cached.
17
+ - MCP sampling uses your Pi thinking level, so servers that sample through a reasoning-only model stop failing.
18
+
19
+ ### Fixed
20
+
21
+ - The GitHub preset in `/mcp-adapter setup` now signs in with your GitHub CLI login (`gh auth token`). Before, it used MCP OAuth, which GitHub doesn't offer to apps it hasn't registered, so `/mcp-auth github` always failed with "does not support dynamic client registration". Install `gh` and run `gh auth login` first. If you added the old preset, remove its `github` entry and add GitHub again from `/mcp-adapter setup`. Thanks to [@Milor123](https://github.com/Milor123) for [#841](https://github.com/nicobailon/pi-mcp-adapter/issues/841).
22
+ - When one config file gives a server a different `url` than another file that set its headers or token, the adapter still leaves those credentials out so they aren't sent to the new address, but it now tells you at startup which server, which settings and which two files are involved. Before, the credentials disappeared silently and the server failed with sign-in errors. Thanks to [@Milor123](https://github.com/Milor123) for [#841](https://github.com/nicobailon/pi-mcp-adapter/issues/841).
23
+ - The MCP panel now lists the tools of a server you already connected to in this session, with your saved direct tools checked, even when the server doesn't let its tool list be cached, as with GitHub. Before, such a server showed "(not cached)" with no tools every time you opened the panel, so direct tools you had saved looked lost. Thanks to [@Milor123](https://github.com/Milor123) for [#841](https://github.com/nicobailon/pi-mcp-adapter/issues/841).
24
+ - Saving direct tools in the MCP panel for a project server you already allowed no longer makes Pi ask "Allow project MCP server?" again on the next start or `/reload`. Saving used to change the server's config in a way that cancelled your approval. A server you didn't allow still asks, and so does any other change to the server's config. Thanks to [@Milor123](https://github.com/Milor123) for [#841](https://github.com/nicobailon/pi-mcp-adapter/issues/841).
25
+ - Approving a project MCP server now sticks when Pi is started from the same folder spelled with different capitalization, such as `C:\Users\Me` and `c:\users\me` on Windows. Before, each spelling counted as a separate project and asked "Allow project MCP server?" again. An approval saved under a differently capitalized spelling asks once more after this update. Thanks to [@Milor123](https://github.com/Milor123) for [#841](https://github.com/nicobailon/pi-mcp-adapter/issues/841).
26
+ - MCP sampling requests now use your current Pi thinking level, so a server that samples through a model that requires reasoning no longer fails with "Reasoning is mandatory for this endpoint and cannot be disabled". Before, sampling always ran with thinking off. On Pi 0.84 and 0.85 sampling still runs with thinking off. Thanks to [@bardic](https://github.com/bardic) for [#846](https://github.com/nicobailon/pi-mcp-adapter/issues/846).
27
+
10
28
  ## [5.3.1] - 2026-10-15
11
29
 
12
30
  ### Highlights
@@ -136,6 +154,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
136
154
  - `/mcp-adapter setup` adds Figma (desktop) to the global config instead of writing `.mcp.json` into whatever project you opened setup from.
137
155
  - `/mcp-adapter setup` warns when a server you add won't be used, because another config file already defines or disables it, or the current config mode doesn't read the file it was written to.
138
156
  - A bearer token or header value that isn't a valid HTTP header value, such as one containing a newline, no longer appears in connection errors. This covers `bearerToken` (including `!command` and `${VAR}` forms), `bearerTokenEnv`, and configured `headers`; the error now names the server and header instead.
157
+
139
158
  ## [4.0.0] - 2026-09-30
140
159
 
141
160
  ### Highlights
package/commands.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  } from "./config.ts";
27
27
  import { markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateMetadataCache, updateStatusBar, getFailureAgeSeconds, getFailureMessage, clearFailure, recordFailure } from "./init.ts";
28
28
  import { isServerInActiveFailureBackoff } from "./failure-backoff.ts";
29
- import { loadMetadataCache, reconstructPromptMetadata } from "./metadata-cache.ts";
29
+ import { loadMetadataCache, reconstructPromptMetadata, withSessionMetadata } from "./metadata-cache.ts";
30
30
  import { buildToolMetadata } from "./tool-metadata.ts";
31
31
  import { supportsOAuth, authenticate, removeAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
32
32
  import { getAuthStorageOptions, inspectAuthForUrl } from "./mcp-auth.ts";
@@ -36,7 +36,7 @@ import { findPiSignInImports, importPiSignIn } from "./pi-signin-import.ts";
36
36
  import { formatTerminalError, openPath, providerSignInMessage, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
37
37
  import { isAbortError } from "./runtime-owner.ts";
38
38
  import { resolveJevCredential } from "./jev-key-store.ts";
39
- import { describeProjectServerBlock } from "./project-server-trust.ts";
39
+ import { describeProjectServerBlock, keepProjectServerApprovals } from "./project-server-trust.ts";
40
40
 
41
41
  function terminalHyperlink(label: string, url: string): string {
42
42
  return `\u001B]8;;${sanitizeTerminalText(url)}\u001B\\${sanitizeTerminalText(label)}\u001B]8;;\u001B\\`;
@@ -792,7 +792,7 @@ export async function openMcpPanel(
792
792
  }
793
793
 
794
794
  const config = state.config;
795
- const cache = loadMetadataCache();
795
+ const cache = withSessionMetadata(loadMetadataCache(), config, state.sessionMetadata);
796
796
  const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
797
797
  const provenanceMap = getServerProvenance(configPath, ctx.cwd);
798
798
  const { lines: noticeLines, fingerprint } = buildSharedConfigNoticeLines(configPath, ctx.cwd);
@@ -838,9 +838,11 @@ export async function openMcpPanel(
838
838
  }
839
839
  }
840
840
  if (!result.cancelled && result.changes.size > 0) {
841
+ const before = loadMcpConfig(configPath, ctx.cwd);
841
842
  writeDirectToolsConfig(result.changes, provenanceMap, config, () => {
842
843
  directToolsFileWritten = true;
843
844
  });
845
+ keepProjectServerApprovals(ctx, before, loadMcpConfig(configPath, ctx.cwd), result.changes.keys());
844
846
  directToolsSaved = true;
845
847
  await onDirectToolsConfigChanged?.(result.changes);
846
848
  ctx.ui.notify("Direct tools updated for this session.", "info");
package/config.ts CHANGED
@@ -79,8 +79,10 @@ export const KNOWN_SERVER_PRESETS: readonly KnownServerPreset[] = [
79
79
  {
80
80
  id: "github",
81
81
  name: "GitHub",
82
- summary: "Work with GitHub through your Copilot account.",
83
- entry: { url: "https://api.githubcopilot.com/mcp", auth: "oauth", protocolVersion: "auto" },
82
+ summary: "Work with GitHub using your GitHub CLI sign-in. Needs `gh` installed and `gh auth login` done.",
83
+ // GitHub's OAuth server has no dynamic client registration, so MCP OAuth can't sign in;
84
+ // the GitHub CLI token works and is read at connect time, never written to config.
85
+ entry: { url: "https://api.githubcopilot.com/mcp/", auth: "bearer", bearerToken: "!gh auth token", protocolVersion: "auto" },
84
86
  },
85
87
  {
86
88
  id: "chrome-devtools",
@@ -269,7 +271,88 @@ function legacyMcpConfigHasContent(filePath: string): boolean {
269
271
  }
270
272
 
271
273
  export function getLegacyMcpMigrationNotices(cwd = process.cwd(), overridePath?: string): string[] {
272
- return [...getPiMcpConfigNotices(cwd, overridePath), ...getOpenCodeImportNotices(cwd, overridePath)];
274
+ return [
275
+ ...getPiMcpConfigNotices(cwd, overridePath),
276
+ ...getOpenCodeImportNotices(cwd, overridePath),
277
+ ...getDroppedCredentialNotices(cwd, overridePath),
278
+ ];
279
+ }
280
+
281
+ /** server name -> credential field -> the file that supplied it. */
282
+ type CredentialFiles = Map<string, Map<string, string>>;
283
+
284
+ function credentialFiles(servers: Record<string, ServerEntry>, fileOf: (name: string, field: string) => string): CredentialFiles {
285
+ return new Map(Object.entries(servers).map(([name, definition]) => [
286
+ name,
287
+ new Map(CREDENTIAL_FIELDS.filter((field) => definition[field] !== undefined).map((field) => [field, fileOf(name, field)])),
288
+ ]));
289
+ }
290
+
291
+ /**
292
+ * Replays the server merges of loadMcpConfigWithSources with the file behind each credential field,
293
+ * and reports the fields mergeServerMaps drops because a later file points the server at another url.
294
+ */
295
+ function getDroppedCredentialNotices(cwd: string, overridePath?: string): string[] {
296
+ const notices: string[] = [];
297
+ const merge = (
298
+ base: Record<string, ServerEntry>,
299
+ baseFiles: CredentialFiles,
300
+ next: Record<string, ServerEntry>,
301
+ nextFiles: CredentialFiles,
302
+ urlFile: (name: string) => string,
303
+ ): [Record<string, ServerEntry>, CredentialFiles] => {
304
+ const files: CredentialFiles = new Map(baseFiles);
305
+ for (const [name, definition] of Object.entries(next)) {
306
+ const existing = base[name];
307
+ const fieldFiles = new Map(files.get(name));
308
+ const byFile = new Map<string, string[]>();
309
+ for (const field of existing ? droppedUrlBoundAuth(existing, definition) : []) {
310
+ const from = fieldFiles.get(field);
311
+ if (from !== undefined) byFile.set(from, [...byFile.get(from) ?? [], field]);
312
+ fieldFiles.delete(field);
313
+ }
314
+ for (const [from, fields] of byFile) {
315
+ notices.push(`"${name}": ignoring ${fields.join(", ")} from ${from}, because ${urlFile(name)} sets a different url for this server. Use one url, or set ${fields.length === 1 ? "it" : "them"} next to that url.`);
316
+ }
317
+ for (const [field, path] of nextFiles.get(name) ?? []) fieldFiles.set(field, path);
318
+ files.set(name, fieldFiles);
319
+ }
320
+ return [mergeServerMaps(base, next), files];
321
+ };
322
+
323
+ const host = !isExclusiveConfigMode() && getConfiguredHostConfigDiscovery(overridePath, cwd) === "on"
324
+ ? loadDiscoveredHostConfigs(cwd)
325
+ : { config: { mcpServers: {} }, serverSources: new Map<string, ImportedServerSource>() };
326
+ const hostFile = (name: string) => host.serverSources.get(name)?.path ?? "a host config";
327
+ let servers = host.config.mcpServers;
328
+ let files = credentialFiles(servers, hostFile);
329
+ const sourceSpecs = getConfigSources(overridePath, cwd);
330
+ const piProjectSource = sourceSpecs.find((source) => source.id === "pi-mcp-project");
331
+ const piProjectNames = Object.keys((piProjectSource && readPiMcpConfig(piProjectSource.readPath))?.mcpServers ?? {});
332
+ for (const source of sourceSpecs) {
333
+ const loaded = readSourceConfig(source.id, source.readPath);
334
+ if (!loaded) continue;
335
+ if (source.id === "pi-mcp-global") {
336
+ for (const name of piProjectNames) delete loaded.mcpServers[name];
337
+ }
338
+ // The same two merges as expandImports and mergeConfigs: the file's own entries over its imports,
339
+ // then the file over the files before it.
340
+ const imported = expandImports({ ...loaded, mcpServers: {} }, cwd);
341
+ // An OpenCode project entry can inherit a credential from the global file, so name the file per key.
342
+ const importFile = (name: string, field: string) =>
343
+ imported.keySources?.get(name)?.get(field) ?? imported.serverSources.get(name)?.path ?? source.readPath;
344
+ const ownFile = () => source.readPath;
345
+ const [fileServers, fileFiles] = merge(
346
+ imported.config.mcpServers,
347
+ credentialFiles(imported.config.mcpServers, importFile),
348
+ loaded.mcpServers,
349
+ credentialFiles(loaded.mcpServers, ownFile),
350
+ ownFile,
351
+ );
352
+ const urlFile = (name: string) => typeof loaded.mcpServers[name]?.url === "string" ? source.readPath : importFile(name, "url");
353
+ [servers, files] = merge(servers, files, fileServers, fileFiles, urlFile);
354
+ }
355
+ return notices;
273
356
  }
274
357
 
275
358
  function getPiMcpConfigNotices(cwd: string, overridePath?: string): string[] {
@@ -476,6 +559,8 @@ interface ImportedServerSource {
476
559
  interface LoadedHostConfig {
477
560
  config: McpConfig;
478
561
  serverSources: Map<string, ImportedServerSource>;
562
+ /** OpenCode imports only: the file that last set each key of each server. */
563
+ keySources?: Map<string, Map<string, string>>;
479
564
  }
480
565
 
481
566
  export const MCP_CONFIG_SOURCE_METADATA = Symbol.for("pi-mcp-adapter/config-source-metadata");
@@ -939,6 +1024,18 @@ function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
939
1024
  // otherwise the original endpoint's credentials would be shipped to the new
940
1025
  // url. See the SECURITY note in mergeServerMaps.
941
1026
  const URL_BOUND_AUTH_FIELDS = ["headers", "bearerToken", "bearerTokenEnv", "bearerTokenStore", "requestHeadersCommand", "caFile"] as const;
1027
+ const CREDENTIAL_FIELDS = [...URL_BOUND_AUTH_FIELDS, "auth", "oauth"] as const;
1028
+
1029
+ /** The credential fields of `existing` that mergeServerMaps drops for `definition`'s different url. */
1030
+ function droppedUrlBoundAuth(existing: ServerEntry, definition: ServerEntry): (typeof CREDENTIAL_FIELDS)[number][] {
1031
+ if (typeof definition.url !== "string" || definition.url === existing.url) return [];
1032
+ return CREDENTIAL_FIELDS.filter((field) => {
1033
+ if (Object.hasOwn(definition, field)) return false;
1034
+ if (field === "auth") return typeof existing.auth === "object";
1035
+ if (field === "oauth") return existing.oauth !== undefined && existing.oauth !== false;
1036
+ return existing[field] !== undefined;
1037
+ });
1038
+ }
942
1039
 
943
1040
  function mergeServerMaps(
944
1041
  base: Record<string, ServerEntry>,
@@ -985,14 +1082,11 @@ function mergeServerMaps(
985
1082
  }
986
1083
  if (existing && typeof definition.url === "string" && definition.url !== existing.url) {
987
1084
  if (baseEntry === existing) baseEntry = { ...existing };
988
- for (const field of URL_BOUND_AUTH_FIELDS) {
1085
+ // Provider tokens (object `auth`) and OAuth config are bound to the url too; the startup
1086
+ // notice reports exactly these fields, so both read the one rule.
1087
+ for (const field of droppedUrlBoundAuth(existing, definition)) {
989
1088
  delete baseEntry[field];
990
1089
  }
991
- // A provider token is bound to the url that asked for it, like the fields above.
992
- if (typeof baseEntry.auth === "object") delete baseEntry.auth;
993
- if (baseEntry.oauth !== false) {
994
- delete baseEntry.oauth;
995
- }
996
1090
  }
997
1091
  if (existing && Object.hasOwn(definition, "env") && isBuiltInAgentPlugin(existing, "env") && !Object.hasOwn(definition, "literalEnv")) {
998
1092
  if (baseEntry === existing) baseEntry = { ...existing };
@@ -1014,6 +1108,7 @@ function expandImports(config: McpConfig, cwd = process.cwd()): LoadedHostConfig
1014
1108
 
1015
1109
  const importedServers: Record<string, ServerEntry> = {};
1016
1110
  const serverSources = new Map<string, ImportedServerSource>();
1111
+ const keySources = new Map<string, Map<string, string>>();
1017
1112
  for (const importKind of config.imports) {
1018
1113
  const imported = loadImportedConfig(importKind, cwd, `Failed to import MCP config from ${importKind}:`);
1019
1114
  if (!imported) continue;
@@ -1023,6 +1118,8 @@ function expandImports(config: McpConfig, cwd = process.cwd()): LoadedHostConfig
1023
1118
  if (!importedServers[name]) {
1024
1119
  importedServers[name] = definition;
1025
1120
  serverSources.set(name, imported.serverSources.get(name) ?? imported.source);
1121
+ const keys = imported.keySources?.get(name);
1122
+ if (keys) keySources.set(name, keys);
1026
1123
  }
1027
1124
  }
1028
1125
  }
@@ -1035,6 +1132,7 @@ function expandImports(config: McpConfig, cwd = process.cwd()): LoadedHostConfig
1035
1132
  mcpServers: mergeServerMaps(importedServers, config.mcpServers),
1036
1133
  },
1037
1134
  serverSources,
1135
+ keySources,
1038
1136
  };
1039
1137
  }
1040
1138
 
package/dist/config.js CHANGED
@@ -68,8 +68,10 @@ export const KNOWN_SERVER_PRESETS = [
68
68
  {
69
69
  id: "github",
70
70
  name: "GitHub",
71
- summary: "Work with GitHub through your Copilot account.",
72
- entry: { url: "https://api.githubcopilot.com/mcp", auth: "oauth", protocolVersion: "auto" },
71
+ summary: "Work with GitHub using your GitHub CLI sign-in. Needs `gh` installed and `gh auth login` done.",
72
+ // GitHub's OAuth server has no dynamic client registration, so MCP OAuth can't sign in;
73
+ // the GitHub CLI token works and is read at connect time, never written to config.
74
+ entry: { url: "https://api.githubcopilot.com/mcp/", auth: "bearer", bearerToken: "!gh auth token", protocolVersion: "auto" },
73
75
  },
74
76
  {
75
77
  id: "chrome-devtools",
@@ -162,7 +164,73 @@ function legacyMcpConfigHasContent(filePath) {
162
164
  }
163
165
  }
164
166
  export function getLegacyMcpMigrationNotices(cwd = process.cwd(), overridePath) {
165
- return [...getPiMcpConfigNotices(cwd, overridePath), ...getOpenCodeImportNotices(cwd, overridePath)];
167
+ return [
168
+ ...getPiMcpConfigNotices(cwd, overridePath),
169
+ ...getOpenCodeImportNotices(cwd, overridePath),
170
+ ...getDroppedCredentialNotices(cwd, overridePath),
171
+ ];
172
+ }
173
+ function credentialFiles(servers, fileOf) {
174
+ return new Map(Object.entries(servers).map(([name, definition]) => [
175
+ name,
176
+ new Map(CREDENTIAL_FIELDS.filter((field) => definition[field] !== undefined).map((field) => [field, fileOf(name, field)])),
177
+ ]));
178
+ }
179
+ /**
180
+ * Replays the server merges of loadMcpConfigWithSources with the file behind each credential field,
181
+ * and reports the fields mergeServerMaps drops because a later file points the server at another url.
182
+ */
183
+ function getDroppedCredentialNotices(cwd, overridePath) {
184
+ const notices = [];
185
+ const merge = (base, baseFiles, next, nextFiles, urlFile) => {
186
+ const files = new Map(baseFiles);
187
+ for (const [name, definition] of Object.entries(next)) {
188
+ const existing = base[name];
189
+ const fieldFiles = new Map(files.get(name));
190
+ const byFile = new Map();
191
+ for (const field of existing ? droppedUrlBoundAuth(existing, definition) : []) {
192
+ const from = fieldFiles.get(field);
193
+ if (from !== undefined)
194
+ byFile.set(from, [...byFile.get(from) ?? [], field]);
195
+ fieldFiles.delete(field);
196
+ }
197
+ for (const [from, fields] of byFile) {
198
+ notices.push(`"${name}": ignoring ${fields.join(", ")} from ${from}, because ${urlFile(name)} sets a different url for this server. Use one url, or set ${fields.length === 1 ? "it" : "them"} next to that url.`);
199
+ }
200
+ for (const [field, path] of nextFiles.get(name) ?? [])
201
+ fieldFiles.set(field, path);
202
+ files.set(name, fieldFiles);
203
+ }
204
+ return [mergeServerMaps(base, next), files];
205
+ };
206
+ const host = !isExclusiveConfigMode() && getConfiguredHostConfigDiscovery(overridePath, cwd) === "on"
207
+ ? loadDiscoveredHostConfigs(cwd)
208
+ : { config: { mcpServers: {} }, serverSources: new Map() };
209
+ const hostFile = (name) => host.serverSources.get(name)?.path ?? "a host config";
210
+ let servers = host.config.mcpServers;
211
+ let files = credentialFiles(servers, hostFile);
212
+ const sourceSpecs = getConfigSources(overridePath, cwd);
213
+ const piProjectSource = sourceSpecs.find((source) => source.id === "pi-mcp-project");
214
+ const piProjectNames = Object.keys((piProjectSource && readPiMcpConfig(piProjectSource.readPath))?.mcpServers ?? {});
215
+ for (const source of sourceSpecs) {
216
+ const loaded = readSourceConfig(source.id, source.readPath);
217
+ if (!loaded)
218
+ continue;
219
+ if (source.id === "pi-mcp-global") {
220
+ for (const name of piProjectNames)
221
+ delete loaded.mcpServers[name];
222
+ }
223
+ // The same two merges as expandImports and mergeConfigs: the file's own entries over its imports,
224
+ // then the file over the files before it.
225
+ const imported = expandImports({ ...loaded, mcpServers: {} }, cwd);
226
+ // An OpenCode project entry can inherit a credential from the global file, so name the file per key.
227
+ const importFile = (name, field) => imported.keySources?.get(name)?.get(field) ?? imported.serverSources.get(name)?.path ?? source.readPath;
228
+ const ownFile = () => source.readPath;
229
+ const [fileServers, fileFiles] = merge(imported.config.mcpServers, credentialFiles(imported.config.mcpServers, importFile), loaded.mcpServers, credentialFiles(loaded.mcpServers, ownFile), ownFile);
230
+ const urlFile = (name) => typeof loaded.mcpServers[name]?.url === "string" ? source.readPath : importFile(name, "url");
231
+ [servers, files] = merge(servers, files, fileServers, fileFiles, urlFile);
232
+ }
233
+ return notices;
166
234
  }
167
235
  function getPiMcpConfigNotices(cwd, overridePath) {
168
236
  if (piMcpConfigEnabled) {
@@ -779,6 +847,21 @@ function mergeConfigs(base, next) {
779
847
  // otherwise the original endpoint's credentials would be shipped to the new
780
848
  // url. See the SECURITY note in mergeServerMaps.
781
849
  const URL_BOUND_AUTH_FIELDS = ["headers", "bearerToken", "bearerTokenEnv", "bearerTokenStore", "requestHeadersCommand", "caFile"];
850
+ const CREDENTIAL_FIELDS = [...URL_BOUND_AUTH_FIELDS, "auth", "oauth"];
851
+ /** The credential fields of `existing` that mergeServerMaps drops for `definition`'s different url. */
852
+ function droppedUrlBoundAuth(existing, definition) {
853
+ if (typeof definition.url !== "string" || definition.url === existing.url)
854
+ return [];
855
+ return CREDENTIAL_FIELDS.filter((field) => {
856
+ if (Object.hasOwn(definition, field))
857
+ return false;
858
+ if (field === "auth")
859
+ return typeof existing.auth === "object";
860
+ if (field === "oauth")
861
+ return existing.oauth !== undefined && existing.oauth !== false;
862
+ return existing[field] !== undefined;
863
+ });
864
+ }
782
865
  function mergeServerMaps(base, next) {
783
866
  const merged = { ...base };
784
867
  for (const [name, definition] of Object.entries(next)) {
@@ -824,15 +907,11 @@ function mergeServerMaps(base, next) {
824
907
  if (existing && typeof definition.url === "string" && definition.url !== existing.url) {
825
908
  if (baseEntry === existing)
826
909
  baseEntry = { ...existing };
827
- for (const field of URL_BOUND_AUTH_FIELDS) {
910
+ // Provider tokens (object `auth`) and OAuth config are bound to the url too; the startup
911
+ // notice reports exactly these fields, so both read the one rule.
912
+ for (const field of droppedUrlBoundAuth(existing, definition)) {
828
913
  delete baseEntry[field];
829
914
  }
830
- // A provider token is bound to the url that asked for it, like the fields above.
831
- if (typeof baseEntry.auth === "object")
832
- delete baseEntry.auth;
833
- if (baseEntry.oauth !== false) {
834
- delete baseEntry.oauth;
835
- }
836
915
  }
837
916
  if (existing && Object.hasOwn(definition, "env") && isBuiltInAgentPlugin(existing, "env") && !Object.hasOwn(definition, "literalEnv")) {
838
917
  if (baseEntry === existing)
@@ -854,6 +933,7 @@ function expandImports(config, cwd = process.cwd()) {
854
933
  return { config, serverSources: new Map() };
855
934
  const importedServers = {};
856
935
  const serverSources = new Map();
936
+ const keySources = new Map();
857
937
  for (const importKind of config.imports) {
858
938
  const imported = loadImportedConfig(importKind, cwd, `Failed to import MCP config from ${importKind}:`);
859
939
  if (!imported)
@@ -863,6 +943,9 @@ function expandImports(config, cwd = process.cwd()) {
863
943
  if (!importedServers[name]) {
864
944
  importedServers[name] = definition;
865
945
  serverSources.set(name, imported.serverSources.get(name) ?? imported.source);
946
+ const keys = imported.keySources?.get(name);
947
+ if (keys)
948
+ keySources.set(name, keys);
866
949
  }
867
950
  }
868
951
  }
@@ -874,6 +957,7 @@ function expandImports(config, cwd = process.cwd()) {
874
957
  mcpServers: mergeServerMaps(importedServers, config.mcpServers),
875
958
  },
876
959
  serverSources,
960
+ keySources,
877
961
  };
878
962
  }
879
963
  function resolveImportCandidates(importKind, cwd) {