@useatlas/mcp
Advanced tools
| // 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. |
+8
-5
| { | ||
| "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:*" | ||
| } | ||
| } |
+12
-19
@@ -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 @@ |
+214
-395
| /** | ||
| * `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)); | ||
| } |
+143
-2
@@ -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(); | ||
| } | ||
| }; |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
112683
36.27%26
100%2630
32.96%6
20%13
30%