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:
parent
4a6c17ce83
commit
69d8afefc1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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]) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in New Issue