/** * Builds a `mcpServers[""]` block, merges it into an existing JSON * config (preserving sibling servers + non-mcp top-level keys), and writes * the result with a `.bak` of any previous file (timestamped if one already * exists). */ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, unlinkSync, } from "node:fs"; import { dirname } from "node:path"; /** Stdio launcher — `bunx @useatlas/mcp serve`, used by `init --local`. */ export interface StdioServerConfig { command: string; args: string[]; env?: Record; } /** * Streamable-HTTP server pointer — used by `init --hosted` against a * self-hosted Atlas instance with managed auth. MCP * clients (Claude Desktop, Cursor, Continue) all accept this `url` + * `headers` shape for remote MCP servers; the bearer is the JWT minted * by the OAuth 2.1 loopback flow in `init/hosted.ts`. * * `type: "http"` pins the transport to Streamable HTTP for the clients * that key off it explicitly (Claude Code, VS Code): without it those * clients guess from the URL, and a bare `{ url }` was defaulting them * to the deprecated HTTP+SSE transport — which the hosted endpoint no * longer speaks, so the first request 400s. Clients that auto-detect * transport (Cursor, Continue) ignore the field. The `url` is always the * canonical `/mcp/{workspaceId}` path — never the legacy `/sse` alias. * * `env` is a forward-compat slot for the cross-workspace identity flow * (#2073). Today's MCP clients don't bridge stdio-style env into HTTP * headers, so the block is informational — but writing it costs * nothing and lets a future agent-framework wrapper read * `ATLAS_DEFAULT_WORKSPACE` and inject `X-Atlas-Default-Workspace` on * every request without re-running this CLI. */ export interface HttpServerConfig { /** Transport discriminator — always Streamable HTTP for hosted Atlas. */ type: "http"; url: string; headers?: Record; env?: Record; } export type ServerConfig = StdioServerConfig | HttpServerConfig; /** * Type guard for `HttpServerConfig`. Wire format is locked by the MCP * client spec — `{ url, headers? }` for HTTP/SSE, `{ command, args, env? }` * for stdio — so we narrow structurally. Use this guard rather than * inlining `"url" in cfg` at call sites: a future stdio variant adding a * URL field would silently break inline checks. */ export function isHttpServerConfig(cfg: ServerConfig): cfg is HttpServerConfig { return "url" in cfg && typeof cfg.url === "string"; } /** Type guard for `StdioServerConfig`. See `isHttpServerConfig`. */ export function isStdioServerConfig(cfg: ServerConfig): cfg is StdioServerConfig { return "command" in cfg && typeof cfg.command === "string"; } interface BuildOpts { /** Inline ATLAS_DATASOURCE_URL into the env block. Omit to inherit the user's shell env. */ datasourceUrl?: string; /** Override the package name used in the bunx invocation. Defaults to @useatlas/mcp. */ packageName?: string; } const DEFAULT_PACKAGE = "@useatlas/mcp"; export function buildServerConfig(opts: BuildOpts = {}): StdioServerConfig { const pkg = opts.packageName ?? DEFAULT_PACKAGE; const cfg: StdioServerConfig = { command: "bunx", args: [pkg, "serve"], }; if (opts.datasourceUrl) { cfg.env = { ATLAS_DATASOURCE_URL: opts.datasourceUrl }; } return cfg; } interface BuildHostedOpts { /** The canonical MCP endpoint URL — e.g. `https://atlas.example.com/mcp/` (no `/sse`). */ url: string; /** OAuth 2.1 access token (JWT). Written verbatim into the Authorization header. */ accessToken: string; /** * Default workspace id (#2073) for multi-workspace agents. When set, * the config writes an `env: { ATLAS_DEFAULT_WORKSPACE }` block as * the forward-compat hint for agent frameworks that bridge env into * `X-Atlas-Default-Workspace`. Single-workspace clients leave this * undefined and the env block is omitted. */ defaultWorkspaceId?: string; } export function buildHostedServerConfig(opts: BuildHostedOpts): HttpServerConfig { const cfg: HttpServerConfig = { type: "http", url: opts.url, headers: { Authorization: `Bearer ${opts.accessToken}` }, }; if (opts.defaultWorkspaceId) { cfg.env = { ATLAS_DEFAULT_WORKSPACE: opts.defaultWorkspaceId }; } return cfg; } interface ExistingShape { mcpServers?: unknown; [key: string]: unknown; } export function mergeMcpServerConfig( existingJson: string | null, serverName: string, serverConfig: ServerConfig, ): string { let parsed: ExistingShape = {}; if (existingJson !== null) { try { const raw = JSON.parse(existingJson) as unknown; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { throw new Error("Existing config root must be a JSON object"); } parsed = raw as ExistingShape; } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Existing config is not valid JSON: ${msg}`, { cause: err }); } } const existingServers = parsed.mcpServers; // We never inspect sibling server values — only spread them through. So the // value type is `unknown`, not `ServerConfig`. A future "list existing // servers" feature would need to narrow per-entry before reading anything. let serversObj: Record; if (existingServers === undefined) { serversObj = {}; } else if ( existingServers === null || typeof existingServers !== "object" || Array.isArray(existingServers) ) { throw new Error( "Existing config has a non-object value at `mcpServers` — refusing to overwrite", ); } else { serversObj = { ...(existingServers as Record) }; } serversObj[serverName] = serverConfig; const merged: ExistingShape = { ...parsed, mcpServers: serversObj }; return `${JSON.stringify(merged, null, 2)}\n`; } export interface WriteResult { /** Path to the .bak file we wrote, or null if no prior config existed. */ backupPath: string | null; } /** * Write a file atomically with a `.bak` of any previous version. * * Order of operations matters: a naive `cp old → bak; write new` leaves * the user without an original config if `write` fails after the copy. * Instead we write to a sibling `.tmp` first, rename it over the live * path (POSIX `rename(2)` is atomic on the same filesystem), and only * then write the backup — so a failed write never destroys the prior * file, and a failed backup leaves the new file in place. */ export async function writeConfigWithBackup( configPath: string, newContent: string, ): Promise { const dir = dirname(configPath); if (!existsSync(configPath)) { try { mkdirSync(dir, { recursive: true }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error( `Could not create config directory ${dir}: ${msg}`, { cause: err }, ); } } const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const tmpPath = `${configPath}.${stamp}.tmp`; try { writeFileSync(tmpPath, newContent, { encoding: "utf8", mode: 0o600 }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Could not write ${tmpPath}: ${msg}`, { cause: err }); } let backupPath: string | null = null; if (existsSync(configPath)) { backupPath = existsSync(`${configPath}.bak`) ? `${configPath}.${stamp}.bak` : `${configPath}.bak`; try { copyFileSync(configPath, backupPath); } catch (err) { // Best-effort cleanup of the staged tmp file before re-throwing. try { unlinkSync(tmpPath); } catch { // intentionally ignored: cleanup of a tmp we just wrote is best-effort. } const msg = err instanceof Error ? err.message : String(err); throw new Error( `Could not back up existing config ${configPath} → ${backupPath}: ${msg}`, { cause: err }, ); } } try { renameSync(tmpPath, configPath); } catch (err) { try { unlinkSync(tmpPath); } catch { // intentionally ignored: cleanup of a tmp we just wrote is best-effort. } const msg = err instanceof Error ? err.message : String(err); throw new Error( `Could not rename ${tmpPath} → ${configPath}: ${msg}`, { cause: err }, ); } return { backupPath }; } export function readConfigOrNull(configPath: string): string | null { if (!existsSync(configPath)) return null; return readFileSync(configPath, "utf8"); }