@useatlas/mcp
Advanced tools
+39
| { | ||
| "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", | ||
| "name": "io.github.AtlasDevHQ/atlas", | ||
| "title": "Atlas", | ||
| "description": "Atlas is a YAML-defined semantic layer for analytics — authored by humans, consumed by AI agents.", | ||
| "version": "0.0.2", | ||
| "websiteUrl": "https://docs.useatlas.dev/guides/mcp", | ||
| "repository": { | ||
| "url": "https://github.com/AtlasDevHQ/atlas", | ||
| "source": "github" | ||
| }, | ||
| "packages": [ | ||
| { | ||
| "registryType": "npm", | ||
| "identifier": "@useatlas/mcp", | ||
| "version": "0.0.2", | ||
| "runtimeHint": "npx", | ||
| "transport": { "type": "stdio" }, | ||
| "environmentVariables": [ | ||
| { | ||
| "name": "ATLAS_DATASOURCE_URL", | ||
| "description": "Analytics datasource URL (postgres, mysql, clickhouse, snowflake, duckdb, bigquery, salesforce). Optional — falls back to a bundled NovaMart SQLite demo when unset.", | ||
| "isRequired": false, | ||
| "isSecret": true | ||
| }, | ||
| { | ||
| "name": "ATLAS_PROVIDER", | ||
| "description": "LLM provider: anthropic, openai, bedrock, ollama, openai-compatible, gateway. Optional — the MCP server does not call an LLM directly (the client does); set this only if you also run Atlas's chat or scheduler.", | ||
| "isRequired": false | ||
| }, | ||
| { | ||
| "name": "ATLAS_API_URL", | ||
| "description": "URL of a running Atlas API. Optional — defaults to http://localhost:3001. When set, the MCP server inherits the API's connections, semantic layer, and governance.", | ||
| "isRequired": false | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } |
| /** | ||
| * `init --hosted` flow: OAuth 2.1 authorization-code-with-PKCE against the | ||
| * hosted Atlas MCP endpoint (#2024 PR E). | ||
| * | ||
| * ── Why loopback (RFC 8252) over device code (RFC 8628) ──────────────── | ||
| * | ||
| * The MCP authorization spec requires an OAuth 2.1 server (PR C wired | ||
| * `@better-auth/oauth-provider` for that). Standards-compliant native MCP | ||
| * clients (Claude Desktop, Cursor, ChatGPT) already use the loopback flow | ||
| * — same shape as `gh auth login` and `gcloud auth login`. RFC 8252 §7.3 | ||
| * recommends loopback for any native client that can spawn a browser: | ||
| * better UX than reading codes off a screen, no polling, no | ||
| * pre-registered client_id needed when DCR is available. | ||
| * | ||
| * The loopback redirect MUST use `127.0.0.1` (not `localhost`) per RFC | ||
| * 8252 §7.3 — DNS resolution to `localhost` is implementation-defined and | ||
| * a hostile resolver could redirect the auth code somewhere else. | ||
| * | ||
| * ── Why DCR is unauthenticated ───────────────────────────────────────── | ||
| * | ||
| * The auth server is configured with `allowUnauthenticatedClientRegistration: true` | ||
| * in `packages/api/src/lib/auth/server.ts`. Without it an MCP client | ||
| * couldn't bootstrap — there's no admin pre-issuing a client_id for every | ||
| * end-user's machine. The MCP spec is on track to standardize Client ID | ||
| * Metadata Documents which would let us turn this off; track upstream. | ||
| * | ||
| * ── What we DON'T persist ────────────────────────────────────────────── | ||
| * | ||
| * The PKCE `code_verifier` and the registered `client_id` are kept in | ||
| * memory for the duration of the flow only — never written to disk. The | ||
| * only artifact written is the JWT access token (and optional refresh | ||
| * token), inside the user's MCP client config file at mode 0o600. | ||
| * | ||
| * ── Test seams ───────────────────────────────────────────────────────── | ||
| * | ||
| * Every external dependency is overrideable so the unit tests in | ||
| * `__tests__/init/hosted.test.ts` never open a browser, never bind a real | ||
| * port, and never hit a real OAuth server. | ||
| * | ||
| * - `fetchImpl` — discovery, DCR, token exchange | ||
| * - `openBrowserImpl` — browser launch (returns success/failure) | ||
| * - `serveImpl` — loopback listener factory | ||
| * - `randomBytesImpl` — PKCE verifier + state (deterministic asserts) | ||
| * - `nowImpl` — for the 5-minute timeout | ||
| * - `consoleImpl` — captures user-facing output | ||
| */ | ||
| // ── External shape contracts (test seams) ────────────────────────────── | ||
| export interface LoopbackServer { | ||
| /** Bound port — the OS picked it when we requested 0. */ | ||
| readonly port: number; | ||
| /** Stop accepting and drain. Called on success, timeout, or error. */ | ||
| stop(): Promise<void>; | ||
| } | ||
| /** | ||
| * Loopback handler return value. Status is narrowed to the two values | ||
| * the OAuth callback ever produces — 200 on success, 400 on protocol | ||
| * failures (state mismatch, missing code, oauth error). The body is HTML | ||
| * so any `ServeImpl` substituting for the default `Bun.serve` backend | ||
| * MUST set `Content-Type: text/html; charset=utf-8` on the response. | ||
| */ | ||
| export interface LoopbackHandler { | ||
| (params: URLSearchParams, method: string): { status: 200 | 400 | 405; body: string }; | ||
| } | ||
| export interface ServeImpl { | ||
| (handler: LoopbackHandler): Promise<LoopbackServer>; | ||
| } | ||
| export interface OpenBrowserResult { | ||
| ok: boolean; | ||
| /** Human-readable detail shown when ok=false. */ | ||
| detail?: string; | ||
| } | ||
| export interface OpenBrowserImpl { | ||
| (url: string): Promise<OpenBrowserResult>; | ||
| } | ||
| export interface ConsoleImpl { | ||
| log(message: string): void; | ||
| error(message: string): void; | ||
| } | ||
| // ── Public types ─────────────────────────────────────────────────────── | ||
| export interface HostedFlowOptions { | ||
| /** | ||
| * Atlas API base — e.g. `https://api.useatlas.dev`. The discovery doc | ||
| * lives at `${apiUrl}/.well-known/oauth-authorization-server/api/auth`. | ||
| * Must be `https://`, except for `http://127.0.0.1` / `http://localhost` | ||
| * which are accepted for local-dev testing. | ||
| */ | ||
| apiUrl: string; | ||
| /** Default 5 min; smaller values shorten test runtime. */ | ||
| callbackTimeoutMs?: number; | ||
| /** Test seams — defaults wired to real implementations. */ | ||
| fetchImpl?: typeof fetch; | ||
| serveImpl?: ServeImpl; | ||
| openBrowserImpl?: OpenBrowserImpl; | ||
| randomBytesImpl?: (length: number) => Uint8Array; | ||
| consoleImpl?: ConsoleImpl; | ||
| } | ||
| /** | ||
| * Branded `string` for OAuth bearer credentials. The brand carries no | ||
| * runtime cost; its purpose is to surface bearer-handling code in code | ||
| * review (`accessToken: Bearer` next to a `console.log` is a smell) and | ||
| * to keep the secret-vs-non-secret distinction visible in the type, not | ||
| * just in trailing comments. | ||
| */ | ||
| export type Bearer = string & { readonly __brand: "Bearer" }; | ||
| export interface HostedFlowResult { | ||
| /** OAuth 2.1 access token (signed JWT). Treat as a credential. */ | ||
| accessToken: Bearer; | ||
| /** OAuth 2.1 refresh token (when offline_access was granted). */ | ||
| refreshToken: Bearer | null; | ||
| /** The `https://atlas.useatlas.dev/workspace_id` claim from the JWT. */ | ||
| workspaceId: string; | ||
| /** `${apiUrl}/mcp/${workspaceId}/sse` — what the MCP client connects to. */ | ||
| mcpUrl: string; | ||
| } | ||
| // ── OAuth 2.1 metadata + DCR shapes ──────────────────────────────────── | ||
| // | ||
| // Restricted to fields we actually consume. Better Auth's discovery doc | ||
| // includes more (e.g. `userinfo_endpoint`, `revocation_endpoint`) but | ||
| // nothing else is load-bearing for the loopback flow. | ||
| interface AuthServerMetadata { | ||
| authorization_endpoint: string; | ||
| token_endpoint: string; | ||
| registration_endpoint: string; | ||
| issuer: string; | ||
| } | ||
| interface RegistrationResponse { | ||
| client_id: string; | ||
| } | ||
| interface TokenResponse { | ||
| access_token: string; | ||
| refresh_token?: string; | ||
| token_type?: string; | ||
| expires_in?: number; | ||
| scope?: string; | ||
| } | ||
| // ── Errors ───────────────────────────────────────────────────────────── | ||
| /** | ||
| * Stable error code for tests + exit-code mapping. Exported as a named | ||
| * union so call sites can `satisfies HostedFlowErrorCode` against the | ||
| * full set (e.g. an exhaustive switch over CLI exit codes). | ||
| */ | ||
| export type HostedFlowErrorCode = | ||
| | "invalid_api_url" | ||
| | "discovery_failed" | ||
| | "issuer_mismatch" | ||
| | "registration_failed" | ||
| | "loopback_bind_failed" | ||
| | "browser_failed" | ||
| | "callback_timeout" | ||
| | "callback_state_mismatch" | ||
| | "callback_missing_code" | ||
| | "callback_oauth_error" | ||
| | "callback_method_not_allowed" | ||
| | "token_exchange_failed" | ||
| | "malformed_jwt" | ||
| | "missing_workspace_claim"; | ||
| export class HostedFlowError extends Error { | ||
| constructor( | ||
| message: string, | ||
| public readonly code: HostedFlowErrorCode, | ||
| options?: ErrorOptions, | ||
| ) { | ||
| super(message, options); | ||
| this.name = "HostedFlowError"; | ||
| } | ||
| } | ||
| // ── Constants ────────────────────────────────────────────────────────── | ||
| const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; | ||
| const FETCH_TIMEOUT_MS = 30 * 1000; | ||
| const REQUESTED_SCOPE = "openid profile email mcp:read offline_access"; | ||
| const CLIENT_NAME = "Atlas MCP CLI"; | ||
| const WORKSPACE_CLAIM = "https://atlas.useatlas.dev/workspace_id"; | ||
| // ── Public entry point ───────────────────────────────────────────────── | ||
| /** | ||
| * Run the OAuth 2.1 loopback flow against `apiUrl` and return the minted | ||
| * JWT. Pure data — the caller decides whether to print or write. | ||
| * | ||
| * Cleanup: the loopback listener is stopped in a `finally` so a failed | ||
| * exchange never leaves an orphan port bound. Browser-launch failures | ||
| * fall back to printing the URL and continue waiting for the callback. | ||
| */ | ||
| export async function runHostedAuthFlow( | ||
| options: HostedFlowOptions, | ||
| ): Promise<HostedFlowResult> { | ||
| const apiUrl = options.apiUrl.replace(/\/+$/, ""); | ||
| validateApiUrl(apiUrl); | ||
| const fetchImpl = options.fetchImpl ?? fetch; | ||
| const serveImpl = options.serveImpl ?? defaultServeImpl; | ||
| const openBrowserImpl = options.openBrowserImpl ?? defaultOpenBrowserImpl; | ||
| const randomBytes = options.randomBytesImpl ?? defaultRandomBytes; | ||
| const cli: ConsoleImpl = options.consoleImpl ?? { | ||
| log: (m) => console.log(m), | ||
| error: (m) => console.error(m), | ||
| }; | ||
| const timeoutMs = options.callbackTimeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| // Step 1 — discovery | ||
| const metadata = await discover(apiUrl, fetchImpl); | ||
| // Step 2 — generate PKCE + state and start the loopback listener BEFORE | ||
| // registering, so we know the redirect_uri the OS picked. RFC 8252 | ||
| // §7.3 says clients SHOULD bind to 127.0.0.1 with port 0 and use | ||
| // whatever port they get back. | ||
| const state = encodeBase64Url(randomBytes(32)); | ||
| const codeVerifier = encodeBase64Url(randomBytes(32)); | ||
| const codeChallenge = await pkceChallenge(codeVerifier); | ||
| const callbackResolver = createCallbackResolver(state, timeoutMs); | ||
| // intentionally ignored: the outer `finally` calls `cancel()`, which | ||
| // settles the resolver as a typed rejection. Whenever the outer flow | ||
| // already failed via a different throw path (register/exchange/etc.) | ||
| // that cancel-rejection becomes orphaned. The real error is surfaced | ||
| // by the throw above; this `.catch` only silences Node's unhandled- | ||
| // rejection warning for the abort path. | ||
| callbackResolver.promise.catch(() => {}); | ||
| let server: LoopbackServer | undefined; | ||
| try { | ||
| server = await serveImpl(callbackResolver.handler); | ||
| const redirectUri = `http://127.0.0.1:${server.port}/callback`; | ||
| // Step 3 — register a public client via DCR | ||
| const clientId = await register(metadata, redirectUri, fetchImpl); | ||
| // Step 4 — open the browser. Failure is non-fatal: print the URL | ||
| // (already printed before the launch attempt) and continue waiting. | ||
| // In headless / CI shells the user will hit `callback_timeout` | ||
| // instead — the message there links back to the printed URL above. | ||
| const authorizeUrl = buildAuthorizeUrl({ | ||
| authorizationEndpoint: metadata.authorization_endpoint, | ||
| clientId, | ||
| redirectUri, | ||
| state, | ||
| codeChallenge, | ||
| }); | ||
| cli.log(`Opening your browser to authorize Atlas MCP CLI…`); | ||
| cli.log(`If it doesn't open automatically, visit:`); | ||
| cli.log(` ${authorizeUrl}`); | ||
| const openResult = await openBrowserImpl(authorizeUrl); | ||
| if (!openResult.ok) { | ||
| cli.error( | ||
| `[atlas-mcp init] Could not auto-launch the browser${openResult.detail ? ` (${openResult.detail})` : ""}. Open the URL above manually.`, | ||
| ); | ||
| } | ||
| // Step 5 — wait for the callback | ||
| const code = await callbackResolver.promise; | ||
| // Step 6 — exchange the code | ||
| const tokenResponse = await exchangeCode({ | ||
| tokenEndpoint: metadata.token_endpoint, | ||
| clientId, | ||
| redirectUri, | ||
| code, | ||
| codeVerifier, | ||
| fetchImpl, | ||
| }); | ||
| // Step 7 — extract + verify claims. We don't verify the JWT | ||
| // signature (the hosted MCP endpoint re-verifies on every request via | ||
| // JWKS) but we DO check `iss` matches the issuer we discovered, so a | ||
| // hostile auth server pretending to be Atlas can't slip a token past | ||
| // us at write-time. | ||
| const claims = decodeJwtPayload(tokenResponse.access_token); | ||
| enforceIssuer(claims, metadata.issuer); | ||
| const workspaceId = extractWorkspaceClaim(claims); | ||
| return { | ||
| accessToken: tokenResponse.access_token as Bearer, | ||
| refreshToken: tokenResponse.refresh_token | ||
| ? (tokenResponse.refresh_token as Bearer) | ||
| : null, | ||
| workspaceId, | ||
| mcpUrl: `${apiUrl}/mcp/${workspaceId}/sse`, | ||
| }; | ||
| } finally { | ||
| // Cancel the timer FIRST so a 5-min hang can't survive an early | ||
| // failure in `register`/`exchangeCode`/`extractWorkspaceClaim`. Then | ||
| // tear down the listener. | ||
| callbackResolver.cancel(); | ||
| if (server) { | ||
| await server.stop().catch((err) => { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| cli.error(`[atlas-mcp init] loopback listener cleanup warning: ${msg}`); | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * `apiUrl` ends up driving discovery, the redirect target, and the | ||
| * eventual MCP URL written to disk. A typo'd or hostile env var | ||
| * (`ATLAS_PUBLIC_API_URL=http://evil.example.com`) would drive the user | ||
| * through a fake authorize page and ship a foreign-issued JWT into their | ||
| * MCP client config. Reject anything that isn't `https://`, except for | ||
| * documented localhost dev URLs. | ||
| */ | ||
| function validateApiUrl(apiUrl: string): void { | ||
| let parsed: URL; | ||
| try { | ||
| parsed = new URL(apiUrl); | ||
| } catch (err) { | ||
| throw new HostedFlowError( | ||
| `--api-url is not a valid URL: ${apiUrl}`, | ||
| "invalid_api_url", | ||
| { cause: err }, | ||
| ); | ||
| } | ||
| if (parsed.protocol === "https:") return; | ||
| if ( | ||
| parsed.protocol === "http:" && | ||
| (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") | ||
| ) { | ||
| return; | ||
| } | ||
| throw new HostedFlowError( | ||
| `--api-url must use https:// (or http://localhost for dev). Got: ${apiUrl}`, | ||
| "invalid_api_url", | ||
| ); | ||
| } | ||
| // ── Step implementations ─────────────────────────────────────────────── | ||
| /** | ||
| * Surface OAuth 2.1 error responses as `error: error_description` when | ||
| * the body parses as the standard `{error,error_description,error_uri}` | ||
| * shape (RFC 6749 §5.2). Falls back to the raw text (truncated to 1KiB) | ||
| * when the body is empty / not JSON / not the canonical shape, so we | ||
| * never silently lose upstream signal. | ||
| */ | ||
| async function describeErrorBody(res: Response): Promise<string> { | ||
| const raw = await res.text().catch(() => ""); | ||
| if (!raw) return ""; | ||
| try { | ||
| const parsed = JSON.parse(raw) as Partial<{ | ||
| error: string; | ||
| error_description: string; | ||
| error_uri: string; | ||
| }>; | ||
| const parts: string[] = []; | ||
| if (typeof parsed.error === "string" && parsed.error.length > 0) { | ||
| parts.push(parsed.error); | ||
| } | ||
| if (typeof parsed.error_description === "string" && parsed.error_description.length > 0) { | ||
| parts.push(parsed.error_description); | ||
| } | ||
| if (typeof parsed.error_uri === "string" && parsed.error_uri.length > 0) { | ||
| parts.push(`see ${parsed.error_uri}`); | ||
| } | ||
| if (parts.length > 0) return parts.join(": "); | ||
| } catch { | ||
| // intentionally ignored: not JSON — fall through to raw-text branch. | ||
| } | ||
| return raw.length > 1024 ? `${raw.slice(0, 1024)}…` : raw; | ||
| } | ||
| async function discover( | ||
| apiUrl: string, | ||
| fetchImpl: typeof fetch, | ||
| ): Promise<AuthServerMetadata> { | ||
| const url = `${apiUrl}/.well-known/oauth-authorization-server/api/auth`; | ||
| let res: Response; | ||
| try { | ||
| res = await fetchImpl(url, { | ||
| method: "GET", | ||
| headers: { Accept: "application/json" }, | ||
| signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), | ||
| }); | ||
| } catch (err) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| throw new HostedFlowError( | ||
| `Could not reach Atlas auth discovery at ${url}: ${msg}`, | ||
| "discovery_failed", | ||
| { cause: err }, | ||
| ); | ||
| } | ||
| if (!res.ok) { | ||
| throw new HostedFlowError( | ||
| `Atlas auth discovery returned ${res.status} for ${url}`, | ||
| "discovery_failed", | ||
| ); | ||
| } | ||
| const body = (await res.json().catch((err) => { | ||
| throw new HostedFlowError( | ||
| `Atlas auth discovery body was not JSON: ${err instanceof Error ? err.message : String(err)}`, | ||
| "discovery_failed", | ||
| { cause: err }, | ||
| ); | ||
| })) as Partial<AuthServerMetadata>; | ||
| if ( | ||
| typeof body.authorization_endpoint !== "string" || | ||
| typeof body.token_endpoint !== "string" || | ||
| typeof body.registration_endpoint !== "string" || | ||
| typeof body.issuer !== "string" | ||
| ) { | ||
| throw new HostedFlowError( | ||
| `Atlas auth discovery is missing one of: authorization_endpoint, token_endpoint, registration_endpoint, issuer`, | ||
| "discovery_failed", | ||
| ); | ||
| } | ||
| return { | ||
| authorization_endpoint: body.authorization_endpoint, | ||
| token_endpoint: body.token_endpoint, | ||
| registration_endpoint: body.registration_endpoint, | ||
| issuer: body.issuer, | ||
| }; | ||
| } | ||
| async function register( | ||
| metadata: AuthServerMetadata, | ||
| redirectUri: string, | ||
| fetchImpl: typeof fetch, | ||
| ): Promise<string> { | ||
| // Public client — no `client_secret`. `token_endpoint_auth_method: "none"` | ||
| // matches the public-client posture; the server enforces PKCE. | ||
| const body = { | ||
| client_name: CLIENT_NAME, | ||
| redirect_uris: [redirectUri], | ||
| grant_types: ["authorization_code", "refresh_token"], | ||
| response_types: ["code"], | ||
| scope: REQUESTED_SCOPE, | ||
| token_endpoint_auth_method: "none", | ||
| }; | ||
| let res: Response; | ||
| try { | ||
| res = await fetchImpl(metadata.registration_endpoint, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json", Accept: "application/json" }, | ||
| body: JSON.stringify(body), | ||
| signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), | ||
| }); | ||
| } catch (err) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| throw new HostedFlowError( | ||
| `Dynamic Client Registration failed: ${msg}`, | ||
| "registration_failed", | ||
| { cause: err }, | ||
| ); | ||
| } | ||
| if (!res.ok) { | ||
| const detail = await describeErrorBody(res); | ||
| throw new HostedFlowError( | ||
| `Dynamic Client Registration returned ${res.status}${detail ? `: ${detail}` : ""}`, | ||
| "registration_failed", | ||
| ); | ||
| } | ||
| const data = (await res.json().catch((err) => { | ||
| throw new HostedFlowError( | ||
| `Dynamic Client Registration response was not JSON: ${err instanceof Error ? err.message : String(err)}`, | ||
| "registration_failed", | ||
| { cause: err }, | ||
| ); | ||
| })) as Partial<RegistrationResponse>; | ||
| if (typeof data.client_id !== "string" || data.client_id.length === 0) { | ||
| throw new HostedFlowError( | ||
| `Dynamic Client Registration response missing client_id`, | ||
| "registration_failed", | ||
| ); | ||
| } | ||
| return data.client_id; | ||
| } | ||
| interface BuildAuthorizeUrlArgs { | ||
| authorizationEndpoint: string; | ||
| clientId: string; | ||
| redirectUri: string; | ||
| state: string; | ||
| codeChallenge: string; | ||
| } | ||
| function buildAuthorizeUrl(args: BuildAuthorizeUrlArgs): string { | ||
| const params = new URLSearchParams({ | ||
| response_type: "code", | ||
| client_id: args.clientId, | ||
| redirect_uri: args.redirectUri, | ||
| scope: REQUESTED_SCOPE, | ||
| state: args.state, | ||
| code_challenge: args.codeChallenge, | ||
| code_challenge_method: "S256", | ||
| }); | ||
| const sep = args.authorizationEndpoint.includes("?") ? "&" : "?"; | ||
| return `${args.authorizationEndpoint}${sep}${params.toString()}`; | ||
| } | ||
| interface ExchangeArgs { | ||
| tokenEndpoint: string; | ||
| clientId: string; | ||
| redirectUri: string; | ||
| code: string; | ||
| codeVerifier: string; | ||
| fetchImpl: typeof fetch; | ||
| } | ||
| async function exchangeCode(args: ExchangeArgs): Promise<TokenResponse> { | ||
| const body = new URLSearchParams({ | ||
| grant_type: "authorization_code", | ||
| code: args.code, | ||
| redirect_uri: args.redirectUri, | ||
| client_id: args.clientId, | ||
| code_verifier: args.codeVerifier, | ||
| }); | ||
| let res: Response; | ||
| try { | ||
| res = await args.fetchImpl(args.tokenEndpoint, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| Accept: "application/json", | ||
| }, | ||
| body: body.toString(), | ||
| signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), | ||
| }); | ||
| } catch (err) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| throw new HostedFlowError( | ||
| `Token exchange failed: ${msg}`, | ||
| "token_exchange_failed", | ||
| { cause: err }, | ||
| ); | ||
| } | ||
| if (!res.ok) { | ||
| const detail = await describeErrorBody(res); | ||
| throw new HostedFlowError( | ||
| `Token endpoint returned ${res.status}${detail ? `: ${detail}` : ""}`, | ||
| "token_exchange_failed", | ||
| ); | ||
| } | ||
| const data = (await res.json().catch((err) => { | ||
| throw new HostedFlowError( | ||
| `Token endpoint response was not JSON: ${err instanceof Error ? err.message : String(err)}`, | ||
| "token_exchange_failed", | ||
| { cause: err }, | ||
| ); | ||
| })) as Partial<TokenResponse>; | ||
| if (typeof data.access_token !== "string" || data.access_token.length === 0) { | ||
| throw new HostedFlowError( | ||
| `Token endpoint response missing access_token`, | ||
| "token_exchange_failed", | ||
| ); | ||
| } | ||
| return { | ||
| access_token: data.access_token, | ||
| refresh_token: typeof data.refresh_token === "string" ? data.refresh_token : undefined, | ||
| token_type: typeof data.token_type === "string" ? data.token_type : undefined, | ||
| expires_in: typeof data.expires_in === "number" ? data.expires_in : undefined, | ||
| scope: typeof data.scope === "string" ? data.scope : undefined, | ||
| }; | ||
| } | ||
| /** | ||
| * Decode a JWT payload WITHOUT verifying the signature. Safe here only | ||
| * because we just minted the token through a TLS-protected OAuth flow | ||
| * against an issuer we discovered ourselves (`validateApiUrl` + | ||
| * `enforceIssuer`). The hosted MCP endpoint re-verifies the signature | ||
| * on every request via JWKS, so any tampering between here and there is | ||
| * rejected server-side. | ||
| * | ||
| * Do NOT lift this helper out as a generic JWT decoder — without the | ||
| * surrounding flow's TLS + issuer guarantees, "decode without verify" is | ||
| * unsafe. | ||
| */ | ||
| function decodeJwtPayload(jwt: string): Record<string, unknown> { | ||
| const parts = jwt.split("."); | ||
| if (parts.length !== 3) { | ||
| throw new HostedFlowError( | ||
| `Access token is not a JWT (expected 3 parts, got ${parts.length})`, | ||
| "malformed_jwt", | ||
| ); | ||
| } | ||
| try { | ||
| const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")); | ||
| return JSON.parse(json) as Record<string, unknown>; | ||
| } catch (err) { | ||
| throw new HostedFlowError( | ||
| `Could not decode JWT payload: ${err instanceof Error ? err.message : String(err)}`, | ||
| "malformed_jwt", | ||
| { cause: err }, | ||
| ); | ||
| } | ||
| } | ||
| /** | ||
| * Defense-in-depth: if the discovered issuer doesn't match the JWT's | ||
| * `iss` claim, the auth server returned a token "for" a different | ||
| * issuer. That's either a server bug or a discovery-redirection attack; | ||
| * either way, refuse to write it to disk. | ||
| */ | ||
| function enforceIssuer(payload: Record<string, unknown>, expectedIssuer: string): void { | ||
| const iss = payload.iss; | ||
| if (typeof iss !== "string" || iss.length === 0) { | ||
| throw new HostedFlowError( | ||
| `Access token has no \`iss\` claim — refusing to trust an unsigned-issuer token.`, | ||
| "issuer_mismatch", | ||
| ); | ||
| } | ||
| if (iss !== expectedIssuer) { | ||
| throw new HostedFlowError( | ||
| `Access token issuer mismatch: discovered \`${expectedIssuer}\`, token claims \`${iss}\`.`, | ||
| "issuer_mismatch", | ||
| ); | ||
| } | ||
| } | ||
| function extractWorkspaceClaim(payload: Record<string, unknown>): string { | ||
| const claim = payload[WORKSPACE_CLAIM]; | ||
| if (typeof claim !== "string" || claim.length === 0) { | ||
| throw new HostedFlowError( | ||
| `Access token is missing the ${WORKSPACE_CLAIM} claim — was the token issued for an MCP scope?`, | ||
| "missing_workspace_claim", | ||
| ); | ||
| } | ||
| return claim; | ||
| } | ||
| // ── Loopback listener — handler factory + default Bun.serve impl ─────── | ||
| interface CallbackResolver { | ||
| /** Single-shot handler that resolves/rejects the promise. */ | ||
| handler: LoopbackHandler; | ||
| /** Resolves with the authorization code on success. */ | ||
| promise: Promise<string>; | ||
| /** | ||
| * Tear down the timer and settle the promise as a no-op rejection. | ||
| * Called from the outer `finally` block on every flow exit so a | ||
| * 5-minute timeout can never survive an early-step failure (would | ||
| * otherwise keep the event loop alive long after the CLI returned). | ||
| */ | ||
| cancel(): void; | ||
| } | ||
| function createCallbackResolver( | ||
| expectedState: string, | ||
| timeoutMs: number, | ||
| ): CallbackResolver { | ||
| let resolve!: (code: string) => void; | ||
| let reject!: (err: HostedFlowError) => void; | ||
| let settled = false; | ||
| const promise = new Promise<string>((res, rej) => { | ||
| resolve = (code: string) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| clearTimeout(timer); | ||
| res(code); | ||
| }; | ||
| reject = (err: HostedFlowError) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| clearTimeout(timer); | ||
| rej(err); | ||
| }; | ||
| }); | ||
| const timer = setTimeout(() => { | ||
| reject( | ||
| new HostedFlowError( | ||
| `Did not receive an authorization callback within ${Math.round(timeoutMs / 1000)}s. If the browser didn't open, try \`bunx @useatlas/mcp init --hosted\` again from a desktop session.`, | ||
| "callback_timeout", | ||
| ), | ||
| ); | ||
| }, timeoutMs); | ||
| const handler: LoopbackHandler = (params, method) => { | ||
| if (method !== "GET") { | ||
| // User-agents only ever fetch the redirect URI as GET on completion | ||
| // of the authorization step; anything else is an attempt to inject | ||
| // parameters out-of-band. Reject without settling so a probe can't | ||
| // preempt the legitimate callback. | ||
| return { | ||
| status: 405, | ||
| body: renderCallbackPage({ | ||
| ok: false, | ||
| message: "Method not allowed. The OAuth callback must be a GET.", | ||
| }), | ||
| }; | ||
| } | ||
| const error = params.get("error"); | ||
| if (error) { | ||
| const description = params.get("error_description") ?? ""; | ||
| reject( | ||
| new HostedFlowError( | ||
| `Authorization server returned error \`${error}\`${description ? `: ${description}` : ""}`, | ||
| "callback_oauth_error", | ||
| ), | ||
| ); | ||
| return { | ||
| status: 400, | ||
| body: renderCallbackPage({ | ||
| ok: false, | ||
| message: `Authorization failed: ${error}${description ? ` — ${description}` : ""}`, | ||
| }), | ||
| }; | ||
| } | ||
| const state = params.get("state"); | ||
| if (state !== expectedState) { | ||
| reject( | ||
| new HostedFlowError( | ||
| `OAuth state mismatch — possible CSRF. Got \`${state ?? "<missing>"}\`, expected the value generated for this run.`, | ||
| "callback_state_mismatch", | ||
| ), | ||
| ); | ||
| return { | ||
| status: 400, | ||
| body: renderCallbackPage({ | ||
| ok: false, | ||
| message: "State mismatch — refusing to complete the flow.", | ||
| }), | ||
| }; | ||
| } | ||
| const code = params.get("code"); | ||
| if (!code) { | ||
| reject( | ||
| new HostedFlowError( | ||
| `Authorization callback was missing the \`code\` parameter.`, | ||
| "callback_missing_code", | ||
| ), | ||
| ); | ||
| return { | ||
| status: 400, | ||
| body: renderCallbackPage({ | ||
| ok: false, | ||
| message: "Missing authorization code in the callback URL.", | ||
| }), | ||
| }; | ||
| } | ||
| resolve(code); | ||
| return { | ||
| status: 200, | ||
| body: renderCallbackPage({ | ||
| ok: true, | ||
| message: "Atlas MCP CLI is now authorized. You can close this tab.", | ||
| }), | ||
| }; | ||
| }; | ||
| const cancel = () => { | ||
| if (settled) return; | ||
| settled = true; | ||
| clearTimeout(timer); | ||
| // Settle as a rejection so any awaiter that's still listening sees | ||
| // a typed error. The outer flow attaches a no-op .catch() on the | ||
| // promise to defuse unhandled-rejection on the abort path. | ||
| reject( | ||
| new HostedFlowError( | ||
| "OAuth flow aborted before the authorization callback arrived.", | ||
| "callback_timeout", | ||
| ), | ||
| ); | ||
| }; | ||
| return { handler, promise, cancel }; | ||
| } | ||
| /** | ||
| * Default loopback listener — Bun.serve on 127.0.0.1:0. The handler is | ||
| * single-shot; subsequent requests get a 404 (see the inline comment | ||
| * below for why 404 instead of 410). Exported so the test suite can | ||
| * exercise the once-fired guard without re-binding through the public | ||
| * `runHostedAuthFlow` entry point. | ||
| */ | ||
| export const defaultServeImpl: ServeImpl = async (handler) => { | ||
| // Once-fired guard. The legitimate auth code is single-use server-side, | ||
| // so any second hit on the redirect URI is either a stray browser | ||
| // refresh or a fingerprinting probe — refuse either way. | ||
| let used = false; | ||
| const server = Bun.serve({ | ||
| hostname: "127.0.0.1", | ||
| port: 0, | ||
| fetch(req) { | ||
| const url = new URL(req.url); | ||
| if (url.pathname !== "/callback") { | ||
| return new Response("Not Found", { status: 404 }); | ||
| } | ||
| if (used) { | ||
| // 404 (not 410) so a fingerprinting probe can't tell whether | ||
| // the listener was live-but-consumed vs never bound. | ||
| return new Response("Not Found", { status: 404 }); | ||
| } | ||
| used = true; | ||
| const result = handler(url.searchParams, req.method); | ||
| return new Response(result.body, { | ||
| status: result.status, | ||
| headers: { "Content-Type": "text/html; charset=utf-8" }, | ||
| }); | ||
| }, | ||
| }); | ||
| // Bun.serve types port as `number | undefined` (Unix-socket configs | ||
| // don't carry a TCP port). We bound `port: 0` above so a TCP port is | ||
| // always assigned — narrow defensively rather than ! through it. | ||
| if (typeof server.port !== "number") { | ||
| await server.stop(true); | ||
| throw new HostedFlowError( | ||
| "Loopback listener bound but did not report a TCP port", | ||
| "loopback_bind_failed", | ||
| ); | ||
| } | ||
| return { | ||
| port: server.port, | ||
| stop: async () => { | ||
| await server.stop(true); | ||
| }, | ||
| }; | ||
| }; | ||
| interface CallbackPageOpts { | ||
| ok: boolean; | ||
| message: string; | ||
| } | ||
| function renderCallbackPage(opts: CallbackPageOpts): string { | ||
| const title = opts.ok ? "Atlas MCP — authorized" : "Atlas MCP — error"; | ||
| const accent = opts.ok ? "#10b981" : "#ef4444"; | ||
| return `<!doctype html> | ||
| <html><head> | ||
| <meta charset="utf-8" /> | ||
| <title>${title}</title> | ||
| <style> | ||
| body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: #0a0a0a; color: #fafafa; margin: 0; min-height: 100vh; display: grid; place-items: center; } | ||
| .card { max-width: 420px; padding: 32px; border-radius: 12px; background: #111; border: 1px solid #222; text-align: center; } | ||
| .dot { width: 12px; height: 12px; border-radius: 50%; background: ${accent}; display: inline-block; margin-right: 8px; } | ||
| h1 { font-size: 18px; margin: 0 0 8px; } | ||
| p { color: #a1a1aa; margin: 0; line-height: 1.5; } | ||
| </style> | ||
| </head><body> | ||
| <div class="card"><h1><span class="dot"></span>${title}</h1><p>${opts.message}</p></div> | ||
| </body></html>`; | ||
| } | ||
| // ── Default browser launcher ─────────────────────────────────────────── | ||
| const defaultOpenBrowserImpl: OpenBrowserImpl = async (url) => { | ||
| // Bun.spawn is the cross-platform shell here. Each platform's own | ||
| // helper handles browser-detection, focus, and security prompts — | ||
| // we just need to invoke it. If the helper isn't found, return | ||
| // ok:false so the caller falls back to "open this URL manually". | ||
| const platform = process.platform; | ||
| const helper = platform === "darwin" ? "open" | ||
| : platform === "win32" ? "cmd /c start" | ||
| : "xdg-open"; | ||
| const cmd = platform === "darwin" ? ["open", url] | ||
| : platform === "win32" ? ["cmd", "/c", "start", "", url] | ||
| : ["xdg-open", url]; | ||
| try { | ||
| const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "pipe" }); | ||
| const exitCode = await proc.exited; | ||
| if (exitCode !== 0) { | ||
| const stderr = await new Response(proc.stderr).text().catch(() => ""); | ||
| return { ok: false, detail: stderr.trim() || `exit ${exitCode}` }; | ||
| } | ||
| return { ok: true }; | ||
| } catch (err) { | ||
| const code = (err as NodeJS.ErrnoException | undefined)?.code; | ||
| if (code === "ENOENT") { | ||
| return { | ||
| ok: false, | ||
| detail: | ||
| platform === "linux" | ||
| ? `${helper} not found on PATH — install xdg-utils (e.g. apt install xdg-utils) or open the URL above manually` | ||
| : `${helper} not found on PATH — open the URL above manually`, | ||
| }; | ||
| } | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| return { ok: false, detail: msg }; | ||
| } | ||
| }; | ||
| // ── Crypto helpers ───────────────────────────────────────────────────── | ||
| function defaultRandomBytes(length: number): Uint8Array { | ||
| const buf = new Uint8Array(length); | ||
| crypto.getRandomValues(buf); | ||
| return buf; | ||
| } | ||
| function encodeBase64Url(bytes: Uint8Array): string { | ||
| let bin = ""; | ||
| for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); | ||
| return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | ||
| } | ||
| async function pkceChallenge(verifier: string): Promise<string> { | ||
| const data = new TextEncoder().encode(verifier); | ||
| const digest = await crypto.subtle.digest("SHA-256", data); | ||
| return encodeBase64Url(new Uint8Array(digest)); | ||
| } |
+11
-3
@@ -107,6 +107,7 @@ #!/usr/bin/env bun | ||
| --local Configure for a local Atlas (default) | ||
| --hosted Configure for app.useatlas.dev (not yet available) | ||
| --hosted Configure for hosted Atlas via OAuth 2.1 loopback flow | ||
| --client <id> Force a specific client: claude-desktop | cursor | continue | generic | ||
| --write Merge into the client's config file (with a .bak backup) | ||
| --api-url <url> Override local Atlas detection URL (default: http://localhost:3001) | ||
| --api-url <url> Override the API base URL (default: http://localhost:3001 for --local, | ||
| https://api.useatlas.dev for --hosted; also reads ATLAS_PUBLIC_API_URL) | ||
| -h, --help Show this help | ||
@@ -118,2 +119,4 @@ | ||
| bunx @useatlas/mcp init --local --client cursor --write | ||
| bunx @useatlas/mcp init --hosted --write | ||
| bunx @useatlas/mcp init --hosted --api-url https://api-eu.useatlas.dev --write | ||
| `; | ||
@@ -141,3 +144,8 @@ | ||
| flags.mode === "hosted" | ||
| ? { mode: "hosted" } | ||
| ? { | ||
| mode: "hosted", | ||
| client: flags.client, | ||
| write: flags.write, | ||
| apiUrl: flags.apiUrl, | ||
| } | ||
| : { | ||
@@ -144,0 +152,0 @@ mode: "local", |
+3
-1
| { | ||
| "name": "@useatlas/mcp", | ||
| "version": "0.0.1", | ||
| "version": "0.0.2", | ||
| "mcpName": "io.github.AtlasDevHQ/atlas", | ||
| "description": "Atlas MCP plugin + bunx installer for Claude Desktop / Cursor / Continue", | ||
@@ -33,2 +34,3 @@ "type": "module", | ||
| "fixtures/", | ||
| "server.json", | ||
| "README.md", | ||
@@ -35,0 +37,0 @@ "LICENSE" |
+120
-13
@@ -8,6 +8,15 @@ /** | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; | ||
| import { | ||
| existsSync, | ||
| mkdirSync, | ||
| readFileSync, | ||
| writeFileSync, | ||
| copyFileSync, | ||
| renameSync, | ||
| unlinkSync, | ||
| } from "node:fs"; | ||
| import { dirname } from "node:path"; | ||
| export interface ServerConfig { | ||
| /** Stdio launcher — `bunx @useatlas/mcp serve`, used by `init --local`. */ | ||
| export interface StdioServerConfig { | ||
| command: string; | ||
@@ -18,2 +27,32 @@ args: string[]; | ||
| /** | ||
| * HTTP/SSE server pointer — used by `init --hosted` against | ||
| * `app.useatlas.dev` (or a self-hosted 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`. | ||
| */ | ||
| export interface HttpServerConfig { | ||
| url: string; | ||
| headers?: Record<string, string>; | ||
| } | ||
| 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 { | ||
@@ -28,5 +67,5 @@ /** Inline ATLAS_DATASOURCE_URL into the env block. Omit to inherit the user's shell env. */ | ||
| export function buildServerConfig(opts: BuildOpts = {}): ServerConfig { | ||
| export function buildServerConfig(opts: BuildOpts = {}): StdioServerConfig { | ||
| const pkg = opts.packageName ?? DEFAULT_PACKAGE; | ||
| const cfg: ServerConfig = { | ||
| const cfg: StdioServerConfig = { | ||
| command: "bunx", | ||
@@ -41,2 +80,16 @@ args: [pkg, "serve"], | ||
| interface BuildHostedOpts { | ||
| /** The hosted MCP endpoint URL — e.g. `https://api.useatlas.dev/mcp/<workspace>/sse`. */ | ||
| url: string; | ||
| /** OAuth 2.1 access token (JWT). Written verbatim into the Authorization header. */ | ||
| accessToken: string; | ||
| } | ||
| export function buildHostedServerConfig(opts: BuildHostedOpts): HttpServerConfig { | ||
| return { | ||
| url: opts.url, | ||
| headers: { Authorization: `Bearer ${opts.accessToken}` }, | ||
| }; | ||
| } | ||
| interface ExistingShape { | ||
@@ -95,2 +148,12 @@ mcpServers?: unknown; | ||
| /** | ||
| * 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( | ||
@@ -100,16 +163,60 @@ configPath: string, | ||
| ): Promise<WriteResult> { | ||
| 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 = `${configPath}.bak`; | ||
| if (existsSync(backupPath)) { | ||
| const stamp = new Date().toISOString().replace(/[:.]/g, "-"); | ||
| backupPath = `${configPath}.${stamp}.bak`; | ||
| 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 }, | ||
| ); | ||
| } | ||
| copyFileSync(configPath, backupPath); | ||
| } else { | ||
| mkdirSync(dirname(configPath), { recursive: true }); | ||
| } | ||
| writeFileSync(configPath, newContent, { encoding: "utf8", mode: 0o600 }); | ||
| 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 }; | ||
@@ -116,0 +223,0 @@ } |
+98
-15
@@ -9,2 +9,3 @@ /** | ||
| import { | ||
| buildHostedServerConfig, | ||
| buildServerConfig, | ||
@@ -18,5 +19,12 @@ mergeMcpServerConfig, | ||
| import { resolveFixturePaths, shouldUseFixture } from "./fixture.js"; | ||
| import { | ||
| HostedFlowError, | ||
| runHostedAuthFlow, | ||
| type HostedFlowOptions, | ||
| type OpenBrowserImpl, | ||
| type ServeImpl, | ||
| } from "./hosted.js"; | ||
| const SERVER_NAME = "atlas"; | ||
| const HOSTED_TRACKING_ISSUE = "https://github.com/AtlasDevHQ/atlas/issues/2024"; | ||
| const DEFAULT_HOSTED_API_URL = "https://api.useatlas.dev"; | ||
@@ -41,2 +49,17 @@ export interface LocalInitOptions { | ||
| mode: "hosted"; | ||
| /** Override the hosted Atlas API base. Defaults to `https://api.useatlas.dev`. */ | ||
| 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[]; | ||
| } | ||
@@ -52,3 +75,3 @@ | ||
| if (options.mode === "hosted") { | ||
| return runHostedStub(); | ||
| return runHosted(options); | ||
| } | ||
@@ -58,10 +81,74 @@ return runLocal(options); | ||
| function runHostedStub(): RunInitResult { | ||
| console.log( | ||
| [ | ||
| `Hosted mode (against app.useatlas.dev) is not yet available — tracking at ${HOSTED_TRACKING_ISSUE}.`, | ||
| "Run `bunx @useatlas/mcp init --local` for now to point an MCP client at a local Atlas", | ||
| "instance or the bundled demo fixture.", | ||
| ].join("\n"), | ||
| ); | ||
| async function runHosted(opts: HostedInitOptions): Promise<RunInitResult> { | ||
| const env = opts.env ?? process.env; | ||
| const apiUrl = opts.apiUrl ?? env.ATLAS_PUBLIC_API_URL ?? DEFAULT_HOSTED_API_URL; | ||
| 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 serverCfg = buildHostedServerConfig({ | ||
| url: result.mcpUrl, | ||
| accessToken: result.accessToken, | ||
| }); | ||
| const clientId = opts.client ?? pickDefaultClient(opts.detectClientsImpl); | ||
| const configPath = opts.configPathOverride ?? getDefaultConfigPath(clientId); | ||
| console.log(`Authorized for workspace ${result.workspaceId} at ${apiUrl}.`); | ||
| if (!opts.write || configPath === null) { | ||
| printPasteSnippet(clientId, serverCfg, configPath); | ||
| if (result.refreshToken) { | ||
| console.log( | ||
| "# A refresh token was issued. Atlas's MCP clients re-authenticate transparently — keep this config file at mode 0600.", | ||
| ); | ||
| } | ||
| return { exitCode: 0 }; | ||
| } | ||
| const existing = readConfigOrNull(configPath); | ||
| let merged: string; | ||
| try { | ||
| merged = mergeMcpServerConfig(existing, SERVER_NAME, 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 }; | ||
@@ -111,10 +198,6 @@ } | ||
| } catch (err) { | ||
| // The .bak (if any) was written *before* the failed write, so a partial | ||
| // failure may have moved the original out of place. Tell the user where | ||
| // to recover from instead of leaving them with a generic "Fatal". | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| console.error(`[atlas-mcp init] failed to write ${configPath}: ${msg}`); | ||
| if (existing !== null) { | ||
| console.error(`A backup of your previous config is at ${configPath}.bak (or a timestamped sibling).`); | ||
| console.error(`Restore with: cp '${configPath}.bak' '${configPath}'`); | ||
| console.error("Your existing config was not modified — the new content was staged in a sibling tmp file and the rename never happened."); | ||
| } | ||
@@ -121,0 +204,0 @@ return { exitCode: 1 }; |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
82690
100.56%13
18.18%1978
117.6%7
16.67%10
233.33%