pi-mcp-adapter 5.3.0 → 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/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,6 +164,75 @@ function legacyMcpConfigHasContent(filePath) {
162
164
  }
163
165
  }
164
166
  export function getLegacyMcpMigrationNotices(cwd = process.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;
234
+ }
235
+ function getPiMcpConfigNotices(cwd, overridePath) {
165
236
  if (piMcpConfigEnabled) {
166
237
  return getConfigSources(overridePath, cwd).flatMap((source) => {
167
238
  const file = isPiMcpSource(source.id) ? readPiMcpConfig(source.readPath) : null;
@@ -196,6 +267,32 @@ export function getLegacyMcpMigrationNotices(cwd = process.cwd(), overridePath)
196
267
  return [`pi-mcp-adapter no longer reads ${source}. ${fix}`];
197
268
  });
198
269
  }
270
+ /** Settings OpenCode drops from an active `opencode` import, grouped by the file that defines each server. */
271
+ function getOpenCodeImportNotices(cwd, overridePath) {
272
+ const active = (!isExclusiveConfigMode() && getConfiguredHostConfigDiscovery(overridePath, cwd) === "on")
273
+ || getConfigSources(overridePath, cwd).some((source) => readSourceConfig(source.id, source.readPath)?.imports?.includes("opencode"));
274
+ const imported = active ? loadImportedConfig("opencode", cwd, "Failed to inspect imported MCP config from opencode:") : null;
275
+ if (!imported)
276
+ return [];
277
+ // Warn only where the OpenCode import is still the server's definition, matching /mcp-adapter.
278
+ const provenance = getServerProvenance(overridePath, cwd);
279
+ // Name the file each key came from: a project entry can inherit a stray key from the global file.
280
+ const byFile = new Map();
281
+ for (const [name, keys] of getOpenCodeIgnoredSettings(imported.value)) {
282
+ if (provenance.get(name)?.ignoredSettingsSource !== "opencode")
283
+ continue;
284
+ for (const key of keys) {
285
+ const path = imported.keySources?.get(name)?.get(key) ?? imported.path;
286
+ const servers = byFile.get(path) ?? new Map();
287
+ servers.set(name, [...servers.get(name) ?? [], key]);
288
+ byFile.set(path, servers);
289
+ }
290
+ }
291
+ return [...byFile].map(([path, servers]) => {
292
+ const ignored = [...servers].map(([name, settings]) => `"${name}": ${settings.join(", ")}`);
293
+ return `${path}: OpenCode ignores these settings, so the import does too (details in /mcp-adapter): ${ignored.join("; ")}.`;
294
+ });
295
+ }
199
296
  export function getSharedConfigPath(target, cwd = process.cwd()) {
200
297
  return target === "project" ? getProjectConfigPath(cwd) : getGenericGlobalConfigPath();
201
298
  }
@@ -750,6 +847,21 @@ function mergeConfigs(base, next) {
750
847
  // otherwise the original endpoint's credentials would be shipped to the new
751
848
  // url. See the SECURITY note in mergeServerMaps.
752
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
+ }
753
865
  function mergeServerMaps(base, next) {
754
866
  const merged = { ...base };
755
867
  for (const [name, definition] of Object.entries(next)) {
@@ -795,15 +907,11 @@ function mergeServerMaps(base, next) {
795
907
  if (existing && typeof definition.url === "string" && definition.url !== existing.url) {
796
908
  if (baseEntry === existing)
797
909
  baseEntry = { ...existing };
798
- 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)) {
799
913
  delete baseEntry[field];
800
914
  }
801
- // A provider token is bound to the url that asked for it, like the fields above.
802
- if (typeof baseEntry.auth === "object")
803
- delete baseEntry.auth;
804
- if (baseEntry.oauth !== false) {
805
- delete baseEntry.oauth;
806
- }
807
915
  }
808
916
  if (existing && Object.hasOwn(definition, "env") && isBuiltInAgentPlugin(existing, "env") && !Object.hasOwn(definition, "literalEnv")) {
809
917
  if (baseEntry === existing)
@@ -825,6 +933,7 @@ function expandImports(config, cwd = process.cwd()) {
825
933
  return { config, serverSources: new Map() };
826
934
  const importedServers = {};
827
935
  const serverSources = new Map();
936
+ const keySources = new Map();
828
937
  for (const importKind of config.imports) {
829
938
  const imported = loadImportedConfig(importKind, cwd, `Failed to import MCP config from ${importKind}:`);
830
939
  if (!imported)
@@ -834,6 +943,9 @@ function expandImports(config, cwd = process.cwd()) {
834
943
  if (!importedServers[name]) {
835
944
  importedServers[name] = definition;
836
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);
837
949
  }
838
950
  }
839
951
  }
@@ -845,6 +957,7 @@ function expandImports(config, cwd = process.cwd()) {
845
957
  mcpServers: mergeServerMaps(importedServers, config.mcpServers),
846
958
  },
847
959
  serverSources,
960
+ keySources,
848
961
  };
849
962
  }
850
963
  function resolveImportCandidates(importKind, cwd) {
@@ -886,6 +999,7 @@ function loadImportedConfig(importKind, cwd, warningPrefix) {
886
999
  let merged = {};
887
1000
  let highestPrecedenceSource;
888
1001
  const serverSources = new Map();
1002
+ const keySources = new Map();
889
1003
  for (const source of resolveImportCandidates(importKind, cwd)) {
890
1004
  const { path } = source;
891
1005
  if (!existsSync(path))
@@ -900,8 +1014,14 @@ function loadImportedConfig(importKind, cwd, warningPrefix) {
900
1014
  const entries = isRecord(mcp.servers)
901
1015
  ? { ...Object.fromEntries(Object.entries(mcp).filter(([name]) => name !== "servers" && name !== "timeout")), ...mcp.servers }
902
1016
  : mcp;
903
- const normalized = Object.fromEntries(Object.entries(entries).map(([name, entry]) => {
904
- if (!isRecord(entry) || !isRecord(entry.oauth))
1017
+ const normalized = Object.fromEntries(Object.entries(entries).map(([name, raw]) => {
1018
+ if (!isRecord(raw))
1019
+ return [name, raw];
1020
+ // OpenCode reads only `environment`; the import also accepts the common `env` spelling.
1021
+ // `environment` wins when both are set, since that is what OpenCode runs.
1022
+ const { env, ...rest } = raw;
1023
+ const entry = env !== undefined && rest.environment === undefined ? { ...rest, environment: env } : rest;
1024
+ if (!isRecord(entry.oauth))
905
1025
  return [name, entry];
906
1026
  const { client_id, client_secret, auth_server_metadata_url, ...oauth } = entry.oauth;
907
1027
  return [name, {
@@ -915,8 +1035,15 @@ function loadImportedConfig(importKind, cwd, warningPrefix) {
915
1035
  }];
916
1036
  }));
917
1037
  merged = mergeOpenCodeConfigs(merged, { ...imported, mcp: normalized });
918
- for (const name of Object.keys(normalized))
1038
+ for (const [name, entry] of Object.entries(normalized)) {
919
1039
  serverSources.set(name, source);
1040
+ if (!isRecord(entry))
1041
+ continue;
1042
+ const keys = keySources.get(name) ?? new Map();
1043
+ for (const key of Object.keys(entry))
1044
+ keys.set(key, path);
1045
+ keySources.set(name, keys);
1046
+ }
920
1047
  highestPrecedenceSource = source;
921
1048
  }
922
1049
  }
@@ -936,6 +1063,7 @@ function loadImportedConfig(importKind, cwd, warningPrefix) {
936
1063
  value: merged,
937
1064
  source: highestPrecedenceSource,
938
1065
  serverSources,
1066
+ keySources,
939
1067
  };
940
1068
  }
941
1069
  for (const source of resolveImportCandidates(importKind, cwd)) {
@@ -1231,6 +1359,24 @@ function isRecord(value) {
1231
1359
  function isStringRecord(value) {
1232
1360
  return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
1233
1361
  }
1362
+ // OpenCode's schema allows only these keys on a server (V1 and V2 forms) and silently drops the rest.
1363
+ const OPENCODE_LOCAL_SERVER_KEYS = new Set(["type", "command", "cwd", "environment", "enabled", "disabled", "codemode", "timeout", "protocol"]);
1364
+ const OPENCODE_REMOTE_SERVER_KEYS = new Set(["type", "url", "headers", "oauth", "enabled", "disabled", "codemode", "timeout", "protocol"]);
1365
+ /** Keys of imported OpenCode servers that OpenCode ignores, so the import ignores them too. */
1366
+ function getOpenCodeIgnoredSettings(merged) {
1367
+ const ignored = new Map();
1368
+ const entries = isRecord(merged) && isRecord(merged.mcp) ? merged.mcp : {};
1369
+ for (const name of Object.keys(extractServers(merged, "opencode"))) {
1370
+ const entry = entries[name];
1371
+ if (!isRecord(entry) || (entry.type !== "local" && entry.type !== "remote"))
1372
+ continue;
1373
+ const allowed = entry.type === "local" ? OPENCODE_LOCAL_SERVER_KEYS : OPENCODE_REMOTE_SERVER_KEYS;
1374
+ const keys = Object.keys(entry).filter((key) => !allowed.has(key));
1375
+ if (keys.length > 0)
1376
+ ignored.set(name, keys);
1377
+ }
1378
+ return ignored;
1379
+ }
1234
1380
  function mergeOpenCodeConfigs(base, next) {
1235
1381
  const baseMcp = base.mcp;
1236
1382
  const nextMcp = next.mcp;
@@ -1728,16 +1874,22 @@ export function writeSharedServerEntry(filePath, serverName, entry) {
1728
1874
  export function getServerProvenance(overridePath, cwd = process.cwd()) {
1729
1875
  const provenance = new Map();
1730
1876
  const userPath = getPiGlobalConfigPath(overridePath);
1877
+ const importProvenance = (importKind, value) => {
1878
+ const ignored = importKind === "opencode" ? getOpenCodeIgnoredSettings(value) : new Map();
1879
+ return new Map(Object.keys(extractServers(value, importKind)).map((name) => {
1880
+ const ignoredSettings = ignored.get(name);
1881
+ // Keep writes inside adapter-owned storage even though the source is external.
1882
+ return [name, { path: userPath, kind: "import", importKind, ...(ignoredSettings ? { ignoredSettings, ignoredSettingsSource: "opencode" } : {}) }];
1883
+ }));
1884
+ };
1731
1885
  if (getConfiguredHostConfigDiscovery(overridePath, cwd) === "on") {
1732
1886
  for (const importKind of Object.keys(IMPORT_PATHS)) {
1733
1887
  const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
1734
1888
  if (!imported)
1735
1889
  continue;
1736
- for (const name of Object.keys(extractServers(imported.value, importKind))) {
1737
- // Keep writes inside adapter-owned storage even though the source is external.
1738
- // Later import kinds win in the same deterministic order as loadDiscoveredHostConfigs.
1739
- provenance.set(name, { path: userPath, kind: "import", importKind });
1740
- }
1890
+ // Later import kinds win in the same deterministic order as loadDiscoveredHostConfigs.
1891
+ for (const [name, prov] of importProvenance(importKind, imported.value))
1892
+ provenance.set(name, prov);
1741
1893
  }
1742
1894
  }
1743
1895
  for (const source of getConfigSources(overridePath, cwd)) {
@@ -1745,21 +1897,28 @@ export function getServerProvenance(overridePath, cwd = process.cwd()) {
1745
1897
  if (!loaded)
1746
1898
  continue;
1747
1899
  if (loaded.imports?.length) {
1900
+ // Same precedence as expandImports + mergeConfigs: the first import in a file wins, and a later file replaces earlier ones.
1901
+ const imported = new Set();
1748
1902
  for (const importKind of loaded.imports) {
1749
- const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
1750
- if (!imported)
1903
+ const importedConfig = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
1904
+ if (!importedConfig)
1751
1905
  continue;
1752
- const servers = extractServers(imported.value, importKind);
1753
- for (const name of Object.keys(servers)) {
1754
- if (!provenance.has(name)) {
1755
- provenance.set(name, { path: userPath, kind: "import", importKind });
1756
- }
1906
+ for (const [name, prov] of importProvenance(importKind, importedConfig.value)) {
1907
+ if (imported.has(name))
1908
+ continue;
1909
+ imported.add(name);
1910
+ provenance.set(name, prov);
1757
1911
  }
1758
1912
  }
1759
1913
  }
1760
1914
  for (const name of Object.keys(loaded.mcpServers)) {
1761
1915
  // A Pi entry brings its own list; other sources keep the one from the Pi entry below them.
1762
- const ignoredSettings = loaded.ignoredSettings ? loaded.ignoredSettings.get(name) : provenance.get(name)?.ignoredSettings;
1916
+ // A source that defines the server replaces an OpenCode import, so its OpenCode list no longer applies.
1917
+ // (`loaded.mcpServers` holds only the file's own servers, so a plain import keeps its list.)
1918
+ const below = provenance.get(name);
1919
+ const ignoredSettings = loaded.ignoredSettings
1920
+ ? loaded.ignoredSettings.get(name)
1921
+ : below?.ignoredSettingsSource === "opencode" ? undefined : below?.ignoredSettings;
1763
1922
  provenance.set(name, {
1764
1923
  path: source.writePath,
1765
1924
  kind: source.kind,