🎩 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
3
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.2
to
0.0.3
+32
src/_oauth-helper/_internal/encoding.ts
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
/**
* CSPRNG entry point for the helper. Sole call site for
* `crypto.getRandomValues` — must not be replaced with `Math.random`
* even for tests; pass `randomBytesImpl` on the relevant `*Options`
* shape to drive deterministic test output without weakening the
* production RNG.
*/
export function defaultRandomBytes(length: number): Uint8Array {
const buf = new Uint8Array(length);
crypto.getRandomValues(buf);
return buf;
}
/**
* RFC 4648 §5 URL-safe base64 with stripped padding.
*
* PKCE (RFC 7636 §4.2) and OAuth `state` (RFC 6749 §10.12) both require
* this exact wire shape — do NOT re-add `=` padding, do NOT swap to the
* standard alphabet, and do NOT remove the `+`/`/` substitutions.
* Server-side PKCE verification computes `BASE64URL(SHA-256(verifier))`
* and compares byte-for-byte; any divergence here yields opaque
* "invalid_grant" failures at the token endpoint.
*/
export 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(/=+$/, "");
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
/**
* Default fetch timeout for every helper request. 30s is the same value
* both pre-extraction implementations used; a single constant keeps the
* wire timeout consistent across consumers without a config hop.
*/
export const FETCH_TIMEOUT_MS = 30 * 1000;
/**
* Surface OAuth 2.1 / DCR error responses as `error: error_description`
* when the body parses as the canonical `{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.
*
* If the body itself fails to read (malformed UTF-8, mid-stream abort,
* stream lock contention) we surface the read failure inline as the
* "detail" string so support tickets do NOT end up with a bare status
* code. Returning `""` here would lose the only diagnostic we have.
*/
export async function describeOAuthErrorBody(res: Response): Promise<string> {
let raw: string;
try {
raw = await res.text();
} catch (err) {
return `<failed to read response body: ${
err instanceof Error ? err.message : String(err)
}>`;
}
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 the raw-text
// branch below. The raw text is preserved (truncated) so non-OAuth
// error bodies still surface in the eventual error message.
}
return raw.length > 1024 ? `${raw.slice(0, 1024)}…` : raw;
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
export interface BuildAuthorizationUrlParams {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
state: string;
codeChallenge: string;
/** Joined with " " into the canonical space-delimited `scope` parameter. */
scopes: ReadonlyArray<string>;
}
/**
* Pure URL construction — no I/O. Returns the consent URL the user-agent
* navigates to. The caller decides how to surface the URL (popup, tab,
* loopback browser launch, terminal print) — this helper is transport-
* agnostic.
*
* `code_challenge_method` is hardcoded to `S256`; OAuth 2.1 forbids the
* `plain` method.
*/
export function buildAuthorizationUrl(params: BuildAuthorizationUrlParams): string {
const search = new URLSearchParams({
response_type: "code",
client_id: params.clientId,
redirect_uri: params.redirectUri,
scope: params.scopes.join(" "),
state: params.state,
code_challenge: params.codeChallenge,
code_challenge_method: "S256",
});
const sep = params.authorizationEndpoint.includes("?") ? "&" : "?";
return `${params.authorizationEndpoint}${sep}${search.toString()}`;
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
/**
* Branded `string` for OAuth bearer credentials shared by both
* consumers. 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" };
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
import { OAuthHelperError } from "./errors";
import { FETCH_TIMEOUT_MS } from "./_internal/http";
/** Drop trailing `/` characters. Non-regex to keep the polynomial-ReDoS checker happy. */
function stripTrailingSlashes(s: string): string {
let i = s.length;
while (i > 0 && s[i - 1] === "/") i--;
return i === s.length ? s : s.slice(0, i);
}
/**
* Subset of RFC 8414 server metadata Atlas's flow consumes. Better Auth's
* discovery doc carries more fields (`userinfo_endpoint`,
* `revocation_endpoint`, etc.); none of them are load-bearing for the
* loopback / popup flows we drive from here, so the type stays narrow.
*/
export interface AuthServerMetadata {
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint: string;
issuer: string;
}
export interface DiscoverOptions {
/** Test seam — defaults to global `fetch`. */
fetchImpl?: typeof fetch;
}
/**
* Atlas pins its OAuth metadata under Better Auth's `/api/auth` mount.
* `apiUrl` is the customer-visible API origin; the discovery doc is at
* `${apiUrl}/.well-known/oauth-authorization-server/api/auth`.
*/
const DISCOVERY_PATH = "/.well-known/oauth-authorization-server/api/auth";
export async function discover(
apiUrl: string,
options?: DiscoverOptions,
): Promise<AuthServerMetadata> {
const fetchImpl = options?.fetchImpl ?? fetch;
const url = `${stripTrailingSlashes(apiUrl)}${DISCOVERY_PATH}`;
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 OAuthHelperError(
`Could not reach Atlas auth discovery at ${url}: ${msg}`,
"discovery_failed",
{ cause: err },
);
}
if (!res.ok) {
throw new OAuthHelperError(
`Atlas auth discovery returned ${res.status} for ${url}`,
"discovery_failed",
);
}
const body = (await res.json().catch((err) => {
throw new OAuthHelperError(
`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 OAuthHelperError(
`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,
};
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
/**
* Error class shared by every primitive in `@atlas/oauth-helper`.
*
* Codes intentionally overlap 1:1 with the consumer error codes in
* `@useatlas/sdk` (`AtlasMcpErrorCode`) and `@useatlas/mcp`
* (`HostedFlowErrorCode`), so each consumer's wrap-and-rethrow at the
* boundary is a typed `code as ConsumerCode` cast — no string remap
* table to maintain. When the helper grows a new code, both consumers
* widen their union or translate it explicitly.
*/
export type OAuthHelperErrorCode =
| "invalid_api_url"
| "invalid_token_endpoint"
| "discovery_failed"
| "registration_failed"
| "token_exchange_failed"
| "issuer_mismatch"
| "malformed_jwt"
| "missing_workspace_claim";
export class OAuthHelperError extends Error {
readonly code: OAuthHelperErrorCode;
constructor(message: string, code: OAuthHelperErrorCode, options?: ErrorOptions) {
super(message, options);
this.name = "OAuthHelperError";
this.code = code;
}
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
import { OAuthHelperError } from "./errors";
import { describeOAuthErrorBody, FETCH_TIMEOUT_MS } from "./_internal/http";
import { validateTokenEndpoint } from "./validate";
export interface ExchangeCodeParams {
/** From discovery (or callback options). MUST be https — guarded internally. */
tokenEndpoint: string;
clientId: string;
redirectUri: string;
code: string;
codeVerifier: string;
/**
* RFC 8707 resource indicator — the protected resource the issued token
* is bound to (e.g. `${apiUrl}/mcp`). When present it's sent as the
* `resource` form field on the token request. Better Auth's
* `@better-auth/oauth-provider` only mints a JWT-formatted access token
* (vs an opaque one) when the token request carries a `resource`; the
* MCP route's `verifyMcpBearer` requires the JWT shape. Omit it for
* consumers that don't bind to a specific resource — the field is simply
* not sent and the server falls back to its default token format.
*/
resource?: string;
}
export interface ExchangeCodeOptions {
/** Test seam — defaults to global `fetch`. */
fetchImpl?: typeof fetch;
}
/**
* RFC 6749 §4.1.3 token-exchange shape, narrowed to what consumers
* actually read. Unrecognised fields on the wire are dropped — callers
* decode the JWT to reach issuer / workspace / azp claims.
*/
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type?: string;
expires_in?: number;
scope?: string;
}
/**
* Exchange the authorization code for an access token (and optional
* refresh token) at the token endpoint. The endpoint is re-validated as
* https:// before the POST so a malicious DCR response advertising
* `token_endpoint: "http://evil/token"` cannot smuggle the auth code +
* PKCE verifier over plaintext — that hardening was added in
* #2198 for the SDK and now applies to every consumer of this helper.
*/
export async function exchangeCode(
params: ExchangeCodeParams,
options?: ExchangeCodeOptions,
): Promise<TokenResponse> {
// Run the https guard BEFORE resolving the fetch impl. The validation
// is intrinsic to the helper's contract, not contingent on the wire
// layer — a test passing a stub `fetchImpl` for an `http://evil`
// endpoint should still get `invalid_token_endpoint`, not a synthetic
// happy-path 200 from the stub.
validateTokenEndpoint(params.tokenEndpoint);
const fetchImpl = options?.fetchImpl ?? fetch;
const body = new URLSearchParams({
grant_type: "authorization_code",
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
code_verifier: params.codeVerifier,
});
// RFC 8707 — bind the issued token to the requested resource. Appended
// only when the caller supplies it so consumers that don't need
// resource-scoped tokens keep the original request shape.
if (params.resource !== undefined && params.resource.length > 0) {
body.set("resource", params.resource);
}
let res: Response;
try {
res = await fetchImpl(params.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 OAuthHelperError(
`Token exchange failed: ${msg}`,
"token_exchange_failed",
{ cause: err },
);
}
if (!res.ok) {
const detail = await describeOAuthErrorBody(res);
throw new OAuthHelperError(
`Token endpoint returned ${res.status}${detail ? `: ${detail}` : ""}`,
"token_exchange_failed",
);
}
const data = (await res.json().catch((err) => {
throw new OAuthHelperError(
`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 OAuthHelperError(
`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,
};
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
/**
* `@atlas/oauth-helper` — transport-agnostic OAuth 2.1 + DCR + PKCE
* primitives shared by `@useatlas/sdk` (programmatic browser flow) and
* `@useatlas/mcp` (CLI loopback flow).
*
* Internal-only — see README. Consumers vendor or bundle the source.
*
* ── Re-export safety note ────────────────────────────────────────────
*
* `decodeJwtPayload` is re-exported below for convenience but it
* skips signature verification. It is safe ONLY inside a flow that has
* already validated the token endpoint as `https://` and that calls
* `enforceIssuer` on the result before reading any other claim. Do NOT
* call it as a generic JWT decoder — see `./jwt.ts` for the full
* contract.
*/
export { OAuthHelperError, type OAuthHelperErrorCode } from "./errors";
export { type Bearer } from "./brands";
export { discover, type AuthServerMetadata, type DiscoverOptions } from "./discover";
export { register, type RegisterParams, type RegisterOptions } from "./register";
export {
generatePkce,
generateState,
type PkceCodeChallenge,
type RandomBytesOptions,
} from "./pkce";
export {
buildAuthorizationUrl,
type BuildAuthorizationUrlParams,
} from "./authorize-url";
export {
exchangeCode,
type ExchangeCodeParams,
type ExchangeCodeOptions,
type TokenResponse,
} from "./exchange";
export { validateIssuerUrl, validateTokenEndpoint } from "./validate";
export { decodeJwtPayload, enforceIssuer } from "./jwt";
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
import { OAuthHelperError } from "./errors";
/**
* Decode a JWT payload WITHOUT verifying the signature. Safe ONLY when
* called from a flow that has already (a) validated the token endpoint
* as https + verified TLS to the discovered issuer, and (b) calls
* `enforceIssuer` on the decoded payload before trusting any other
* claim. 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.
*/
export function decodeJwtPayload(jwtToken: string): Record<string, unknown> {
const parts = jwtToken.split(".");
if (parts.length !== 3) {
throw new OAuthHelperError(
`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 OAuthHelperError(
`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 trust it.
*/
export function enforceIssuer(
payload: Record<string, unknown>,
expectedIssuer: string,
): void {
const iss = payload.iss;
if (typeof iss !== "string" || iss.length === 0) {
throw new OAuthHelperError(
`Access token has no \`iss\` claim — refusing to trust an unsigned-issuer token.`,
"issuer_mismatch",
);
}
if (iss !== expectedIssuer) {
throw new OAuthHelperError(
`Access token issuer mismatch: discovered \`${expectedIssuer}\`, token claims \`${iss}\`.`,
"issuer_mismatch",
);
}
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
import { defaultRandomBytes, encodeBase64Url } from "./_internal/encoding";
export interface PkceCodeChallenge {
/** Random verifier persisted between begin → complete. */
codeVerifier: string;
/** SHA-256 of `codeVerifier`, base64url-encoded. Sent on `/authorize`. */
codeChallenge: string;
/**
* Always `S256`. RFC 7636 §4.2 defines both `plain` and `S256`;
* OAuth 2.1 §4.1.1 forbids `plain` for new deployments.
*/
method: "S256";
}
export interface RandomBytesOptions {
/** Test seam — defaults to `crypto.getRandomValues`. */
randomBytesImpl?: (length: number) => Uint8Array;
}
const VERIFIER_BYTES = 32;
const STATE_BYTES = 32;
export async function generatePkce(options?: RandomBytesOptions): Promise<PkceCodeChallenge> {
const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes;
const codeVerifier = encodeBase64Url(randomBytes(VERIFIER_BYTES));
const codeChallenge = await pkceChallenge(codeVerifier);
return { codeVerifier, codeChallenge, method: "S256" };
}
/**
* Anti-CSRF nonce echoed by the auth server in the redirect — callers
* must persist this and compare against the callback's `state`. Same
* RNG seam as `generatePkce` so deterministic tests can pin both.
*/
export function generateState(options?: RandomBytesOptions): string {
const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes;
return encodeBase64Url(randomBytes(STATE_BYTES));
}
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));
}
# Vendored `@atlas/oauth-helper`
**This directory is auto-generated. Do not edit.**
The canonical source lives at `packages/oauth-helper/src/`. This copy
exists so `@useatlas/mcp`'s published TS bundle (which ships source raw,
not a build artifact) can resolve the OAuth 2.1 + DCR + PKCE primitives
without a runtime dependency on `@atlas/oauth-helper` (internal-only).
The contents are regenerated by `scripts/vendor-oauth-helper.sh` on
every `bun install` and `npm publish`. To change anything here, edit
the canonical source and rerun the script (it runs automatically on
`bun install`).
CI runs `scripts/check-oauth-helper-drift.sh` to fail loudly if a
contributor edits the vendored copy directly without re-running the
vendor script — the canonical source is the only legitimate edit point.
See `packages/oauth-helper/README.md` for the helper's public surface.
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
import type { AuthServerMetadata } from "./discover";
import { OAuthHelperError } from "./errors";
import { describeOAuthErrorBody, FETCH_TIMEOUT_MS } from "./_internal/http";
export interface RegisterParams {
/** Where the auth server should send the user after consent. */
redirectUri: string;
/** Human-readable name registered via DCR — shown on the consent screen. */
clientName: string;
/** Scopes to request — joined with " " into the canonical space-delimited string. */
scopes: ReadonlyArray<string>;
}
export interface RegisterOptions {
/** Test seam — defaults to global `fetch`. */
fetchImpl?: typeof fetch;
}
/**
* Dynamic Client Registration (RFC 7591). Posts a public-client manifest
* to the registration endpoint and returns the freshly-minted client_id.
*
* Public-client posture: `token_endpoint_auth_method: "none"` matches
* both the SDK popup flow and the CLI loopback flow — the server enforces
* PKCE on the token exchange instead.
*/
export async function register(
metadata: AuthServerMetadata,
params: RegisterParams,
options?: RegisterOptions,
): Promise<string> {
const fetchImpl = options?.fetchImpl ?? fetch;
const body = {
client_name: params.clientName,
redirect_uris: [params.redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
scope: params.scopes.join(" "),
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 OAuthHelperError(
`Dynamic Client Registration failed: ${msg}`,
"registration_failed",
{ cause: err },
);
}
if (!res.ok) {
const detail = await describeOAuthErrorBody(res);
throw new OAuthHelperError(
`Dynamic Client Registration returned ${res.status}${detail ? `: ${detail}` : ""}`,
"registration_failed",
);
}
const data = (await res.json().catch((err) => {
throw new OAuthHelperError(
`Dynamic Client Registration response was not JSON: ${err instanceof Error ? err.message : String(err)}`,
"registration_failed",
{ cause: err },
);
})) as Partial<{ client_id: string }>;
if (typeof data.client_id !== "string" || data.client_id.length === 0) {
throw new OAuthHelperError(
`Dynamic Client Registration response missing client_id`,
"registration_failed",
);
}
return data.client_id;
}
// AUTO-GENERATED — DO NOT EDIT.
// Canonical source: packages/oauth-helper/src/
// Regenerated by plugins/mcp/scripts/vendor-oauth-helper.sh on every
// `bun install` and `npm publish`. Edits made here are overwritten.
import { OAuthHelperError, type OAuthHelperErrorCode } from "./errors";
/**
* Refuse anything other than `https://` (or `http://localhost` /
* `http://127.0.0.1` for dev). Used by callers for both:
*
* 1. The user-supplied `apiUrl` driving discovery — 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.
* 2. The discovered `token_endpoint` — a malicious DCR response can
* advertise `token_endpoint: "http://evil/token"` and would
* otherwise smuggle the auth code + PKCE verifier over plaintext.
*
* The `code` argument lets the caller distinguish which guard tripped
* (`invalid_api_url` for case 1, `invalid_token_endpoint` for case 2)
* so the consumer's error envelope routes the right hint.
*/
function validateHttpsUrl(
input: string,
code: Extract<OAuthHelperErrorCode, "invalid_api_url" | "invalid_token_endpoint">,
label: string,
): void {
let parsed: URL;
try {
parsed = new URL(input);
} catch (err) {
throw new OAuthHelperError(
`${label} is not a valid URL: ${input}`,
code,
{ cause: err },
);
}
if (parsed.protocol === "https:") return;
if (
parsed.protocol === "http:" &&
(parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost")
) {
return;
}
throw new OAuthHelperError(
`${label} must use https:// (or http://localhost for dev). Got: ${input}`,
code,
);
}
export function validateIssuerUrl(apiUrl: string): void {
validateHttpsUrl(apiUrl, "invalid_api_url", "apiUrl");
}
export function validateTokenEndpoint(tokenEndpoint: string): void {
validateHttpsUrl(tokenEndpoint, "invalid_token_endpoint", "tokenEndpoint");
}
+27
-19

@@ -13,4 +13,5 @@ #!/usr/bin/env bun

* (monorepo dev or a create-atlas-agent project that bundles the API code)
* it boots the server, otherwise it prints a clear "not yet supported"
* message pointing at #2024 (hosted MCP).
* it boots the server, otherwise it prints a clear "not supported" message
* pointing users at `bunx @useatlas/mcp init --hosted` (decision recorded
* in #2052; the standalone "serve" path is intentionally not vendored).
*/

@@ -112,3 +113,3 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";

--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)
https://mcp.useatlas.dev for --hosted; also reads ATLAS_PUBLIC_API_URL)
-h, --help Show this help

@@ -132,3 +133,4 @@

Atlas API source (the create-atlas-agent template, the monorepo, or a custom
deployment). Standalone \`bunx\`-only invocations are tracked in #2024.
deployment). For zero-config use, run \`bunx @useatlas/mcp init --hosted\` to
connect to Atlas SaaS instead.
`;

@@ -207,3 +209,3 @@

* `DATABASE_URL`, an Effect Layer construction failure) doesn't get
* misreported as "package missing — see #2024."
* misreported as "package missing — install via `init --hosted`."
*

@@ -236,11 +238,11 @@ * Detection strategy: Node sets `err.code === "ERR_MODULE_NOT_FOUND"` /

}
interface SseHandle {
interface StreamableHttpHandle {
server: { hostname: string; port: number };
close(): Promise<void>;
}
interface SseModule {
startSseServer: (
interface StreamableHttpModule {
startStreamableHttpServer: (
factory: () => Promise<AtlasMcpServer>,
opts: { port: number },
) => Promise<SseHandle>;
) => Promise<StreamableHttpHandle>;
}

@@ -266,4 +268,6 @@

// path-alias the Atlas API source). A transient `bunx` install does NOT
// include `@atlas/mcp`, so this path intentionally fails with a pointer to
// #2024 (hosted MCP) rather than pretending to start a broken server.
// include `@atlas/mcp`, so this path intentionally fails and points the
// user at the hosted installer rather than pretending to start a broken
// server. Vendoring a demo runtime (#2052 path B) was considered and
// rejected — the supported zero-friction path is `init --hosted`.
let serverMod: ServerModule;

@@ -275,3 +279,3 @@ try {

// still demand the .d.ts at consumer build time.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
serverMod = (await import("@atlas/mcp/server")) as any as ServerModule;

@@ -290,6 +294,10 @@ } catch (err) {

"",
"The serve subcommand currently requires the Atlas API source to be available",
"in the same project (monorepo dev or a create-atlas-agent scaffold). Standalone",
"`bunx @useatlas/mcp serve` is tracked in https://github.com/AtlasDevHQ/atlas/issues/2024.",
"Standalone `bunx @useatlas/mcp serve` is not supported. To connect any MCP",
"client to Atlas SaaS in one command, run:",
"",
" bunx @useatlas/mcp init --hosted --write",
"",
"Self-hosted: scaffold a project with `bun create atlas-agent` and run",
"`bun run mcp` from inside it. See https://docs.useatlas.dev/guides/mcp.",
"",
`Details: ${detail}`,

@@ -309,5 +317,5 @@ ].join("\n"),

(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see note on serverMod above
const sseMod = (await import("@atlas/mcp/sse")) as any as SseModule;
const handle = await sseMod.startSseServer(
// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- see note on serverMod above
const streamableMod = (await import("@atlas/mcp/streamable-http")) as any as StreamableHttpModule;
const handle = await streamableMod.startStreamableHttpServer(
() => serverMod.createAtlasMcpServer({ transport: "sse" }),

@@ -336,3 +344,3 @@ { port: flags.port },

} catch (err) {
// Anything thrown after `startSseServer` resolved (signal-handler
// Anything thrown after `startStreamableHttpServer` resolved (signal-handler
// setup, console write) leaves a live listener bound to the port.

@@ -339,0 +347,0 @@ // Drain it before rejecting so we don't leak.

{
"name": "@useatlas/mcp",
"version": "0.0.2",
"version": "0.0.3",
"mcpName": "io.github.AtlasDevHQ/atlas",

@@ -39,3 +39,5 @@ "description": "Atlas MCP plugin + bunx installer for Claude Desktop / Cursor / Continue",

"scripts": {
"test": "bun test __tests__/"
"test": "bun test __tests__/",
"vendor:oauth-helper": "bash scripts/vendor-oauth-helper.sh",
"prepare": "bash scripts/vendor-oauth-helper.sh"
},

@@ -71,7 +73,8 @@ "keywords": [

"@atlas/mcp": "workspace:*",
"@modelcontextprotocol/sdk": "^1.28.0",
"hono": "^4.12.9",
"zod": "^4.3.6",
"@atlas/oauth-helper": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"hono": "^4.12.27",
"zod": "^4.4.3",
"@useatlas/plugin-sdk": "workspace:*"
}
}

@@ -25,3 +25,3 @@ /**

import { z } from "zod";
import { createPlugin } from "@useatlas/plugin-sdk";
import { createPlugin, measuredHealthCheck } from "@useatlas/plugin-sdk";
import type { AtlasInteractionPlugin, PluginHealthResult } from "@useatlas/plugin-sdk";

@@ -71,5 +71,5 @@

// @atlas/api deps) at module evaluation time.
const { startSseServer } = await import("@atlas/mcp/sse");
const { startStreamableHttpServer } = await import("@atlas/mcp/streamable-http");
const { createAtlasMcpServer } = await import("@atlas/mcp/server");
const handle = await startSseServer(
const handle = await startStreamableHttpServer(
() => createAtlasMcpServer({ skipConfig: true }),

@@ -109,18 +109,11 @@ { port: config.port ?? 8080 },

async healthCheck(): Promise<PluginHealthResult> {
const start = performance.now();
if (!connected) {
return {
healthy: false,
message: "MCP server not initialized or not connected",
latencyMs: Math.round(performance.now() - start),
};
}
if (config.transport === "sse" && !sseHandle) {
return {
healthy: false,
message: "SSE handle missing — server may have crashed",
latencyMs: Math.round(performance.now() - start),
};
}
return { healthy: true, latencyMs: Math.round(performance.now() - start) };
return measuredHealthCheck(() => {
if (!connected) {
return { healthy: false, message: "MCP server not initialized or not connected" };
}
if (config.transport === "sse" && !sseHandle) {
return { healthy: false, message: "SSE handle missing — server may have crashed" };
}
return { healthy: true };
});
},

@@ -127,0 +120,0 @@

@@ -27,3 +27,3 @@ /**

/**
* HTTP/SSE server pointer — used by `init --hosted` against
* Streamable-HTTP server pointer — used by `init --hosted` against
* `app.useatlas.dev` (or a self-hosted instance with managed auth). MCP

@@ -33,6 +33,24 @@ * clients (Claude Desktop, Cursor, Continue) all accept this `url` +

* by the OAuth 2.1 loopback flow in `init/hosted.ts`.
*
* `type: "http"` pins the transport to Streamable HTTP for the clients
* that key off it explicitly (Claude Code, VS Code): without it those
* clients guess from the URL, and a bare `{ url }` was defaulting them
* to the deprecated HTTP+SSE transport — which the hosted endpoint no
* longer speaks, so the first request 400s. Clients that auto-detect
* transport (Cursor, Continue) ignore the field. The `url` is always the
* canonical `/mcp/{workspaceId}` path — never the legacy `/sse` alias.
*
* `env` is a forward-compat slot for the cross-workspace identity flow
* (#2073). Today's MCP clients don't bridge stdio-style env into HTTP
* headers, so the block is informational — but writing it costs
* nothing and lets a future agent-framework wrapper read
* `ATLAS_DEFAULT_WORKSPACE` and inject `X-Atlas-Default-Workspace` on
* every request without re-running this CLI.
*/
export interface HttpServerConfig {
/** Transport discriminator — always Streamable HTTP for hosted Atlas. */
type: "http";
url: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}

@@ -80,13 +98,26 @@

interface BuildHostedOpts {
/** The hosted MCP endpoint URL — e.g. `https://api.useatlas.dev/mcp/<workspace>/sse`. */
/** The canonical hosted MCP endpoint URL — e.g. `https://mcp.useatlas.dev/mcp/<workspace>` (no `/sse`). */
url: string;
/** OAuth 2.1 access token (JWT). Written verbatim into the Authorization header. */
accessToken: string;
/**
* Default workspace id (#2073) for multi-workspace agents. When set,
* the config writes an `env: { ATLAS_DEFAULT_WORKSPACE }` block as
* the forward-compat hint for agent frameworks that bridge env into
* `X-Atlas-Default-Workspace`. Single-workspace clients leave this
* undefined and the env block is omitted.
*/
defaultWorkspaceId?: string;
}
export function buildHostedServerConfig(opts: BuildHostedOpts): HttpServerConfig {
return {
const cfg: HttpServerConfig = {
type: "http",
url: opts.url,
headers: { Authorization: `Bearer ${opts.accessToken}` },
};
if (opts.defaultWorkspaceId) {
cfg.env = { ATLAS_DEFAULT_WORKSPACE: opts.defaultWorkspaceId };
}
return cfg;
}

@@ -93,0 +124,0 @@

/**
* `init --hosted` flow: OAuth 2.1 authorization-code-with-PKCE against the
* hosted Atlas MCP endpoint (#2024 PR E).
* hosted Atlas MCP endpoint.
*

@@ -34,2 +34,15 @@ * ── Why loopback (RFC 8252) over device code (RFC 8628) ────────────────

*
* ── Where the protocol lives ───────────────────────────────────────────
*
* Discovery, DCR, PKCE, authorization-URL construction, token exchange,
* the HTTPS-only token-endpoint guard, JWT-payload decode, and
* issuer enforcement are all in `@atlas/oauth-helper` — vendored into
* `./_oauth-helper/` at install time so the published `@useatlas/mcp`
* carries a self-contained copy. This file owns the loopback transport
* (browser launch, listener, callback resolver, plural-claim extraction)
* and translates helper errors into `HostedFlowError` for the CLI's
* exit-code mapping. Spec quirks and security hardenings land in the
* helper once and reach both `@useatlas/sdk` and `@useatlas/mcp` at the
* same time.
*
* ── Test seams ─────────────────────────────────────────────────────────

@@ -45,6 +58,20 @@ *

* - `randomBytesImpl` — PKCE verifier + state (deterministic asserts)
* - `nowImpl` — for the 5-minute timeout
* - `consoleImpl` — captures user-facing output
*/
import {
OAuthHelperError,
buildAuthorizationUrl,
decodeJwtPayload,
discover,
enforceIssuer,
exchangeCode,
generatePkce,
generateState,
register,
validateIssuerUrl,
type Bearer,
type OAuthHelperErrorCode,
} from "../_oauth-helper";
// ── External shape contracts (test seams) ──────────────────────────────

@@ -110,9 +137,7 @@

/**
* 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.
* Re-exported from `@atlas/oauth-helper` so existing CLI consumers
* importing `Bearer` from this module keep working — the canonical
* brand now lives in the helper, shared with `@useatlas/sdk`.
*/
export type Bearer = string & { readonly __brand: "Bearer" };
export type { Bearer };

@@ -126,43 +151,41 @@ export interface HostedFlowResult {

workspaceId: string;
/** `${apiUrl}/mcp/${workspaceId}/sse` — what the MCP client connects to. */
/**
* The `https://atlas.useatlas.dev/workspace_ids` plural claim,
* if present in the JWT. Atlas mints this for users belonging to more
* than one workspace; the CLI uses it to decide whether to prompt for
* single-vs-multi-workspace setup at write time. Empty array (or
* missing claim) means single-workspace user — no prompt.
*/
workspaceIds: string[];
/**
* `${apiUrl}/mcp/${workspaceId}` — the canonical Streamable-HTTP endpoint
* the MCP client connects to. Deliberately *not* the legacy `/sse` alias:
* a `/sse` URL leads clients that key transport off the path (or a
* `type: "sse"` block) into the deprecated HTTP+SSE transport, which the
* hosted endpoint no longer speaks — the first request 400s. See
* `packages/api/src/lib/mcp/connect-url.ts` for the matching product-side URL.
*/
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).
* Stable error code for tests + exit-code mapping. The first eight
* values are 1:1 with `@atlas/oauth-helper`'s `OAuthHelperErrorCode`
* — the helper's HTTPS-only token-endpoint guard surfaces here as
* `invalid_token_endpoint` so the CLI's exit-code switch and the
* existing test suite continue to operate on a single union. The
* remaining values are loopback / browser / callback codes the CLI
* alone produces.
*/
export type HostedFlowErrorCode =
| "invalid_api_url"
| "invalid_token_endpoint"
| "discovery_failed"
| "issuer_mismatch"
| "registration_failed"
| "token_exchange_failed"
| "malformed_jwt"
| "missing_workspace_claim"
| "loopback_bind_failed"

@@ -174,7 +197,17 @@ | "browser_failed"

| "callback_oauth_error"
| "callback_method_not_allowed"
| "token_exchange_failed"
| "malformed_jwt"
| "missing_workspace_claim";
| "callback_method_not_allowed";
/**
* Compile-time witness that `OAuthHelperErrorCode ⊂ HostedFlowErrorCode`.
* If a future helper code arm doesn't exist on `HostedFlowErrorCode`,
* `_HelperCodeIsSubset` resolves to `never` and the assignment fails
* the type check. Catches the drift class the `liftHelper` casts below
* implicitly assume.
*/
type _HelperCodeIsSubset = OAuthHelperErrorCode extends HostedFlowErrorCode
? true
: never;
const _hostedFlowErrorCodeWitness: _HelperCodeIsSubset = true;
void _hostedFlowErrorCodeWitness;
export class HostedFlowError extends Error {

@@ -191,9 +224,57 @@ constructor(

/**
* Re-throw any `OAuthHelperError` as a `HostedFlowError` carrying the
* same code. `_hostedFlowErrorCodeWitness` above proves the cast at
* compile time.
*
* Non-`OAuthHelperError` throws (e.g. a misconfigured `fetchImpl` or
* `serveImpl` panicking with a `TypeError`) bypass the typed-error
* contract by design — the helper layer is the only legitimate source
* of `OAuthHelperError`, so anything else is a programmer error worth
* surfacing with its original stack rather than coerced into a fake
* `HostedFlowErrorCode`. The CLI's outer catch in `bin/cli.ts` prints
* the raw stack in that branch.
*/
async function liftHelper<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (err instanceof OAuthHelperError) {
throw new HostedFlowError(err.message, err.code, { cause: err });
}
throw err;
}
}
/** Drop trailing `/` characters. Non-regex to keep the polynomial-ReDoS checker happy. */
function stripTrailingSlashes(s: string): string {
let i = s.length;
while (i > 0 && s[i - 1] === "/") i--;
return i === s.length ? s : s.slice(0, i);
}
function liftHelperSync<T>(fn: () => T): T {
try {
return fn();
} catch (err) {
if (err instanceof OAuthHelperError) {
throw new HostedFlowError(err.message, err.code, { cause: err });
}
throw err;
}
}
// ── 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 REQUESTED_SCOPES: ReadonlyArray<string> = [
"openid",
"profile",
"email",
"mcp:read",
"offline_access",
];
const CLIENT_NAME = "Atlas MCP CLI";
const WORKSPACE_CLAIM = "https://atlas.useatlas.dev/workspace_id";
const WORKSPACES_CLAIM = "https://atlas.useatlas.dev/workspace_ids";

@@ -213,8 +294,8 @@ // ── Public entry point ─────────────────────────────────────────────────

): Promise<HostedFlowResult> {
const apiUrl = options.apiUrl.replace(/\/+$/, "");
validateApiUrl(apiUrl);
const apiUrl = stripTrailingSlashes(options.apiUrl);
liftHelperSync(() => validateIssuerUrl(apiUrl));
const fetchImpl = options.fetchImpl ?? fetch;
const serveImpl = options.serveImpl ?? defaultServeImpl;
const openBrowserImpl = options.openBrowserImpl ?? defaultOpenBrowserImpl;
const randomBytes = options.randomBytesImpl ?? defaultRandomBytes;
const randomBytesImpl = options.randomBytesImpl;
const cli: ConsoleImpl = options.consoleImpl ?? {

@@ -227,3 +308,3 @@ log: (m) => console.log(m),

// Step 1 — discovery
const metadata = await discover(apiUrl, fetchImpl);
const metadata = await liftHelper(() => discover(apiUrl, { fetchImpl }));

@@ -234,5 +315,4 @@ // Step 2 — generate PKCE + state and start the loopback listener BEFORE

// whatever port they get back.
const state = encodeBase64Url(randomBytes(32));
const codeVerifier = encodeBase64Url(randomBytes(32));
const codeChallenge = await pkceChallenge(codeVerifier);
const state = generateState({ randomBytesImpl });
const { codeVerifier, codeChallenge } = await generatePkce({ randomBytesImpl });

@@ -254,3 +334,13 @@ const callbackResolver = createCallbackResolver(state, timeoutMs);

// Step 3 — register a public client via DCR
const clientId = await register(metadata, redirectUri, fetchImpl);
const clientId = await liftHelper(() =>
register(
metadata,
{
redirectUri,
clientName: CLIENT_NAME,
scopes: REQUESTED_SCOPES,
},
{ fetchImpl },
),
);

@@ -261,3 +351,3 @@ // Step 4 — open the browser. Failure is non-fatal: print the URL

// instead — the message there links back to the printed URL above.
const authorizeUrl = buildAuthorizeUrl({
const authorizeUrl = buildAuthorizationUrl({
authorizationEndpoint: metadata.authorization_endpoint,

@@ -268,2 +358,3 @@ clientId,

codeChallenge,
scopes: REQUESTED_SCOPES,
});

@@ -283,11 +374,28 @@ cli.log(`Opening your browser to authorize Atlas MCP CLI…`);

// Step 6 — exchange the code
const tokenResponse = await exchangeCode({
tokenEndpoint: metadata.token_endpoint,
clientId,
redirectUri,
code,
codeVerifier,
fetchImpl,
});
// Step 6 — exchange the code. The helper validates the token
// endpoint as https-or-loopback before posting, so a malicious DCR
// response advertising `token_endpoint: "http://evil/token"` cannot
// smuggle the auth code over plaintext — the guard lives in the
// helper, so both consumers (SDK + CLI) are covered identically.
//
// `resource` (RFC 8707) binds the issued token to the MCP endpoint
// (`${apiUrl}/mcp`). Better Auth's oauth-provider only mints a
// JWT-formatted access token when the token request carries it; the
// hosted MCP route's `verifyMcpBearer` requires that JWT shape, so
// without `resource` real hosted installs receive an opaque token and
// fail bearer verification. This is the production counterpart to the
// resource-indicator the eval previously body-patched in (#3493).
const tokenResponse = await liftHelper(() =>
exchangeCode(
{
tokenEndpoint: metadata.token_endpoint,
clientId,
redirectUri,
code,
codeVerifier,
resource: `${apiUrl}/mcp`,
},
{ fetchImpl },
),
);

@@ -299,5 +407,8 @@ // Step 7 — extract + verify claims. We don't verify the JWT

// us at write-time.
const claims = decodeJwtPayload(tokenResponse.access_token);
enforceIssuer(claims, metadata.issuer);
const claims = liftHelperSync(() =>
decodeJwtPayload(tokenResponse.access_token),
);
liftHelperSync(() => enforceIssuer(claims, metadata.issuer));
const workspaceId = extractWorkspaceClaim(claims);
const workspaceIds = extractWorkspacesClaim(claims, cli);

@@ -310,3 +421,4 @@ return {

workspaceId,
mcpUrl: `${apiUrl}/mcp/${workspaceId}/sse`,
workspaceIds,
mcpUrl: `${apiUrl}/mcp/${workspaceId}`,
};

@@ -327,327 +439,50 @@ } finally {

/**
* `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",
);
}
// ── Workspace-claim extraction (CLI-specific — not in the helper) ──────
// ── 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);
function extractWorkspaceClaim(payload: Record<string, unknown>): string {
const claim = payload[WORKSPACE_CLAIM];
if (typeof claim !== "string" || claim.length === 0) {
throw new HostedFlowError(
`Could not reach Atlas auth discovery at ${url}: ${msg}`,
"discovery_failed",
{ cause: err },
`Access token is missing the ${WORKSPACE_CLAIM} claim — was the token issued for an MCP scope?`,
"missing_workspace_claim",
);
}
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,
};
return claim;
}
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.
* Extract the optional plural workspace claim. Returns an empty array
* when the claim is missing entirely (single-workspace users — Atlas
* only mints it for users belonging to more than one workspace, so
* its absence is the common case and not a fatal error). When the
* claim is *present but malformed* (not an array, or entries that
* aren't non-empty strings), we degrade to empty too — failing the
* install over a server-side schema bug is strictly worse than skipping
* the multi-workspace prompt — but we surface a warning via `cli.error`
* so a degraded run leaves a diagnostic breadcrumb instead of a silent
* downgrade.
*/
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",
function extractWorkspacesClaim(
payload: Record<string, unknown>,
cli: ConsoleImpl,
): string[] {
const raw = payload[WORKSPACES_CLAIM];
if (raw === undefined) return [];
if (!Array.isArray(raw)) {
cli.error(
`[atlas-mcp init] ${WORKSPACES_CLAIM} claim was present but not an array (got ${typeof raw}); falling back to single-workspace setup.`,
);
return [];
}
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 },
const filtered = raw.filter(
(entry): entry is string => typeof entry === "string" && entry.length > 0,
);
if (filtered.length !== raw.length) {
cli.error(
`[atlas-mcp init] ${WORKSPACES_CLAIM} claim contained ${raw.length - filtered.length} non-string or empty entr${raw.length - filtered.length === 1 ? "y" : "ies"}; using only the ${filtered.length} valid entr${filtered.length === 1 ? "y" : "ies"}.`,
);
}
return filtered;
}
/**
* 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 ───────

@@ -775,2 +610,6 @@

const cancel = () => {
// After a happy-path `resolve(code)`, `cancel()` is a no-op
// (`settled` is already true from `resolve`). The reject below
// only fires on the genuine abort path — an early failure in
// register/exchange/extract that throws before the callback arrives.
if (settled) return;

@@ -905,21 +744,1 @@ settled = true;

};
// ── 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));
}

@@ -26,4 +26,31 @@ /**

// 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";
const SERVER_NAME = "atlas";
const DEFAULT_HOSTED_API_URL = "https://api.useatlas.dev";
// #2068 — `mcp.useatlas.dev` is the brand surface for the hosted MCP
// endpoint. DNS CNAMEs fan it (and the regional siblings
// `mcp-eu`/`mcp-apac.useatlas.dev`) into the same Railway services as
// the underlying `api.*` hosts. Defaulting here means the standard
// `bunx @useatlas/mcp init --hosted --write` flow lands on the brand
// surface without operator plumbing; ATLAS_PUBLIC_API_URL still wins
// for cross-region overrides and self-hosted targets.
const DEFAULT_HOSTED_API_URL = "https://mcp.useatlas.dev";

@@ -46,5 +73,27 @@ export interface LocalInitOptions {

/**
* 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<WorkspaceChoice>;
export interface HostedInitOptions {
mode: "hosted";
/** Override the hosted Atlas API base. Defaults to `https://api.useatlas.dev`. */
/** Override the hosted Atlas API base. Defaults to `https://mcp.useatlas.dev`. */
apiUrl?: string;

@@ -64,2 +113,8 @@ client?: McpClientId;

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

@@ -105,5 +160,21 @@

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,
});

@@ -114,2 +185,9 @@ const clientId = opts.client ?? pickDefaultClient(opts.detectClientsImpl);

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 https://app.useatlas.dev/settings/ai-agents to grant access to your other ` +
`workspaces (${result.workspaceIds.length - 1} additional).`,
);
}

@@ -242,1 +320,64 @@ if (!opts.write || configPath === null) {

}
/**
* 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<string>((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();
}
};