🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@useatlas/mcp

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@useatlas/mcp - npm Package Compare versions

Comparing version
0.0.1
to
0.0.2
+39
server.json
{
"$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",

{
"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"

@@ -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 @@ }

@@ -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 };