Reuse OpenCode 2 HTTP/stdio clients with per-session read-only grants, native approval and sanitized owner-scoped MCP status. Narrow the environment inherited by trusted local MCP processes. Validated in an isolated checkout with bun run check and 20 focused tests.
92 lines
5.3 KiB
TypeScript
92 lines
5.3 KiB
TypeScript
import { z } from "zod";
|
|
|
|
export const mcpProtocols = ["legacy", "auto", "2026-07-28"] as const;
|
|
export const mcpTimeoutDefaults = { startup: 10_000, catalog: 10_000, execution: 60_000 };
|
|
const timeout = z.object({
|
|
startup: z.number().int().positive().optional(),
|
|
catalog: z.number().int().positive().optional(),
|
|
execution: z.number().int().positive().optional(),
|
|
}).strict();
|
|
const common = {
|
|
disabled: z.boolean().optional(), codemode: z.boolean().optional(),
|
|
protocol: z.enum(mcpProtocols).optional(), timeout: timeout.optional(),
|
|
};
|
|
const oauth = z.object({
|
|
client_id: z.string().optional(), client_secret: z.string().optional(), scope: z.string().optional(),
|
|
callback_port: z.number().int().min(1).max(65535).optional(), redirect_uri: z.string().optional(),
|
|
auth_server_metadata_url: z.string().optional(),
|
|
}).strict();
|
|
const server = z.discriminatedUnion("type", [
|
|
z.object({ ...common, type: z.literal("local"), command: z.array(z.string()).min(1), cwd: z.string().optional(), environment: z.record(z.string()).optional() }).strict(),
|
|
z.object({ ...common, type: z.literal("remote"), url: z.string().min(1), headers: z.record(z.string()).optional(), oauth: z.union([oauth, z.literal(false)]).optional() }).strict(),
|
|
]);
|
|
export type McpServerConfig = z.infer<typeof server>;
|
|
export const isMcpTool = (name: string) => name.startsWith("mcp_") ||
|
|
name === "opencode_list_mcp_resources" || name === "opencode_read_mcp_resource";
|
|
|
|
export function normalizeMcpServer(name: string, input: unknown): McpServerConfig {
|
|
if (!/^mcp_[A-Za-z0-9_-]+$/.test(name)) throw new Error("MCP server names must start with mcp_ and contain only letters, digits, _ or -");
|
|
const parsed = server.safeParse(input);
|
|
if (!parsed.success) throw new Error(`invalid MCP configuration for ${name}`);
|
|
const value = parsed.data;
|
|
if (value.type === "local" && !value.command[0]?.trim()) throw new Error(`invalid MCP command for ${name}`);
|
|
if (value.type === "remote" && !/\{env:[A-Za-z_][A-Za-z0-9_]*\}/.test(value.url)) {
|
|
try {
|
|
const url = new URL(value.url);
|
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error();
|
|
} catch { throw new Error(`invalid MCP endpoint for ${name}`); }
|
|
}
|
|
return { ...value, protocol: value.protocol ?? (value.type === "remote" ? "auto" : "legacy") };
|
|
}
|
|
|
|
export function normalizeMcpConfig(input: unknown) {
|
|
const parsed = z.object({ timeout: timeout.optional(), servers: z.record(z.unknown()).default({}) }).strict().safeParse(input ?? {});
|
|
if (!parsed.success) throw new Error("invalid MCP configuration");
|
|
return {
|
|
timeout: { ...mcpTimeoutDefaults, ...parsed.data.timeout },
|
|
servers: Object.fromEntries(Object.entries(parsed.data.servers).map(([name, value]) => [name, normalizeMcpServer(name, value)])),
|
|
};
|
|
}
|
|
|
|
type Rule = { action: string; resource: string; effect: "allow" | "deny" | "ask" };
|
|
const matches = (pattern: string, value: string) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*").replaceAll("?", ".")}$`).test(value);
|
|
|
|
// Only exact, explicit service rules are an administrator's read-only grant.
|
|
// Built-in defaults, broad allows and saved user approvals are not grants.
|
|
export function mcpReadonlyTools(input: Record<string, unknown>) {
|
|
const agents = input.agents as { instruction?: { permissions?: Rule[] } } | undefined;
|
|
const rules = [...((input.permissions ?? []) as Rule[]), ...(agents?.instruction?.permissions ?? [])];
|
|
const candidates = rules.filter((rule) => rule.effect === "allow" && /^mcp_[A-Za-z0-9_.-]+$/.test(rule.action)).map((rule) => rule.action);
|
|
return [...new Set(candidates)].filter((name) => {
|
|
let effect: Rule["effect"] = "ask";
|
|
for (const rule of rules) if (matches(rule.action, name) && matches(rule.resource, "*")) effect = rule.effect;
|
|
return effect === "allow";
|
|
}).sort();
|
|
}
|
|
|
|
const baseEnvironment = new Set(["PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"]);
|
|
const reservedEnvironment = /^(?:AGENT_INTERNAL_TOKEN|TJWATER_AGENT_.*|OPENCODE_.*|ORCA_.*|NODE_OPTIONS|BUN_OPTIONS)$/i;
|
|
export function mcpLocalEnvironment(parent: NodeJS.ProcessEnv, explicit: Record<string, string> = {}) {
|
|
// Native stdio inherits its host environment. Override non-runtime entries
|
|
// with empty values before adding credentials intentionally owned by this MCP.
|
|
const environment = Object.fromEntries(Object.entries(parent).map(([key, value]) => [key, baseEnvironment.has(key.toUpperCase()) ? value ?? "" : ""]));
|
|
Object.assign(environment, explicit);
|
|
for (const key of Object.keys(environment)) if (reservedEnvironment.test(key)) environment[key] = "";
|
|
return environment;
|
|
}
|
|
|
|
export const mcpSessionPolicySchema = z.object({
|
|
mode: z.enum(["interactive", "readonly", "disabled"]),
|
|
readonly_tools: z.array(z.string()),
|
|
}).strict();
|
|
export type McpSessionPolicy = z.infer<typeof mcpSessionPolicySchema>;
|
|
export const mcpServerStatusSchema = z.object({
|
|
name: z.string(), type: z.enum(["local", "remote"]),
|
|
status: z.enum(["connected", "pending", "disabled", "failed", "needs_auth"]),
|
|
configured_protocol: z.enum(mcpProtocols), tools: z.array(z.string()),
|
|
}).strict();
|
|
export const mcpRuntimeReportSchema = z.object({
|
|
servers: z.array(mcpServerStatusSchema), configuration_errors: z.array(z.string()),
|
|
}).strict();
|
|
export type McpRuntimeReport = z.infer<typeof mcpRuntimeReportSchema>;
|