mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
256 lines
8.6 KiB
TypeScript
256 lines
8.6 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import {
|
|
MCP_STARTUP_POLICY,
|
|
recommendedMcpToolTimeoutMs,
|
|
recommendedMcpToolTimeoutSeconds,
|
|
type McpToolTimeoutInputs,
|
|
} from "../mcpStartupPolicy.js";
|
|
import {
|
|
clientConfigurationRecordSchema,
|
|
clientRegistrationEntry,
|
|
type ClientRegistrationDialect,
|
|
} from "./ClientConfigurationDocument.js";
|
|
import type { SetupClient } from "./SupportedClients.js";
|
|
import { equivalentClientRegistrationCommands } from "./ClientRegistrationIdentity.js";
|
|
|
|
const tomlTimeoutSchema = z.union([
|
|
z.number().positive(),
|
|
z.bigint().positive(),
|
|
]);
|
|
|
|
const launcherSchema = z
|
|
.object({
|
|
command: z.string().min(1),
|
|
args: z.array(z.string()).default([]),
|
|
})
|
|
.passthrough();
|
|
const opencodeLauncherSchema = z
|
|
.object({
|
|
type: z.literal("local"),
|
|
command: z.array(z.string()).min(1),
|
|
})
|
|
.passthrough();
|
|
const normalizeOpencodeLauncher = (
|
|
entry: z.output<typeof opencodeLauncherSchema>,
|
|
): z.output<typeof launcherSchema> => {
|
|
const [command = "", ...args] = entry.command;
|
|
return { ...entry, command, args };
|
|
};
|
|
|
|
const registrationSchema = launcherSchema.extend({
|
|
startup_timeout_sec: tomlTimeoutSchema.optional(),
|
|
tool_timeout_sec: tomlTimeoutSchema.optional(),
|
|
startupTimeoutMs: z.number().positive().optional(),
|
|
toolTimeoutMs: z.number().positive().optional(),
|
|
type: z.string().optional(),
|
|
transport: z.string().optional(),
|
|
serverName: z.string().optional(),
|
|
disabled: z.boolean().optional(),
|
|
enabled: z.boolean().optional(),
|
|
});
|
|
|
|
export type ClientRegistration = z.output<typeof registrationSchema>;
|
|
|
|
// https://developers.openai.com/codex/config-reference/#mcp_servers
|
|
const codexApprovalModeSchema = z.enum(["auto", "prompt", "writes", "approve"]);
|
|
const codexToolPolicySchema = z
|
|
.object({
|
|
approval_mode: codexApprovalModeSchema.optional(),
|
|
output_token_limit: z
|
|
.union([z.number().int().positive(), z.bigint().positive()])
|
|
.optional(),
|
|
})
|
|
.passthrough();
|
|
const codexPolicySchema = z
|
|
.object({
|
|
default_tools_approval_mode: codexApprovalModeSchema.optional(),
|
|
enabled_tools: z.array(z.string()).optional(),
|
|
disabled_tools: z.array(z.string()).optional(),
|
|
tools: clientConfigurationRecordSchema
|
|
.superRefine((tools, context) => {
|
|
for (const [name, value] of Object.entries(tools)) {
|
|
const policy = codexToolPolicySchema.safeParse(value);
|
|
if (!policy.success)
|
|
for (const issue of policy.error.issues)
|
|
context.addIssue({ ...issue, path: [name, ...issue.path] });
|
|
}
|
|
})
|
|
.optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const registrationError = (error: z.ZodError): ConfigurationError =>
|
|
new ConfigurationError("Invalid client registration settings", {
|
|
settings: error.issues.map((issue) => ({
|
|
setting: issue.path.join("."),
|
|
constraint: issue.message,
|
|
})),
|
|
});
|
|
|
|
/** Launcher identity remains usable when unrelated readiness settings are invalid. */
|
|
export const parseClientRegistrationLauncher = (
|
|
value: unknown,
|
|
client: SetupClient,
|
|
): Result<{ command: string; args: string[] }, ConfigurationError> => {
|
|
const parsed =
|
|
client.format === "opencode"
|
|
? opencodeLauncherSchema
|
|
.transform(normalizeOpencodeLauncher)
|
|
.safeParse(value)
|
|
: launcherSchema.safeParse(value);
|
|
return parsed.success
|
|
? ok({ command: parsed.data.command, args: parsed.data.args })
|
|
: err(registrationError(parsed.error));
|
|
};
|
|
|
|
/** Policy belongs to the client dialect, and is validated before refresh writes. */
|
|
const validateClientPolicy = (
|
|
value: unknown,
|
|
client: SetupClient,
|
|
): Result<void, ConfigurationError> => {
|
|
const policy =
|
|
client.name === "codex"
|
|
? codexPolicySchema.safeParse(value)
|
|
: client.format === "copilot_cli"
|
|
? z.object({ tools: z.array(z.string()).optional() }).safeParse(value)
|
|
: undefined;
|
|
return policy === undefined || policy.success
|
|
? ok(undefined)
|
|
: err(registrationError(policy.error));
|
|
};
|
|
|
|
/** Normalize launcher facts once; policy fields keep their dialect-specific shape. */
|
|
export const parseClientRegistration = (
|
|
value: unknown,
|
|
client: SetupClient,
|
|
): Result<ClientRegistration, ConfigurationError> => {
|
|
const policy = validateClientPolicy(value, client);
|
|
if (!policy.ok) return policy;
|
|
if (client.format === "dsh") {
|
|
const parsed = registrationSchema
|
|
.pick({ command: true, args: true, serverName: true, transport: true })
|
|
.strip()
|
|
.safeParse(value);
|
|
return parsed.success
|
|
? ok(parsed.data)
|
|
: err(registrationError(parsed.error));
|
|
}
|
|
if (client.format === "opencode") {
|
|
const entry = opencodeLauncherSchema
|
|
.extend({
|
|
environment: z.record(z.string(), z.string()).optional(),
|
|
})
|
|
.transform(normalizeOpencodeLauncher)
|
|
.safeParse(value);
|
|
if (!entry.success) return err(registrationError(entry.error));
|
|
value = entry.data;
|
|
}
|
|
const parsed = registrationSchema.safeParse(value);
|
|
if (!parsed.success) return err(registrationError(parsed.error));
|
|
const registration = parsed.data;
|
|
const transportInvalid =
|
|
((client.format === "kimi" || client.format === "commandcode") &&
|
|
registration.transport !== undefined &&
|
|
registration.transport !== "stdio") ||
|
|
(client.format === "commandcode" && registration.transport === undefined);
|
|
const typeInvalid =
|
|
((client.format === "vscode" || client.format === "copilot_cli") &&
|
|
registration.type !== "stdio") ||
|
|
((client.format === "kimi" ||
|
|
client.format === "omp" ||
|
|
client.format === "pi") &&
|
|
registration.type !== undefined &&
|
|
registration.type !== "stdio");
|
|
if (transportInvalid || typeInvalid)
|
|
return err(
|
|
new ConfigurationError("Expected an stdio registration", {
|
|
settings: [
|
|
{
|
|
setting: transportInvalid ? "transport" : "type",
|
|
constraint: "Expected stdio",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
return ok(registration);
|
|
};
|
|
|
|
// These fields select REA's connection and activation. Everything else belongs
|
|
// to the caller/client, including future policy keys. Client-specific timeout
|
|
// defaults and preservation rules are applied below.
|
|
const managedConnectionKeys = new Set([
|
|
"command",
|
|
"args",
|
|
"env",
|
|
"environment",
|
|
"type",
|
|
"transport",
|
|
"enabled",
|
|
"disabled",
|
|
"enable",
|
|
"serverName",
|
|
"url",
|
|
"httpUrl",
|
|
]);
|
|
|
|
/** Patch a source fragment, retaining client-owned settings rather than rebuilding it. */
|
|
export const refreshClientRegistration = (
|
|
client: SetupClient,
|
|
dialect: ClientRegistrationDialect,
|
|
command: readonly string[],
|
|
environment: Readonly<Record<string, string>>,
|
|
previous: unknown,
|
|
timeoutInputs: McpToolTimeoutInputs,
|
|
): Result<Record<string, unknown>, ConfigurationError> => {
|
|
const source = clientConfigurationRecordSchema.safeParse(previous);
|
|
const existing = source.success ? source.data : {};
|
|
const policy = validateClientPolicy(existing, client);
|
|
if (!policy.ok) return policy;
|
|
const prior = parseClientRegistrationLauncher(existing, client);
|
|
const priorCommand = prior.ok
|
|
? [prior.value.command, ...prior.value.args]
|
|
: undefined;
|
|
const launcher =
|
|
priorCommand !== undefined &&
|
|
equivalentClientRegistrationCommands(priorCommand, command)
|
|
? priorCommand
|
|
: command;
|
|
const registration = {
|
|
...clientRegistrationEntry(dialect, launcher, environment),
|
|
...Object.fromEntries(
|
|
Object.entries(existing).filter(
|
|
([key]) => !managedConnectionKeys.has(key),
|
|
),
|
|
),
|
|
};
|
|
if (client.name === "codex" || client.name === "grok_build")
|
|
registration.startup_timeout_sec =
|
|
(client.name === "codex"
|
|
? positiveTimeout(existing.startup_timeout_sec)
|
|
: undefined) ?? MCP_STARTUP_POLICY.codexStartupTimeoutSeconds;
|
|
if (client.name === "codex")
|
|
registration.tool_timeout_sec =
|
|
positiveTimeout(existing.tool_timeout_sec) ??
|
|
recommendedMcpToolTimeoutSeconds(timeoutInputs);
|
|
if (client.name === "kimi") {
|
|
registration.startupTimeoutMs =
|
|
positiveTimeout(existing.startupTimeoutMs) ??
|
|
MCP_STARTUP_POLICY.kimiStartupTimeoutMs;
|
|
registration.toolTimeoutMs =
|
|
positiveTimeout(existing.toolTimeoutMs) ??
|
|
recommendedMcpToolTimeoutMs(timeoutInputs);
|
|
}
|
|
return ok(registration);
|
|
};
|
|
|
|
const positiveTimeout = (value: unknown): number | bigint | undefined => {
|
|
// TOML keeps integers beyond the safe Number range exact as bigint.
|
|
if (typeof value === "bigint") return value > 0n ? value : undefined;
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
? value
|
|
: undefined;
|
|
};
|