Files
cokaremote/src/config.ts
T

220 lines
6.6 KiB
TypeScript

import path from "node:path";
export interface AppConfig {
host: string;
port: number;
endpoint: string;
publicUrl: string | undefined;
allowedHosts: string[] | undefined;
trustProxyHops: number;
authToken: string | undefined;
allowNoAuth: boolean;
oauthEnabled: boolean;
oauthApprovalKey: string | undefined;
oauthIssuerUrl: string | undefined;
oauthResourceUrl: string | undefined;
oauthStateFile: string;
oauthAccessTokenTtlSeconds: number;
oauthRefreshTokenTtlSeconds: number;
oauthAuthorizationCodeTtlSeconds: number;
defaultCwd: string;
defaultShell: string;
maxRequestBody: string;
maxOutputBytes: number;
maxRetainedProcessOutputBytes: number;
processRetentionMs: number;
maxProcesses: number;
maxFileChunkBytes: number;
maxEditFileBytes: number;
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value === "") {
return fallback;
}
if (["1", "true", "yes", "on"].includes(value.toLowerCase())) {
return true;
}
if (["0", "false", "no", "off"].includes(value.toLowerCase())) {
return false;
}
throw new Error(`Invalid boolean value: ${value}`);
}
function parseInteger(
value: string | undefined,
fallback: number,
name: string,
minimum: number,
maximum = Number.MAX_SAFE_INTEGER,
): number {
if (value === undefined || value === "") {
return fallback;
}
const normalized = value.trim();
const parsed = /^[+-]?\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
const range = maximum === Number.MAX_SAFE_INTEGER
? `greater than or equal to ${minimum}`
: `between ${minimum} and ${maximum}`;
throw new Error(`${name} must be an integer ${range}`);
}
return parsed;
}
function normalizeEndpoint(value: string | undefined): string {
const endpoint = value?.trim() || "/mcp";
if (!endpoint.startsWith("/")) {
throw new Error("MCP_ENDPOINT must start with '/'");
}
return endpoint.length > 1 ? endpoint.replace(/\/+$/, "") : endpoint;
}
function normalizeOAuthUrl(value: string | undefined, name: string): string {
if (!value) {
throw new Error(`${name} is required when MCP_OAUTH_ENABLED=true`);
}
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} must be an absolute URL`);
}
const isLoopback =
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "[::1]";
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
throw new Error(`${name} must use HTTPS (HTTP is allowed only for loopback tests)`);
}
if (url.username || url.password) {
throw new Error(`${name} must not contain user credentials`);
}
if (url.search || url.hash) {
throw new Error(`${name} must not contain a query string or fragment`);
}
return url.href;
}
export function loadConfig(
env: NodeJS.ProcessEnv = process.env,
processCwd = process.cwd(),
): AppConfig {
const allowNoAuth = parseBoolean(env.MCP_ALLOW_NO_AUTH, false);
const authToken = env.MCP_AUTH_TOKEN?.trim() || undefined;
const oauthEnabled = parseBoolean(env.MCP_OAUTH_ENABLED, false);
const oauthApprovalKey = oauthEnabled
? env.MCP_OAUTH_APPROVAL_KEY?.trim() || authToken
: undefined;
if (!allowNoAuth && !authToken && !oauthEnabled) {
throw new Error(
"MCP_AUTH_TOKEN is required. Set MCP_ALLOW_NO_AUTH=true only when an upstream OAuth gateway or private network authenticates callers.",
);
}
if (oauthEnabled && !oauthApprovalKey) {
throw new Error(
"MCP_OAUTH_APPROVAL_KEY (or MCP_AUTH_TOKEN for backward compatibility) is required when OAuth is enabled",
);
}
const defaultCwd = path.resolve(env.MCP_DEFAULT_CWD?.trim() || processCwd);
const allowedHosts = env.MCP_ALLOWED_HOSTS?.split(",")
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
const endpoint = normalizeEndpoint(env.MCP_ENDPOINT);
const publicUrl = env.MCP_PUBLIC_URL?.trim().replace(/\/+$/, "") || undefined;
const oauthIssuerUrl = oauthEnabled
? normalizeOAuthUrl(env.MCP_OAUTH_ISSUER?.trim() || publicUrl, "MCP_OAUTH_ISSUER")
: undefined;
const oauthResourceUrl = oauthEnabled
? normalizeOAuthUrl(
env.MCP_OAUTH_RESOURCE?.trim() || (publicUrl ? `${publicUrl}${endpoint}` : undefined),
"MCP_OAUTH_RESOURCE",
)
: undefined;
return {
host: env.MCP_HOST?.trim() || "0.0.0.0",
port: parseInteger(env.MCP_PORT, 3000, "MCP_PORT", 1, 65_535),
endpoint,
publicUrl,
allowedHosts: allowedHosts && allowedHosts.length > 0 ? allowedHosts : undefined,
trustProxyHops: parseInteger(
env.MCP_TRUST_PROXY_HOPS,
0,
"MCP_TRUST_PROXY_HOPS",
0,
16,
),
authToken,
allowNoAuth,
oauthEnabled,
oauthApprovalKey,
oauthIssuerUrl,
oauthResourceUrl,
oauthStateFile: path.resolve(
env.MCP_OAUTH_STATE_FILE?.trim() ||
path.join(processCwd, ".remote-dev-mcp-oauth-state.json"),
),
oauthAccessTokenTtlSeconds: parseInteger(
env.MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
60 * 60,
"MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS",
300,
),
oauthRefreshTokenTtlSeconds: parseInteger(
env.MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS,
30 * 24 * 60 * 60,
"MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS",
3600,
),
oauthAuthorizationCodeTtlSeconds: parseInteger(
env.MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS,
5 * 60,
"MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS",
60,
),
defaultCwd,
defaultShell:
env.MCP_DEFAULT_SHELL?.trim() || env.SHELL?.trim() || "/bin/bash",
maxRequestBody: env.MCP_MAX_REQUEST_BODY?.trim() || "8mb",
maxOutputBytes: parseInteger(
env.MCP_MAX_OUTPUT_BYTES,
1024 * 1024,
"MCP_MAX_OUTPUT_BYTES",
16 * 1024,
),
maxRetainedProcessOutputBytes: parseInteger(
env.MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES,
4 * 1024 * 1024,
"MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES",
64 * 1024,
),
processRetentionMs: parseInteger(
env.MCP_PROCESS_RETENTION_MS,
60 * 60 * 1000,
"MCP_PROCESS_RETENTION_MS",
1000,
),
maxProcesses: parseInteger(
env.MCP_MAX_PROCESSES,
128,
"MCP_MAX_PROCESSES",
1,
),
maxFileChunkBytes: parseInteger(
env.MCP_MAX_FILE_CHUNK_BYTES,
1024 * 1024,
"MCP_MAX_FILE_CHUNK_BYTES",
4096,
),
maxEditFileBytes: parseInteger(
env.MCP_MAX_EDIT_FILE_BYTES,
64 * 1024 * 1024,
"MCP_MAX_EDIT_FILE_BYTES",
4096,
),
};
}