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:
Iwakurakaoru0822 2026-10-06 21:58:20 -07:00
parent 4a6c17ce83
commit 69d8afefc1
8 changed files with 386 additions and 93 deletions

View File

@ -131,6 +131,11 @@ Setup can configure these clients for REA's local MCP server:
| Command Code | `commandcode` |
| VS Code | `vscode` |
For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
both load. If the configuration already uses OpenCode V2's native
`mcp.servers` table, setup registers REA there instead and replaces any earlier
`mcp.rea` entry from REA.
## Review setup changes
`rea setup` first offers the supported agents in a multi-select. Existing REA

View File

@ -22,7 +22,13 @@ describe.each([
expect(
parseClientConfiguration(`${bom}${JSON.stringify(document)}`, format),
).toEqual({ document, servers: document[serversKey], serversKey });
).toEqual({
document,
servers: document[serversKey],
serversPath: [serversKey],
dialect: format,
legacyServers: {},
});
});
it.each(["", "\uFEFF"])(
@ -70,12 +76,16 @@ describe.each([
else servers.rea = registration;
const document = { ...parsed.document, [serversKey]: servers };
text = serializeClientConfiguration(document, format, text, "rea");
text = serializeClientConfiguration(document, format, text, [
[serversKey, "rea"],
]);
expect(parseClientConfiguration(text, format)).toEqual({
document,
servers,
serversKey,
serversPath: [serversKey],
dialect: format,
legacyServers: {},
});
expect(Buffer.from(text.slice(0, prefix.length))).toEqual(
Buffer.from(prefix),

View File

@ -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",

View File

@ -1,4 +1,7 @@
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
import {
effectiveClientServer,
parseClientConfiguration,
} from "./ClientConfigurationDocument.js";
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
@ -85,8 +88,10 @@ export const readClientRegistrationStatuses = async (
continue;
try {
const content = await readFile(client.configPath, "utf8");
const { servers } = parseClientConfiguration(content, client.format);
const raw = servers[PRODUCT_IDENTITY.mcpServerKey];
const raw = effectiveClientServer(
parseClientConfiguration(content, client.format),
PRODUCT_IDENTITY.mcpServerKey,
);
if (raw === undefined) {
statuses.push(
unavailableStatus(client.name, client.configPath, "missing"),

View File

@ -1,9 +1,13 @@
import {
clientRegistrationEntry,
clientConfigurationValuesEqual,
clientServerPath,
legacyClientServerPath,
parseClientConfiguration,
serializeClientConfiguration,
withClientServers,
type ClientConfigurationDocument,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { constants as fsConstants } from "node:fs";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
@ -82,20 +86,13 @@ const configureClientDocument = async (
void cause;
return { status: "failed", reason: "readback" };
}
const { document, servers, serversKey } = parsed;
const desired = clientConfigurationDesired(
client,
environment,
command,
format,
parsed.dialect,
);
if (
clientConfigurationValuesEqual(
servers[PRODUCT_IDENTITY.mcpServerKey],
desired,
)
)
return { status: "unchanged" };
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
if (
@ -103,20 +100,25 @@ const configureClientDocument = async (
!(await preserveConfigBackup(transactionPath, backupPath))
)
return { status: "failed", reason: "backup" };
document[serversKey] = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
// otherwise conflict with it.
const legacyPath = legacyClientServerPath(
parsed,
PRODUCT_IDENTITY.mcpServerKey,
);
const document = withClientServers(
parsed,
{ ...parsed.servers, [PRODUCT_IDENTITY.mcpServerKey]: desired },
PRODUCT_IDENTITY.mcpServerKey,
);
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(
transactionPath,
serializeClientConfiguration(
document,
format,
original,
PRODUCT_IDENTITY.mcpServerKey,
),
serializeClientConfiguration(document, format, original, [
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
...(legacyPath === undefined ? [] : [legacyPath]),
]),
{
encoding: "utf8",
mode: 0o600,
@ -132,12 +134,7 @@ const configureClientDocument = async (
await readFile(transactionPath, "utf8"),
format,
);
if (
!clientConfigurationValuesEqual(
readback.servers[PRODUCT_IDENTITY.mcpServerKey],
desired,
)
) {
if (!registrationCurrent(readback, desired)) {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
@ -159,18 +156,17 @@ export const clientConfigurationAligned = async (
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
): Promise<boolean> => {
const desired = clientConfigurationDesired(
client,
providerEnvironment,
command,
client.format,
);
try {
const original = await readFile(client.configPath, "utf8");
const { servers } = parseClientConfiguration(original, client.format);
return clientConfigurationValuesEqual(
servers[PRODUCT_IDENTITY.mcpServerKey],
desired,
const parsed = parseClientConfiguration(original, client.format);
return registrationCurrent(
parsed,
clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed.dialect,
),
);
} catch (cause: unknown) {
// Unreadable configuration is treated as not aligned so setup repairs it.
@ -206,20 +202,15 @@ export const inspectClientConfiguration = async (
"The configuration file could not be read. Check its permissions before rerunning setup.",
};
}
const desired = clientConfigurationDesired(
client,
providerEnvironment,
command,
client.format,
);
try {
const { servers } = parseClientConfiguration(original, client.format);
if (
clientConfigurationValuesEqual(
servers[PRODUCT_IDENTITY.mcpServerKey],
desired,
)
)
const parsed = parseClientConfiguration(original, client.format);
const desired = clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed.dialect,
);
if (registrationCurrent(parsed, desired))
return { status: "already_current" };
} catch (cause: unknown) {
// Malformed configuration is reported with the invalid remediation.
@ -263,11 +254,21 @@ const restoreConfig = async (
}
};
/** Whether REA's entry matches and no conflicting legacy entry remains. */
const registrationCurrent = (
parsed: ClientConfigurationDocument,
desired: unknown,
): boolean =>
clientConfigurationValuesEqual(
parsed.servers[PRODUCT_IDENTITY.mcpServerKey],
desired,
) && !Object.hasOwn(parsed.legacyServers, PRODUCT_IDENTITY.mcpServerKey);
const clientConfigurationDesired = (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
format: NonNullable<SetupClient["format"]> | undefined,
format: ClientRegistrationDialect | undefined,
) => {
const environment = Object.fromEntries(
Object.entries(providerEnvironment).sort(([left], [right]) =>

View File

@ -1,7 +1,10 @@
import {
parseClientConfiguration,
serializeClientConfiguration,
type ClientServersKey,
clientServerPath,
legacyClientServerPath,
withClientServers,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
import { homedir } from "node:os";
@ -141,15 +144,9 @@ const removeClient = async (
"Configuration could not be read. Check file permissions, then rerun uninstall.",
);
}
let document: Record<string, unknown>;
let key: ClientServersKey;
let servers: Record<string, unknown>;
let parsed: ClientConfigurationDocument;
try {
({
document,
servers,
serversKey: key,
} = parseClientConfiguration(original, client.format));
parsed = parseClientConfiguration(original, client.format);
} catch (cause: unknown) {
void cause;
return item(
@ -158,18 +155,34 @@ const removeClient = async (
`Configuration is not valid ${client.format === "toml" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
);
}
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
if (registration === undefined)
const name = PRODUCT_IDENTITY.mcpServerKey;
// Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`.
const legacyPath = legacyClientServerPath(parsed, name);
const hasNative = Object.hasOwn(parsed.servers, name);
if (!hasNative && legacyPath === undefined)
return item(client.name, "skipped", "REA registration is absent.");
if (!isOwnedRegistration(registration, client))
const removeNative =
hasNative && isOwnedRegistration(parsed.servers[name], client);
const removeLegacy =
legacyPath !== undefined &&
isOwnedRegistration(parsed.legacyServers[name], client);
if (!removeNative && !removeLegacy)
return item(
client.name,
"retained",
"The rea-named registration is not REA-owned.",
);
const remaining = { ...servers };
delete remaining[PRODUCT_IDENTITY.mcpServerKey];
document[key] = remaining;
const remaining = { ...parsed.servers };
if (removeNative) delete remaining[name];
const document = withClientServers(
parsed,
remaining,
removeLegacy ? name : undefined,
);
const editedPaths = [
...(removeNative ? [clientServerPath(parsed, name)] : []),
...(removeLegacy ? [legacyPath] : []),
];
const backupPath = `${client.configPath}.rea.backup`;
try {
await fileSystem.copy(transactionPath, backupPath);
@ -188,14 +201,17 @@ const removeClient = async (
document,
client.format,
original,
PRODUCT_IDENTITY.mcpServerKey,
editedPaths,
),
);
const readback = parseClientConfiguration(
await fileSystem.readText(transactionPath),
client.format,
);
if (PRODUCT_IDENTITY.mcpServerKey in readback.servers)
if (
(removeNative && Object.hasOwn(readback.servers, name)) ||
(removeLegacy && Object.hasOwn(readback.legacyServers, name))
)
throw new Error("registration readback mismatch");
return item(
client.name,

View File

@ -5,7 +5,10 @@ import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { readClientRegistrationStatuses } from "./ClientRegistrationStatus.js";
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
import {
effectiveClientServer,
parseClientConfiguration,
} from "./ClientConfigurationDocument.js";
import { supportedClients } from "./SupportedClients.js";
import type { SetupAction } from "./SetupTypes.js";
import type { Result } from "../domain/result.js";
@ -60,7 +63,7 @@ export const existingMaintenanceScope = async (
continue;
const client = supported.find(({ name }) => name === registration.client);
if (client === undefined) continue;
const { servers } = parseClientConfiguration(
const parsed = parseClientConfiguration(
await readFile(client.configPath, "utf8"),
client.format,
);
@ -69,7 +72,7 @@ export const existingMaintenanceScope = async (
enabled: z.boolean().optional(),
disabled: z.boolean().optional(),
})
.parse(servers[PRODUCT_IDENTITY.mcpServerKey]);
.parse(effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey));
if (enabled.enabled !== false && enabled.disabled !== true)
clients.push(client.name);
}

View File

@ -191,6 +191,157 @@ describe("additional client configuration dialects", () => {
});
});
const parseOpenCode = (text: string): Record<string, unknown> => {
const errors: ParseError[] = [];
const document = parseJsonc(text, errors, {
allowTrailingComma: true,
}) as Record<string, unknown>;
expect(errors).toEqual([]);
return document;
};
const openCodeClient = async (prefix: string) => {
const home = await createTestTempDirectory(prefix);
const client = getClients(home).find(({ name }) => name === "opencode");
if (client === undefined) throw new Error("missing OpenCode client");
await mkdir(client.markerPath ?? home, { recursive: true });
return { home, client };
};
const legacyEntry = JSON.stringify({ type: "local", command, enabled: true });
describe("OpenCode V2 native server table", () => {
it("registers in mcp.servers, replaces REA's V1 entry, and uninstalls", async () => {
const { home, client } = await openCodeClient("rea-opencode-v2-");
await writeFile(
client.configPath,
`{
"mcp": {
// Default timeouts are not a server.
"timeout": { "catalog": 30000 },
"servers": {
// An independently managed server.
"other": { "type": "local", "command": ["node", "other.js"] },
},
"rea": ${legacyEntry},
},
}\n`,
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
const text = await readFile(client.configPath, "utf8");
expect(text).toContain("// Default timeouts are not a server.");
expect(text).toContain("// An independently managed server.");
expect(parseOpenCode(text).mcp).toEqual({
timeout: { catalog: 30000 },
servers: {
other: { type: "local", command: ["node", "other.js"] },
rea: { type: "local", command: [...command] },
},
});
expect(await readClientRegistrationStatuses(home)).toEqual(
expect.arrayContaining([
expect.objectContaining({
client: "opencode",
state: "aligned",
command,
}),
]),
);
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
"removed",
);
expect(
parseOpenCode(await readFile(client.configPath, "utf8")).mcp,
).toEqual({
timeout: { catalog: 30000 },
servers: { other: { type: "local", command: ["node", "other.js"] } },
});
});
it("updates and removes REA's entry after OpenCode migrates it to mcp.servers", async () => {
const { home, client } = await openCodeClient("rea-opencode-migrated-");
const stale = ["npx", "-y", `${PRODUCT_IDENTITY.packageName}@3.2.0`, "mcp"];
await writeFile(
client.configPath,
`${JSON.stringify({ mcp: { servers: { rea: { type: "local", command: stale } } } })}\n`,
);
expect(await readClientRegistrationStatuses(home)).toEqual(
expect.arrayContaining([
expect.objectContaining({ client: "opencode", command: stale }),
]),
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(
parseOpenCode(await readFile(client.configPath, "utf8")).mcp,
).toEqual({
servers: { rea: { type: "local", command: [...command] } },
});
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
"removed",
);
expect(
parseOpenCode(await readFile(client.configPath, "utf8")).mcp,
).toEqual({
servers: {},
});
});
it("reports and removes a V1 entry that OpenCode still loads beside mcp.servers", async () => {
const { home, client } = await openCodeClient("rea-opencode-mixed-");
await writeFile(
client.configPath,
`{ "mcp": { "servers": {}, "rea": ${legacyEntry} } }\n`,
);
expect(await readClientRegistrationStatuses(home)).toEqual(
expect.arrayContaining([
expect.objectContaining({
client: "opencode",
state: "aligned",
command,
}),
]),
);
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
"removed",
);
expect(
parseOpenCode(await readFile(client.configPath, "utf8")).mcp,
).toEqual({
servers: {},
});
});
it("keeps a V1 server named servers in the V1 table", async () => {
const { client } = await openCodeClient("rea-opencode-v1-servers-");
const servers = { type: "local", command: ["node", "servers.js"] };
await writeFile(
client.configPath,
`${JSON.stringify({ mcp: { servers } })}\n`,
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(
parseOpenCode(await readFile(client.configPath, "utf8")).mcp,
).toEqual({
servers,
rea: { type: "local", command: [...command], enabled: true },
});
});
});
describe("platform-aware client config paths", () => {
it.each([
[