import { URL } from "node:url";
export type AuthScheme = "auto" | "personal_token" | "oauth";
export type SessionConfig = {
apiToken: string;
authScheme: AuthScheme;
baseUrl: string;
defaultTeamId?: number;
requestTimeout: number;
defaultHeaders: Record<string, string>;
};
export type AppConfig = {
apiToken?: string;
defaultTeamId?: number;
primaryLanguage?: string;
baseUrl?: string;
requestTimeoutMs?: number;
defaultHeadersJson?: string;
authScheme?: AuthScheme;
};
function toOptionalString(value: string | undefined | null): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parseOptionalInteger(value: string | undefined | null): number | undefined {
const raw = toOptionalString(value);
if (!raw) {
return undefined;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed;
}
const AUTH_SCHEMES: AuthScheme[] = ["auto", "personal_token", "oauth"];
function parseAuthSchemeValue(value: string | undefined | null): AuthScheme | undefined {
const raw = toOptionalString(value);
if (!raw) {
return undefined;
}
const normalised = raw.toLowerCase();
if ((AUTH_SCHEMES as string[]).includes(normalised)) {
return normalised as AuthScheme;
}
return undefined;
}
function parseHeaders(value: string | undefined, language: string | undefined): Record<string, string> {
const entries: Record<string, string> = {};
const source = toOptionalString(value);
if (source) {
try {
const parsed = JSON.parse(source) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") {
entries[key] = entry;
} else if (entry !== undefined && entry !== null) {
entries[key] = String(entry);
}
}
}
} catch {
return entries;
}
}
if (language && !entries["Accept-Language"]) {
entries["Accept-Language"] = language;
}
return entries;
}
export function normaliseBaseUrl(value: string | undefined): string {
const fallback = "https://api.clickup.com/api/v2";
const raw = toOptionalString(value);
if (!raw) {
return fallback;
}
const prefixed = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
let url: URL;
try {
url = new URL(prefixed);
} catch {
return fallback;
}
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length === 0) {
url.pathname = "/api/v2";
} else if (segments[0] === "api") {
if (segments.length === 1) {
segments.push("v2");
}
url.pathname = `/${segments.join("/")}`;
} else {
url.pathname = `/${segments.join("/")}`;
}
url.hash = "";
url.search = "";
return url.toString().replace(/\/+$/, "");
}
function resolveRequestTimeout(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value) || value <= 0) {
return 30;
}
return Math.ceil(value / 1000);
}
type EnvSource = Record<string, string | undefined>;
function resolveEnv(source?: EnvSource): EnvSource {
return source ?? process.env;
}
function readEnv(env: EnvSource, key: string): string | undefined {
return env[key];
}
export function fromEnv(env?: EnvSource): AppConfig {
const source = resolveEnv(env);
const apiToken = toOptionalString(readEnv(source, "CLICKUP_TOKEN"));
const defaultTeamId = parseOptionalInteger(readEnv(source, "CLICKUP_DEFAULT_TEAM_ID"));
const primaryLanguage = toOptionalString(readEnv(source, "CLICKUP_PRIMARY_LANGUAGE"));
const baseUrl = toOptionalString(readEnv(source, "CLICKUP_BASE_URL"));
const requestTimeoutMs = parseOptionalInteger(readEnv(source, "REQUEST_TIMEOUT_MS"));
const defaultHeadersJson = toOptionalString(readEnv(source, "DEFAULT_HEADERS_JSON"));
const authScheme = parseAuthSchemeValue(readEnv(source, "CLICKUP_AUTH_SCHEME"));
return {
apiToken,
defaultTeamId,
primaryLanguage,
baseUrl,
requestTimeoutMs,
defaultHeadersJson,
authScheme
};
}
export function validateOrThrow(config: AppConfig): void {
// Defensive: never throw during initialization, matching Python MCP behavior.
// Invalid values are normalized in toSessionConfig instead.
return;
}
export function toSessionConfig(config: AppConfig, envSource?: EnvSource): SessionConfig {
const env = resolveEnv(envSource);
// Defensive: implement fallback chain config → environment → defaults
// All inputs are sanitized and normalized to prevent initialization failures
const apiToken = config.apiToken ?? toOptionalString(readEnv(env, "CLICKUP_TOKEN")) ?? "";
// Defensive: handle NaN and invalid team IDs from config
let defaultTeamId = config.defaultTeamId;
if (defaultTeamId !== undefined && !Number.isFinite(defaultTeamId)) {
// Log normalization for debugging
console.warn("session_config_normalization", {
message: "Invalid defaultTeamId in config, attempting fallback to environment",
configValue: defaultTeamId
});
defaultTeamId = undefined;
}
// Fallback to environment if config value was invalid
if (defaultTeamId === undefined) {
defaultTeamId = parseOptionalInteger(readEnv(env, "CLICKUP_DEFAULT_TEAM_ID"));
}
const baseUrl = normaliseBaseUrl(config.baseUrl);
const requestTimeout = resolveRequestTimeout(config.requestTimeoutMs);
const defaultHeaders = parseHeaders(config.defaultHeadersJson, config.primaryLanguage);
// Defensive: ensure defaultTeamId is either a valid finite number or undefined
const normalizedTeamId = Number.isFinite(defaultTeamId ?? NaN) ? defaultTeamId : undefined;
return {
apiToken,
authScheme: config.authScheme ?? "auto",
baseUrl,
defaultTeamId: normalizedTeamId,
requestTimeout,
defaultHeaders
};
}