char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Sommcp: load and validate server configuration on the conversation machine7a8c6a6

main
4.6 KiB124 linesraw
1import type { Environment } from "@berry/api";
2import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
3
4export const DEFAULT_CONFIG = "~/.config/berry/mcp.json";
5
6export type Server =
7  & {
8    enabled: boolean;
9    timeout: number;
10    cwd: string;
11  }
12  & (
13    | { command: string; args: string[]; env: Record<string, string> }
14    | { url: string; headers: Record<string, string> }
15  );
16
17function record(value: unknown): value is Record<string, unknown> {
18  return value !== null && typeof value === "object" && !Array.isArray(value);
19}
20
21/** The server entries from the selected file on the conversation's machine. */
22export async function readConfig(env: Environment, path: string, signal: AbortSignal) {
23  const result = await env.readTextFile(path, withAbortSignal(signal, BACKGROUND_CONTEXT));
24  if (!result.ok) {
25    if (result.error.code === "not_found") return {};
26    throw result.error;
27  }
28  let parsed: unknown;
29  try {
30    parsed = JSON.parse(result.value);
31  } catch {
32    throw new Error(`Invalid JSON in MCP configuration: ${path}`);
33  }
34  if (!record(parsed) || !record(parsed.mcpServers)) {
35    throw new Error(`MCP configuration ${path} must contain an mcpServers object`);
36  }
37  return parsed.mcpServers;
38}
39
40/** Validate a server and resolve variables and paths on its own machine. */
41export async function serverConfig(
42  env: Environment,
43  raw: unknown,
44  signal: AbortSignal,
45): Promise<Server> {
46  if (!record(raw)) throw new Error("Server configuration must be an object");
47  if (raw.enabled === false) throw new Error("MCP server is disabled");
48  const string = (value: unknown, field: string): string => {
49    if (typeof value !== "string") throw new Error(`${field} must be a string`);
50    return value.replace(/\$\{([^}]+)\}/g, (_, name: string) => {
51      const found = env.variables[name];
52      if (found === undefined) {
53        throw new Error(`Environment variable ${name} is not set on ${env.name}`);
54      }
55      return found;
56    });
57  };
58  const strings = (value: unknown, field: string): Record<string, string> => {
59    if (value === undefined) return {};
60    if (!record(value)) throw new Error(`${field} must be an object of strings`);
61    return Object.fromEntries(
62      Object.entries(value).map(([key, value]) => [key, string(value, field)]),
63    );
64  };
65  const home = (value: string) =>
66    value === "~" ? env.home : /^~[/\\]/.test(value) ? env.home + value.slice(1) : value;
67  if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") {
68    throw new Error("enabled must be a boolean");
69  }
70  const timeout = raw.timeout ?? 60;
71  if (typeof timeout !== "number" || !Number.isFinite(timeout * 1000) || timeout <= 0) {
72    throw new Error("timeout must be a positive number of seconds");
73  }
74  if (timeout * 1000 > 0x7fffffff) {
75    throw new Error("timeout exceeds the maximum supported timer duration");
76  }
77  const cwd = await env.absolutePath(
78    raw.cwd === undefined ? env.cwd : home(string(raw.cwd, "cwd")),
79    withAbortSignal(signal, BACKGROUND_CONTEXT),
80  );
81  if (!cwd.ok) throw cwd.error;
82  const base = { enabled: raw.enabled !== false, timeout: timeout * 1000, cwd: cwd.value };
83  if (raw.url !== undefined) {
84    if (raw.command !== undefined) throw new Error("Specify either command or url, not both");
85    if (raw.type !== undefined && raw.type !== "http" && raw.type !== "streamable-http") {
86      throw new Error("Only Streamable HTTP is supported for URL servers");
87    }
88    if (raw.oauth || raw.auth) {
89      throw new Error("OAuth is not supported yet; use headers with an access token");
90    }
91    const expanded = string(raw.url, "url");
92    let url: URL;
93    try {
94      url = new URL(expanded);
95    } catch {
96      throw new Error("url must be a valid absolute HTTP or HTTPS URL");
97    }
98    if (url.protocol !== "https:" && url.protocol !== "http:") {
99      throw new Error("MCP URLs must use HTTP or HTTPS");
100    }
101    if (url.username || url.password) {
102      throw new Error("MCP URL credentials are not supported; use headers instead");
103    }
104    return {
105      ...base,
106      url: url.href,
107      headers: strings(raw.headers, "headers"),
108    };
109  }
110  if (raw.type !== undefined && raw.type !== "stdio") {
111    throw new Error("Only stdio command servers are supported");
112  }
113  const command = home(string(raw.command, "command"));
114  if (!command.trim()) throw new Error("command must not be empty");
115  if (raw.args !== undefined && !Array.isArray(raw.args)) {
116    throw new Error("args must be an array of strings");
117  }
118  return {
119    ...base,
120    command,
121    args: (raw.args as unknown[] | undefined ?? []).map((arg) => home(string(arg, "args"))),
122    env: strings(raw.env, "env"),
123  };
124}