/** * Runs the `init` flow: detect the user's MCP client, build an Atlas server * config block, and either print it to stdout (default) or merge it into * the client's config file (`--write`). */ import { detectClients, getDefaultConfigPath, type ClientInfo, type McpClientId } from "./clients.js"; import { buildHostedServerConfig, buildServerConfig, mergeMcpServerConfig, readConfigOrNull, writeConfigWithBackup, type ServerConfig, } from "./config-merge.js"; import { detectLocalAtlas, resolveApiUrl } from "./local-atlas.js"; import { resolveFixturePaths, shouldUseFixture } from "./fixture.js"; import { HostedFlowError, runHostedAuthFlow, type HostedFlowOptions, type OpenBrowserImpl, type ServeImpl, } from "./hosted.js"; import { DemoFlowError, runDemoMintFlow } from "./demo.js"; // Re-exported so downstream packages (the canonical MCP eval in // packages/mcp/src/__tests__/canonical-mcp-auth.ts) can drive the // real OAuth 2.1 loopback flow against an in-process server. The flow's // implementation lives in `./hosted.ts` but we keep the import surface // flat so a single `import { runHostedAuthFlow, ... } from // "@useatlas/mcp/init"` covers everything callers need. export { HostedFlowError, runHostedAuthFlow, type Bearer, type HostedFlowErrorCode, type HostedFlowOptions, type HostedFlowResult, type LoopbackHandler, type LoopbackServer, type OpenBrowserImpl, type OpenBrowserResult, type ServeImpl, } from "./hosted.js"; export { DemoFlowError, runDemoMintFlow, type DemoFlowErrorCode, type DemoFlowOptions, type DemoFlowResult } from "./demo.js"; const SERVER_NAME = "atlas"; // #5604 — the anonymous demo is written under its OWN server name so a // `--demo` run never clobbers a real workspace's `atlas` entry (and vice versa). const DEMO_SERVER_NAME = "atlas-demo"; // There is no default for `--hosted`/`--demo`: the Atlas-operated hosted // service is shut down (ADR-0047), so the target is always the caller's own // deployment, named by `--api-url` or ATLAS_PUBLIC_API_URL. const HOSTED_SERVICE_SHUT_DOWN = "The hosted Atlas service (useatlas.dev) has shut down. " + "Run `bunx @useatlas/mcp init --local` against a local Atlas, or point at your own " + "self-hosted Atlas with managed auth: `bunx @useatlas/mcp init --hosted --api-url ` " + "(or set ATLAS_PUBLIC_API_URL)."; export interface LocalInitOptions { mode: "local"; client?: McpClientId; write?: boolean; /** Override the resolved config file path (test seam). */ configPathOverride?: string; /** Override the API URL used for local-Atlas detection. */ apiUrl?: string; /** Process env (test seam). Defaults to `process.env`. */ env?: NodeJS.ProcessEnv; /** Test seam — defaults to global `fetch`. */ fetchImpl?: typeof fetch; /** Test seam — defaults to filesystem-backed `detectClients()`. */ detectClientsImpl?: () => ClientInfo[]; } /** * Cross-workspace prompt outcome (#2073). The CLI offers two choices to * users who belong to more than one workspace: * - `single`: pin this agent to the active workspace (legacy behavior) * - `multi`: configure for cross-workspace use; the user upgrades the * agent to multi-scope in Settings → AI Agents * * Single-workspace users never see the prompt — the CLI treats it as an * implicit `single` choice. */ export type WorkspaceChoice = "single" | "multi"; /** * Test seam for the interactive prompt. Production callers leave this * undefined and the default readline-backed prompt is used; tests pass * a stub that immediately resolves with the chosen value. */ export type WorkspacePromptImpl = (args: { workspaceIds: string[]; activeWorkspaceId: string; }) => Promise; export interface HostedInitOptions { mode: "hosted"; /** The Atlas API base. Required here or via ATLAS_PUBLIC_API_URL — there is no default. */ apiUrl?: string; client?: McpClientId; write?: boolean; /** Override the resolved config file path (test seam). */ configPathOverride?: string; /** Process env (test seam). Defaults to `process.env`. */ env?: NodeJS.ProcessEnv; /** Test seams — passthrough to the hosted flow. */ fetchImpl?: typeof fetch; serveImpl?: ServeImpl; openBrowserImpl?: OpenBrowserImpl; randomBytesImpl?: (length: number) => Uint8Array; callbackTimeoutMs?: number; detectClientsImpl?: () => ClientInfo[]; /** * Override the multi-workspace prompt (test seam). Default uses * `node:readline` against process.stdin/stdout. Set to `() => Promise.resolve("single")` * for non-interactive scripts; tests typically supply a deterministic stub. */ workspacePromptImpl?: WorkspacePromptImpl; } /** * `init --demo` (#5604): mint an anonymous demo principal against an Atlas * deployment serving the NovaMart demo and write it as `atlas-demo`. No account, no email, no OAuth. */ export interface DemoInitOptions { mode: "demo"; /** The Atlas API base. Required here or via ATLAS_PUBLIC_API_URL — there is no default. */ apiUrl?: string; client?: McpClientId; write?: boolean; /** Override the resolved config file path (test seam). */ configPathOverride?: string; /** Process env (test seam). Defaults to `process.env`. */ env?: NodeJS.ProcessEnv; /** Test seam — defaults to global `fetch`. */ fetchImpl?: typeof fetch; detectClientsImpl?: () => ClientInfo[]; } export type RunInitOptions = LocalInitOptions | HostedInitOptions | DemoInitOptions; export interface RunInitResult { exitCode: number; } export async function runInit(options: RunInitOptions): Promise { if (options.mode === "demo") { return runDemo(options); } if (options.mode === "hosted") { return runHosted(options); } return runLocal(options); } async function runDemo(opts: DemoInitOptions): Promise { const env = opts.env ?? process.env; const apiUrl = opts.apiUrl ?? env.ATLAS_PUBLIC_API_URL; if (!apiUrl) { console.error(`[atlas-mcp init --demo] ${HOSTED_SERVICE_SHUT_DOWN}`); return { exitCode: 1 }; } const clientId = opts.client ?? pickDefaultClient(opts.detectClientsImpl); let result; try { result = await runDemoMintFlow({ apiUrl, client: clientId, fetchImpl: opts.fetchImpl }); } catch (err) { if (err instanceof DemoFlowError) { console.error(`[atlas-mcp init --demo] ${err.message}`); return { exitCode: 1 }; } const msg = err instanceof Error ? err.message : String(err); console.error(`[atlas-mcp init --demo] Unexpected error: ${msg}`); return { exitCode: 1 }; } const serverCfg = buildHostedServerConfig({ url: result.mcpUrl, accessToken: result.accessToken, }); const configPath = opts.configPathOverride ?? getDefaultConfigPath(clientId); console.log( `Anonymous demo session minted against ${apiUrl} (demo workspace ${result.workspaceId}). ` + `No account, no email — read-only, scoped to the NovaMart demo.`, ); console.log( `The token expires at ${new Date(result.expiresAt).toISOString()}; re-run this command to mint a fresh one.`, ); console.log( "After your first answer you may share an email with the `shareEmail` tool — optional, never required.", ); return writeOrPrint({ clientId, serverName: DEMO_SERVER_NAME, serverCfg, configPath, write: opts.write ?? false, }); } async function runHosted(opts: HostedInitOptions): Promise { const env = opts.env ?? process.env; const apiUrl = opts.apiUrl ?? env.ATLAS_PUBLIC_API_URL; if (!apiUrl) { console.error(`[atlas-mcp init --hosted] ${HOSTED_SERVICE_SHUT_DOWN}`); return { exitCode: 1 }; } let result; try { const flowOpts: HostedFlowOptions = { apiUrl, fetchImpl: opts.fetchImpl, serveImpl: opts.serveImpl, openBrowserImpl: opts.openBrowserImpl, randomBytesImpl: opts.randomBytesImpl, callbackTimeoutMs: opts.callbackTimeoutMs, }; result = await runHostedAuthFlow(flowOpts); } catch (err) { if (err instanceof HostedFlowError) { console.error(`[atlas-mcp init --hosted] ${err.message}`); return { exitCode: 1 }; } const msg = err instanceof Error ? err.message : String(err); console.error(`[atlas-mcp init --hosted] Unexpected error: ${msg}`); return { exitCode: 1 }; } const isMultiWorkspaceUser = result.workspaceIds.length > 1; let workspaceChoice: WorkspaceChoice = "single"; if (isMultiWorkspaceUser) { const promptImpl = opts.workspacePromptImpl ?? defaultWorkspacePrompt; workspaceChoice = await promptImpl({ workspaceIds: result.workspaceIds, activeWorkspaceId: result.workspaceId, }); } const serverCfg = buildHostedServerConfig({ url: result.mcpUrl, accessToken: result.accessToken, // Hint the agent's framework about the default workspace via env; // forward-compat for wrappers that bridge env into a header. Today's // MCP clients (Claude Desktop, Cursor) ignore the env block on // HTTP/SSE configs, but writing it is harmless. defaultWorkspaceId: workspaceChoice === "multi" ? result.workspaceId : undefined, }); const clientId = opts.client ?? pickDefaultClient(opts.detectClientsImpl); const configPath = opts.configPathOverride ?? getDefaultConfigPath(clientId); console.log(`Authorized for workspace ${result.workspaceId} at ${apiUrl}.`); if (workspaceChoice === "multi") { console.log( `Multi-workspace mode: this agent will use ${result.workspaceId} by default. ` + `Open Settings → AI Agents in your Atlas web app to grant access to your other ` + `workspaces (${result.workspaceIds.length - 1} additional).`, ); } return writeOrPrint({ clientId, serverName: SERVER_NAME, serverCfg, configPath, write: opts.write ?? false, afterSnippet: result.refreshToken ? "# A refresh token was issued. Atlas's MCP clients re-authenticate transparently — keep this config file at mode 0600." : undefined, }); } async function runLocal(opts: LocalInitOptions): Promise { const env = opts.env ?? process.env; const apiUrl = opts.apiUrl ?? resolveApiUrl(env); const localAtlas = await detectLocalAtlas({ url: apiUrl, fetchImpl: opts.fetchImpl }); const datasourceUrl = chooseDatasourceUrl({ env, localAtlas, }); const serverCfg = buildServerConfig({ datasourceUrl }); const clientId = opts.client ?? pickDefaultClient(opts.detectClientsImpl); const configPath = opts.configPathOverride ?? getDefaultConfigPath(clientId); if (localAtlas) { console.log(`local Atlas detected at ${apiUrl} — leaving ATLAS_DATASOURCE_URL unset so MCP inherits your shell env.`); } else if (datasourceUrl) { console.log(`No ATLAS_DATASOURCE_URL set — using bundled demo fixture at ${datasourceUrl}.`); console.log("Override by exporting ATLAS_DATASOURCE_URL before launching your MCP client."); } return writeOrPrint({ clientId, serverName: SERVER_NAME, serverCfg, configPath, write: opts.write ?? false, }); } function chooseDatasourceUrl(args: { env: NodeJS.ProcessEnv; localAtlas: boolean; }): string | undefined { // When the caller already has Atlas running OR has ATLAS_DATASOURCE_URL set // in their shell, leave the env block empty so MCP inherits the shell — // and so we never bake credentials into a JSON file that lives in a dotfile. if (args.localAtlas) return undefined; if (!shouldUseFixture(args.env)) return undefined; return resolveFixturePaths().sqliteUrl; } /** * The shared tail of every init mode: print a paste snippet (default, or when * the client has no config path), or merge into the client's config with a * `.bak` of the previous file. */ async function writeOrPrint(args: { clientId: McpClientId; serverName: string; serverCfg: ServerConfig; configPath: string | null; write: boolean; /** An extra line printed after the paste snippet (print mode only). */ afterSnippet?: string; }): Promise { const { clientId, serverName, serverCfg, configPath, write } = args; if (!write || configPath === null) { printPasteSnippet(clientId, serverCfg, configPath, serverName); if (args.afterSnippet) console.log(args.afterSnippet); return { exitCode: 0 }; } const existing = readConfigOrNull(configPath); let merged: string; try { merged = mergeMcpServerConfig(existing, serverName, serverCfg); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[atlas-mcp init] could not merge into existing config (${configPath}): ${msg}`); console.error("Aborting — your config was not modified. Re-run without --write to print a snippet instead."); return { exitCode: 1 }; } let writeResult; try { writeResult = await writeConfigWithBackup(configPath, merged); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[atlas-mcp init] failed to write ${configPath}: ${msg}`); if (existing !== null) { console.error("Your existing config was not modified — the new content was staged in a sibling tmp file and the rename never happened."); } return { exitCode: 1 }; } console.log(`Wrote ${configPath}`); if (writeResult.backupPath) { console.log(`Backed up the previous config to ${writeResult.backupPath}`); } console.log("Restart your MCP client to pick up the new server."); return { exitCode: 0 }; } function pickDefaultClient(impl?: () => ClientInfo[]): McpClientId { const clients = (impl ?? detectClients)(); const detected = clients.find((c) => c.detected && c.id !== "generic"); return detected?.id ?? "claude-desktop"; } function printPasteSnippet( clientId: McpClientId, serverCfg: ServerConfig, configPath: string | null, serverName: string = SERVER_NAME, ) { const snippet = JSON.stringify({ mcpServers: { [serverName]: serverCfg } }, null, 2); console.log(`# ${clientId}${configPath ? ` — ${configPath}` : ""}`); console.log("# Paste the following into your MCP client config:"); console.log(snippet); } /** * Default multi-workspace prompt (#2073). Asks via stdin/stdout once the * OAuth flow has finished and we know the user belongs to N>1 workspaces. * * Two choices, not N+1: * 1. `single` — pin the agent to the active workspace. URL bakes in * `${activeWorkspaceId}` (today's behavior, no follow-up needed). * 2. `multi` — configure for cross-workspace use; user upgrades the * grant set in Settings → AI Agents. * * Why not N+1: picking "workspace #2 instead of the active one" requires * either re-running OAuth with that workspace active OR a server-side * mint-against-other-workspace endpoint. Both are out of scope for the * 1.4.1 close-out PR; the two-choice prompt covers the install-once * path acceptably and Settings → AI Agents handles the rest. * * Falls back to `single` on any I/O error (TTY closed mid-prompt, EOF, * unparseable input). The CLI never blocks waiting for unreachable * input; degrading to the safe default is preferable to a hung process. */ const defaultWorkspacePrompt: WorkspacePromptImpl = async ({ workspaceIds, activeWorkspaceId, }) => { // Lazy-import readline so test runs that supply `workspacePromptImpl` // don't have to mock stdin/stdout. Same pattern as serve.ts's lazy // dynamic imports for transport modules. const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); const question = (q: string) => new Promise((resolve) => rl.question(q, resolve)); try { console.log(""); console.log( `You belong to ${workspaceIds.length} workspaces. Configure this agent for:`, ); console.log(` [1] Just ${activeWorkspaceId} (active workspace, default)`); console.log(` [2] All your workspaces (workspace-aware via X-Atlas-Workspace)`); const answer = (await question("Choice [1]: ")).trim(); if (answer === "2") return "multi"; return "single"; } catch (err: unknown) { // Narrowed: only Error instances downgrade silently to the safe // default ("single"). Anything else (a non-Error throw — vanishingly // rare from readline but possible from a future test harness) // re-throws so the install fails loudly rather than silently // configuring single-workspace for a multi-workspace user. // // Logged to stderr so operators piping the install output see the // downgrade — the previous empty `catch {}` mapped every TTY hiccup // to a silent "single" and looked indistinguishable from the user // picking option 1. if (!(err instanceof Error)) throw err; process.stderr.write( `[atlas-mcp init] workspace prompt failed (${err.message}) — defaulting to single-workspace setup. Re-run interactively to opt into multi-workspace.\n`, ); return "single"; } finally { rl.close(); } };