fix(setup): register OpenCode V2 configurations in mcp.servers
OpenCode V2 reads native server entries from mcp.servers, and a native entry takes precedence over a V1 mcp.<name> entry. After OpenCode migrated a configuration, setup wrote an ignored V1 entry, status reported REA as missing, and uninstall left the native entry in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a6c17ce83
commit
69d8afefc1
8 files changed
+386
-93
No files matched your search
@@ -14,11 +14,27 @@ import type { SetupClient } from "./SupportedClients.js";
|
||||
export type ClientConfigurationFormat = NonNullable<SetupClient["format"]>;
|
||||
export type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
|
||||
|
||||
/**
|
||||
* Registration entry dialect. OpenCode V2 reads V1 `mcp.<name>` entries, but a
|
||||
* document already using the native `mcp.servers` table takes native entries.
|
||||
*/
|
||||
export type ClientRegistrationDialect =
|
||||
| ClientConfigurationFormat
|
||||
| "opencode_v2";
|
||||
|
||||
/** Validated client document and its server table, preserving unrelated settings. */
|
||||
export interface ClientConfigurationDocument {
|
||||
readonly document: Record<string, unknown>;
|
||||
/** Server table that registrations are written to. */
|
||||
readonly servers: Record<string, unknown>;
|
||||
readonly serversKey: ClientServersKey;
|
||||
/** Key path of `servers` in `document`. */
|
||||
readonly serversPath: readonly string[];
|
||||
readonly dialect: ClientRegistrationDialect;
|
||||
/**
|
||||
* OpenCode V1 server members beside a native V2 `mcp.servers` table. OpenCode
|
||||
* still loads them, and a native entry with the same name takes precedence.
|
||||
*/
|
||||
readonly legacyServers: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const objectSchema = z.record(z.string(), z.unknown());
|
||||
@@ -77,6 +93,9 @@ const parseDocument = (
|
||||
return objectSchema.parse(document);
|
||||
};
|
||||
|
||||
/** Members of OpenCode V2's `mcp` object that are not server names. */
|
||||
const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]);
|
||||
|
||||
/** Parse a client document and reject malformed roots or MCP server tables. */
|
||||
export const parseClientConfiguration = (
|
||||
text: string,
|
||||
@@ -88,7 +107,71 @@ export const parseClientConfiguration = (
|
||||
const serversKey = clientConfigurationServersKey(format);
|
||||
const value = document[serversKey];
|
||||
const servers = value === undefined ? {} : objectSchema.parse(value);
|
||||
return { document, servers, serversKey };
|
||||
const native = servers.servers;
|
||||
// A V1 server named `servers` is an entry with a `type`; the V2 table is not.
|
||||
if (
|
||||
format !== "opencode" ||
|
||||
native === undefined ||
|
||||
(typeof native === "object" && native !== null && "type" in native)
|
||||
)
|
||||
return {
|
||||
document,
|
||||
servers,
|
||||
serversPath: [serversKey],
|
||||
dialect: format,
|
||||
legacyServers: {},
|
||||
};
|
||||
return {
|
||||
document,
|
||||
servers: objectSchema.parse(native),
|
||||
serversPath: [serversKey, "servers"],
|
||||
dialect: "opencode_v2",
|
||||
legacyServers: Object.fromEntries(
|
||||
Object.entries(servers).filter(
|
||||
([name]) => !OPENCODE_V2_MCP_SETTINGS.has(name),
|
||||
),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** The registration a client loads for `name`, including OpenCode V1 entries. */
|
||||
export const effectiveClientServer = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
name: string,
|
||||
): unknown =>
|
||||
Object.hasOwn(parsed.servers, name)
|
||||
? parsed.servers[name]
|
||||
: parsed.legacyServers[name];
|
||||
|
||||
/**
|
||||
* Return `parsed.document` with `servers` at its server table. A legacy OpenCode
|
||||
* V1 member named in `removeLegacy` is removed beside a native V2 table.
|
||||
*/
|
||||
export const withClientServers = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
servers: Record<string, unknown>,
|
||||
removeLegacy?: string,
|
||||
): Record<string, unknown> => {
|
||||
const [key = "", nested] = parsed.serversPath;
|
||||
if (nested === undefined) return { ...parsed.document, [key]: servers };
|
||||
const outer = { ...objectSchema.parse(parsed.document[key]) };
|
||||
if (removeLegacy !== undefined && !OPENCODE_V2_MCP_SETTINGS.has(removeLegacy))
|
||||
delete outer[removeLegacy];
|
||||
return { ...parsed.document, [key]: { ...outer, [nested]: servers } };
|
||||
};
|
||||
|
||||
/** Read the value at a key path, or `undefined` when any member is absent. */
|
||||
const valueAt = (
|
||||
document: Record<string, unknown>,
|
||||
path: readonly string[],
|
||||
): unknown => {
|
||||
let current: unknown = document;
|
||||
for (const key of path) {
|
||||
const object = objectSchema.safeParse(current);
|
||||
if (!object.success || !Object.hasOwn(object.data, key)) return undefined;
|
||||
current = object.data[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
/** Serialize a validated client document, retaining JSONC comments. */
|
||||
@@ -96,33 +179,43 @@ export const serializeClientConfiguration = (
|
||||
document: Record<string, unknown>,
|
||||
format: ClientConfigurationFormat | undefined,
|
||||
originalText?: string,
|
||||
editedServerName?: string,
|
||||
editedPaths?: readonly (readonly string[])[],
|
||||
): string => {
|
||||
if (format === undefined || format === "unsupported")
|
||||
throw new TypeError("client does not have a supported MCP config format");
|
||||
if (format === "toml") return stringifyToml(document);
|
||||
if (originalText !== undefined && editedServerName !== undefined) {
|
||||
const serversKey = clientConfigurationServersKey(format);
|
||||
const servers = objectSchema.parse(document[serversKey] ?? {});
|
||||
const editedValue = Object.hasOwn(servers, editedServerName)
|
||||
? servers[editedServerName]
|
||||
: undefined;
|
||||
const edits = modify(
|
||||
if (originalText !== undefined && editedPaths !== undefined)
|
||||
return editedPaths.reduce(
|
||||
(text, path) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, [...path], valueAt(document, path), {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
|
||||
}),
|
||||
),
|
||||
originalText,
|
||||
[serversKey, editedServerName],
|
||||
editedValue,
|
||||
{
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
|
||||
},
|
||||
);
|
||||
return applyEdits(originalText, edits);
|
||||
}
|
||||
return `${JSON.stringify(document, null, 2)}\n`;
|
||||
};
|
||||
|
||||
/** Key path of a named registration in the server table. */
|
||||
export const clientServerPath = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
name: string,
|
||||
): string[] => [...parsed.serversPath, name];
|
||||
|
||||
/** Key path of a present OpenCode V1 entry beside a native V2 table. */
|
||||
export const legacyClientServerPath = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
name: string,
|
||||
): string[] | undefined =>
|
||||
Object.hasOwn(parsed.legacyServers, name)
|
||||
? [parsed.serversPath[0] ?? "mcp", name]
|
||||
: undefined;
|
||||
|
||||
/** Build the stdio entry shape expected by one client's configuration dialect. */
|
||||
export const clientRegistrationEntry = (
|
||||
format: ClientConfigurationFormat,
|
||||
format: ClientRegistrationDialect,
|
||||
command: readonly string[],
|
||||
environment: Readonly<Record<string, string>>,
|
||||
): Record<string, unknown> => {
|
||||
@@ -137,6 +230,15 @@ export const clientRegistrationEntry = (
|
||||
? {}
|
||||
: { environment: { ...environment } }),
|
||||
};
|
||||
case "opencode_v2":
|
||||
// V2 connects servers unless `disabled` is true.
|
||||
return {
|
||||
type: "local",
|
||||
command: [...command],
|
||||
...(Object.keys(environment).length === 0
|
||||
? {}
|
||||
: { environment: { ...environment } }),
|
||||
};
|
||||
case "vscode":
|
||||
return {
|
||||
type: "stdio",
|
||||
|
||||
Reference in new issue
Block a user