@useatlas/sdk
Advanced tools
+284
| /** | ||
| * Programmatic MCP onboarding helpers — OAuth 2.1 + DCR + PKCE flow | ||
| * reshaped so it runs from a server-side framework, a browser, an | ||
| * embedded React component, or a Node CI script. The | ||
| * `@useatlas/mcp init --hosted` CLI binds a `127.0.0.1:0` loopback | ||
| * listener to receive the redirect; this module delegates that step | ||
| * to the caller — `beginConnect` returns the `authorizationUrl` for | ||
| * the caller to open (popup / redirect / new tab) and `completeConnect` | ||
| * exchanges the resulting `code` for a JWT. | ||
| * | ||
| * ── What the caller must persist between begin → complete ─────────── | ||
| * | ||
| * `state`, `codeVerifier`, `clientId`, and `tokenEndpoint` are returned | ||
| * from `beginConnect` and required by `completeConnect`. In a browser | ||
| * popup flow the caller stores them in `sessionStorage`; in a server- | ||
| * side flow the caller stashes them in the user's session record. | ||
| * | ||
| * The hook in `@useatlas/react` wraps this lifecycle for the popup | ||
| * case so SDK consumers don't reimplement the bookkeeping. | ||
| * | ||
| * ── Tree-shaking ────────────────────────────────────────────────── | ||
| * | ||
| * This module imports nothing from `@modelcontextprotocol/sdk` — only | ||
| * the standard browser/node OAuth + crypto primitives via the shared | ||
| * `@atlas/oauth-helper`, which is bundled into the SDK at build time | ||
| * (it is never resolved from npm). The published `.d.ts` does not | ||
| * import or re-export the helper package — the JSDoc references it, | ||
| * but TypeScript doesn't resolve those. Anyone who never imports | ||
| * `mcp.ts` pays nothing. | ||
| */ | ||
| /** | ||
| * Error codes carried by `AtlasMcpError`. The first eight values are | ||
| * 1:1 with `@atlas/oauth-helper`'s `OAuthHelperErrorCode` (re-declared | ||
| * inline so the published `.d.ts` doesn't import the internal-only | ||
| * helper package). The next three (`callback_*`) are produced inside | ||
| * the SDK proper. The remaining popup codes (`popup_blocked`, | ||
| * `popup_closed`) are reserved for the React popup driver in | ||
| * `@useatlas/react`'s `use-mcp-connect`, which throws against this | ||
| * union; the SDK proper never throws those itself. | ||
| */ | ||
| export type AtlasMcpErrorCode = "invalid_api_url" | "invalid_token_endpoint" | "discovery_failed" | "registration_failed" | "token_exchange_failed" | "issuer_mismatch" | "malformed_jwt" | "missing_workspace_claim" | "callback_state_mismatch" | "callback_state_missing" | "callback_missing_code" | "popup_blocked" | "popup_closed" | "grant_not_supported" | "workspace_not_in_grant_list"; | ||
| export declare class AtlasMcpError extends Error { | ||
| readonly code: AtlasMcpErrorCode; | ||
| constructor(message: string, code: AtlasMcpErrorCode, options?: ErrorOptions); | ||
| } | ||
| export interface BeginConnectOptions { | ||
| /** | ||
| * Atlas API base — e.g. `https://mcp.useatlas.dev`. Discovery doc lives | ||
| * at `${apiUrl}/.well-known/oauth-authorization-server/api/auth`. Must | ||
| * be `https://`, except for `http://127.0.0.1` / `http://localhost` | ||
| * which are accepted for local-dev testing. | ||
| */ | ||
| apiUrl: string; | ||
| /** Human-readable name registered via DCR — shown on the consent screen. */ | ||
| clientName: string; | ||
| /** | ||
| * Where the auth server should send the user after consent. Must match | ||
| * the `redirect_uri` the caller listens on (e.g. a `/oauth/callback` | ||
| * page in a Next.js app or the popup-host page in a browser flow). | ||
| */ | ||
| redirectUri: string; | ||
| /** Defaults to `["mcp:read", "offline_access"]`. */ | ||
| scopes?: ReadonlyArray<string>; | ||
| /** | ||
| * Forward-compat slot (#2196). **No-op today.** The Atlas OAuth | ||
| * provider does not yet accept a workspace hint on the authorize | ||
| * endpoint — server-side claim issuance reads the user's session at | ||
| * consent time and emits the singular + (optionally) plural claims | ||
| * based on membership, regardless of what you pass here. Reserved so | ||
| * the SDK surface doesn't need to change when the provider lands | ||
| * the hint. | ||
| * | ||
| * Passing this option today has no observable effect — to get a | ||
| * multi-workspace token, just ensure the authenticating user belongs | ||
| * to more than one workspace. The plural claim then surfaces on | ||
| * `completeConnect`'s result as `workspaces`. | ||
| */ | ||
| workspaceId?: string; | ||
| /** Test seam — defaults to global `fetch`. */ | ||
| fetchImpl?: typeof fetch; | ||
| /** Test seam — defaults to `crypto.getRandomValues`. */ | ||
| randomBytesImpl?: (length: number) => Uint8Array; | ||
| } | ||
| export interface BeginConnectResult { | ||
| /** URL the caller redirects/popup-opens for the user to consent. */ | ||
| authorizationUrl: string; | ||
| /** | ||
| * Anti-CSRF state. Caller must echo this back to `completeConnect.expectedState` | ||
| * when the callback fires. | ||
| */ | ||
| state: string; | ||
| /** | ||
| * PKCE code verifier. Caller must persist between begin and complete | ||
| * (sessionStorage in a browser, session record on a server). | ||
| */ | ||
| codeVerifier: string; | ||
| /** DCR-issued client_id for this caller. Persist alongside `codeVerifier`. */ | ||
| clientId: string; | ||
| /** Cached for `completeConnect` so it doesn't re-discover. */ | ||
| tokenEndpoint: string; | ||
| /** Discovered issuer claim — used to verify the JWT's `iss`. */ | ||
| issuer: string; | ||
| } | ||
| export interface CompleteConnectOptions { | ||
| apiUrl: string; | ||
| /** State value received in the callback (`?state=`). */ | ||
| state: string; | ||
| /** State value originally returned from `beginConnect`. */ | ||
| expectedState: string; | ||
| /** Authorization code received in the callback (`?code=`). */ | ||
| code: string; | ||
| /** PKCE verifier from `beginConnect`. */ | ||
| codeVerifier: string; | ||
| /** DCR-issued client_id from `beginConnect`. */ | ||
| clientId: string; | ||
| /** Same `redirect_uri` you passed to `beginConnect`. */ | ||
| redirectUri: string; | ||
| /** | ||
| * From `beginConnect.tokenEndpoint` — skips a re-discover roundtrip. | ||
| * Pass alongside `issuer`; passing only one re-discovers and ignores | ||
| * the partial input. | ||
| */ | ||
| tokenEndpoint?: string; | ||
| /** | ||
| * From `beginConnect.issuer` — used to verify the JWT's `iss` claim. | ||
| * Pass alongside `tokenEndpoint`. | ||
| */ | ||
| issuer?: string; | ||
| /** Test seam. */ | ||
| fetchImpl?: typeof fetch; | ||
| } | ||
| export interface CompleteConnectResult { | ||
| /** OAuth 2.1 access token (signed JWT). Treat as a credential. */ | ||
| accessToken: string; | ||
| /** OAuth 2.1 refresh token, when offline_access was granted. */ | ||
| refreshToken: string | null; | ||
| /** | ||
| * ms epoch. Derived from `expires_in` at exchange time; falls back | ||
| * to one hour from now when the token endpoint omits `expires_in`, | ||
| * so callers scheduling refresh against a server with a non-standard | ||
| * lifetime should re-check expiry from the JWT itself. | ||
| */ | ||
| expiresAt: number; | ||
| /** `https://atlas.useatlas.dev/workspace_id` claim from the JWT. */ | ||
| workspaceId: string; | ||
| /** | ||
| * `https://atlas.useatlas.dev/workspace_ids` plural claim (#2196) — the | ||
| * complete set of workspaces this token grants access to, in the order | ||
| * the server emitted them. Empty array when the token was minted for | ||
| * a user belonging to exactly one workspace (the server omits the | ||
| * plural claim in that case). Always a stable type so embedders | ||
| * rendering a workspace picker don't need to null-check. | ||
| * | ||
| * Typed `ReadonlyArray<string>` so consumers can't mutate the | ||
| * SDK-owned array in place (the value is held in `useState` inside | ||
| * `useMcpConnect`; an in-place `.sort()` would silently mutate React | ||
| * state). Pipes cleanly into `buildConfig({ workspaces })` whose | ||
| * input shape is the same. | ||
| * | ||
| * The runtime authorization layer at the MCP edge does NOT rely on | ||
| * this list — membership is re-checked against the live grants table | ||
| * on every request so revocation is immediate. Treat the array as a | ||
| * UX affordance, not a security boundary. | ||
| */ | ||
| workspaces: ReadonlyArray<string>; | ||
| } | ||
| export type McpClientId = "claude-desktop" | "cursor" | "continue" | "chatgpt" | "generic"; | ||
| export interface BuildConfigOptions { | ||
| client: McpClientId; | ||
| apiUrl: string; | ||
| accessToken: string; | ||
| /** | ||
| * Workspace pinned in the connection URL. Even for multi-workspace | ||
| * tokens this is required — the hosted MCP edge mounts at | ||
| * `/mcp/{workspace_id}/sse`. For multi-workspace setups pass the | ||
| * default-workspace id (typically the singular claim from | ||
| * `completeConnect`); per-request overrides happen via the | ||
| * `X-Atlas-Workspace` header. | ||
| */ | ||
| workspaceId: string; | ||
| /** Override the `mcpServers["..."]` key. Defaults to `"atlas"`. */ | ||
| serverName?: string; | ||
| /** | ||
| * Multi-workspace opt-in (#2196). Pass the full list of workspaces | ||
| * this token grants access to (the `workspaces` field from | ||
| * `completeConnect`'s result). When non-empty, the emitted block | ||
| * gains an `env: { ATLAS_DEFAULT_WORKSPACE: <workspaceId> }` slot so | ||
| * future MCP-client framework wrappers can bridge it into the | ||
| * `X-Atlas-Default-Workspace` header (priority 2 in the edge's | ||
| * resolution chain). Output matches the CLI's hosted-config writer | ||
| * so SDK and CLI emit identical config blocks for the same token. | ||
| * | ||
| * **Wire-shape note.** [#2073's recommendation A](https://github.com/AtlasDevHQ/atlas/issues/2073) | ||
| * sketched `url: "https://mcp.useatlas.dev/sse"` without a workspace | ||
| * in the path, but the implemented hosted MCP endpoint mounts at | ||
| * `/mcp/{workspace_id}/sse` and resolves per-request overrides via | ||
| * the `X-Atlas-Workspace` header. The SDK emits the implemented | ||
| * shape — a single config block, one default workspace in the | ||
| * path, and the env hint for per-request overrides. | ||
| * | ||
| * Omit (or pass an empty array) for the legacy single-workspace | ||
| * shape — backward-compatible with every caller pre-#2196. | ||
| */ | ||
| workspaces?: ReadonlyArray<string>; | ||
| } | ||
| export interface McpHttpServer { | ||
| url: string; | ||
| headers: { | ||
| Authorization: string; | ||
| }; | ||
| /** | ||
| * Multi-workspace hint block (#2196). Present only when `buildConfig` | ||
| * was called with a non-empty `workspaces` array — the legacy | ||
| * single-workspace shape omits the field entirely so the | ||
| * JSON-serialized server block is byte-identical to pre-#2196 | ||
| * output. (The outer `McpClientConfig` carries a `kind` discriminator | ||
| * intended to be dropped before JSON output; see `stripKind` in the | ||
| * worked example for the standard call-site pattern.) | ||
| */ | ||
| env?: { | ||
| ATLAS_DEFAULT_WORKSPACE: string; | ||
| }; | ||
| } | ||
| /** | ||
| * Discriminated by `client`: | ||
| * - `"generic"` returns the bare server block — `{ url, headers }`. | ||
| * - everything else returns the wrapped block — | ||
| * `{ mcpServers: { [serverName]: McpHttpServer } }`. | ||
| * | ||
| * Splitting these as a tagged union (instead of one bag with optional | ||
| * fields) means a consumer that destructures `cfg` gets only the | ||
| * fields that are actually present for the chosen client, without | ||
| * needing runtime guards. | ||
| */ | ||
| export interface McpWrappedConfig { | ||
| readonly kind: "wrapped"; | ||
| readonly mcpServers: Record<string, McpHttpServer>; | ||
| } | ||
| export interface McpBareConfig extends McpHttpServer { | ||
| readonly kind: "bare"; | ||
| } | ||
| export type McpClientConfig = McpWrappedConfig | McpBareConfig; | ||
| export interface ConnectMachineToMachineOptions { | ||
| apiUrl: string; | ||
| clientId: string; | ||
| clientSecret: string; | ||
| scopes?: ReadonlyArray<string>; | ||
| fetchImpl?: typeof fetch; | ||
| } | ||
| export interface ConnectMachineToMachineResult { | ||
| accessToken: string; | ||
| expiresAt: number; | ||
| } | ||
| export declare function beginConnect(options: BeginConnectOptions): Promise<BeginConnectResult>; | ||
| export declare function completeConnect(options: CompleteConnectOptions): Promise<CompleteConnectResult>; | ||
| export declare function buildConfig(options: BuildConfigOptions): McpClientConfig; | ||
| /** | ||
| * Server-to-server flow using the OAuth `client_credentials` grant. | ||
| * | ||
| * Throws `AtlasMcpError(code: "grant_not_supported")` until the Atlas | ||
| * OAuth provider exposes the grant. The public surface is fixed; when | ||
| * the provider lands the grant (tracking issue: see roadmap), swap | ||
| * this body for the live exchange without changing the type. | ||
| */ | ||
| export declare function connectMachineToMachine(_options: ConnectMachineToMachineOptions): Promise<ConnectMachineToMachineResult>; | ||
| export interface ListAgentsResponse { | ||
| clients: Array<{ | ||
| clientId: string; | ||
| clientName: string | null; | ||
| redirectUris: string[]; | ||
| createdAt: string; | ||
| updatedAt: string | null; | ||
| disabled: boolean; | ||
| type: string | null; | ||
| lastUsedAt: string | null; | ||
| tokenCount: number; | ||
| tokenState: "active" | "reconnect_required" | "revoked"; | ||
| }>; | ||
| deployMode: "self-hosted" | "saas"; | ||
| } | ||
| export interface RevokeAgentResponse { | ||
| success: boolean; | ||
| tokensRevoked: number; | ||
| } |
+427
| // ../oauth-helper/src/errors.ts | ||
| class OAuthHelperError extends Error { | ||
| code; | ||
| constructor(message, code, options) { | ||
| super(message, options); | ||
| this.name = "OAuthHelperError"; | ||
| this.code = code; | ||
| } | ||
| } | ||
| // ../oauth-helper/src/_internal/http.ts | ||
| var FETCH_TIMEOUT_MS = 30 * 1000; | ||
| async function describeOAuthErrorBody(res) { | ||
| let raw; | ||
| 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); | ||
| const parts = []; | ||
| 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 {} | ||
| return raw.length > 1024 ? `${raw.slice(0, 1024)}…` : raw; | ||
| } | ||
| // ../oauth-helper/src/discover.ts | ||
| function stripTrailingSlashes(s) { | ||
| let i = s.length; | ||
| while (i > 0 && s[i - 1] === "/") | ||
| i--; | ||
| return i === s.length ? s : s.slice(0, i); | ||
| } | ||
| var DISCOVERY_PATH = "/.well-known/oauth-authorization-server/api/auth"; | ||
| async function discover(apiUrl, options) { | ||
| const fetchImpl = options?.fetchImpl ?? fetch; | ||
| const url = `${stripTrailingSlashes(apiUrl)}${DISCOVERY_PATH}`; | ||
| let res; | ||
| 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 }); | ||
| }); | ||
| 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 | ||
| }; | ||
| } | ||
| // ../oauth-helper/src/register.ts | ||
| async function register(metadata, params, options) { | ||
| 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; | ||
| 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 }); | ||
| }); | ||
| 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; | ||
| } | ||
| // ../oauth-helper/src/_internal/encoding.ts | ||
| function defaultRandomBytes(length) { | ||
| const buf = new Uint8Array(length); | ||
| crypto.getRandomValues(buf); | ||
| return buf; | ||
| } | ||
| function encodeBase64Url(bytes) { | ||
| let bin = ""; | ||
| for (let i = 0;i < bytes.length; i++) | ||
| bin += String.fromCharCode(bytes[i]); | ||
| return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | ||
| } | ||
| // ../oauth-helper/src/pkce.ts | ||
| var VERIFIER_BYTES = 32; | ||
| var STATE_BYTES = 32; | ||
| async function generatePkce(options) { | ||
| const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes; | ||
| const codeVerifier = encodeBase64Url(randomBytes(VERIFIER_BYTES)); | ||
| const codeChallenge = await pkceChallenge(codeVerifier); | ||
| return { codeVerifier, codeChallenge, method: "S256" }; | ||
| } | ||
| function generateState(options) { | ||
| const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes; | ||
| return encodeBase64Url(randomBytes(STATE_BYTES)); | ||
| } | ||
| async function pkceChallenge(verifier) { | ||
| const data = new TextEncoder().encode(verifier); | ||
| const digest = await crypto.subtle.digest("SHA-256", data); | ||
| return encodeBase64Url(new Uint8Array(digest)); | ||
| } | ||
| // ../oauth-helper/src/authorize-url.ts | ||
| function buildAuthorizationUrl(params) { | ||
| 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()}`; | ||
| } | ||
| // ../oauth-helper/src/validate.ts | ||
| function validateHttpsUrl(input, code, label) { | ||
| let parsed; | ||
| 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); | ||
| } | ||
| function validateIssuerUrl(apiUrl) { | ||
| validateHttpsUrl(apiUrl, "invalid_api_url", "apiUrl"); | ||
| } | ||
| function validateTokenEndpoint(tokenEndpoint) { | ||
| validateHttpsUrl(tokenEndpoint, "invalid_token_endpoint", "tokenEndpoint"); | ||
| } | ||
| // ../oauth-helper/src/exchange.ts | ||
| async function exchangeCode(params, options) { | ||
| 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 | ||
| }); | ||
| let res; | ||
| 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 }); | ||
| }); | ||
| 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 | ||
| }; | ||
| } | ||
| // ../oauth-helper/src/jwt.ts | ||
| function decodeJwtPayload(jwtToken) { | ||
| 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); | ||
| } catch (err) { | ||
| throw new OAuthHelperError(`Could not decode JWT payload: ${err instanceof Error ? err.message : String(err)}`, "malformed_jwt", { cause: err }); | ||
| } | ||
| } | ||
| function enforceIssuer(payload, expectedIssuer) { | ||
| 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"); | ||
| } | ||
| } | ||
| // src/mcp.ts | ||
| class AtlasMcpError extends Error { | ||
| code; | ||
| constructor(message, code, options) { | ||
| super(message, options); | ||
| this.name = "AtlasMcpError"; | ||
| this.code = code; | ||
| } | ||
| } | ||
| async function liftHelper(fn) { | ||
| try { | ||
| return await fn(); | ||
| } catch (err) { | ||
| if (err instanceof OAuthHelperError) { | ||
| throw new AtlasMcpError(err.message, err.code, { cause: err }); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| function liftHelperSync(fn) { | ||
| try { | ||
| return fn(); | ||
| } catch (err) { | ||
| if (err instanceof OAuthHelperError) { | ||
| throw new AtlasMcpError(err.message, err.code, { cause: err }); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| var DEFAULT_SCOPES = ["mcp:read", "offline_access"]; | ||
| var WORKSPACE_CLAIM = "https://atlas.useatlas.dev/workspace_id"; | ||
| var WORKSPACES_CLAIM = "https://atlas.useatlas.dev/workspace_ids"; | ||
| async function beginConnect(options) { | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| liftHelperSync(() => validateIssuerUrl(apiUrl)); | ||
| const fetchImpl = options.fetchImpl ?? fetch; | ||
| const randomBytesImpl = options.randomBytesImpl; | ||
| const scopes = options.scopes ?? DEFAULT_SCOPES; | ||
| return liftHelper(async () => { | ||
| const metadata = await discover(apiUrl, { fetchImpl }); | ||
| const state = generateState({ randomBytesImpl }); | ||
| const { codeVerifier, codeChallenge } = await generatePkce({ randomBytesImpl }); | ||
| const clientId = await register(metadata, { | ||
| redirectUri: options.redirectUri, | ||
| clientName: options.clientName, | ||
| scopes | ||
| }, { fetchImpl }); | ||
| const authorizationUrl = buildAuthorizationUrl({ | ||
| authorizationEndpoint: metadata.authorization_endpoint, | ||
| clientId, | ||
| redirectUri: options.redirectUri, | ||
| state, | ||
| codeChallenge, | ||
| scopes | ||
| }); | ||
| return { | ||
| authorizationUrl, | ||
| state, | ||
| codeVerifier, | ||
| clientId, | ||
| tokenEndpoint: metadata.token_endpoint, | ||
| issuer: metadata.issuer | ||
| }; | ||
| }); | ||
| } | ||
| async function completeConnect(options) { | ||
| if (options.state !== options.expectedState) { | ||
| throw new AtlasMcpError(`OAuth state mismatch — possible CSRF. Got \`${options.state}\`, expected the value returned from beginConnect.`, "callback_state_mismatch"); | ||
| } | ||
| if (!options.code) { | ||
| throw new AtlasMcpError(`Authorization callback was missing the \`code\` parameter.`, "callback_missing_code"); | ||
| } | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| liftHelperSync(() => validateIssuerUrl(apiUrl)); | ||
| const fetchImpl = options.fetchImpl ?? fetch; | ||
| return liftHelper(async () => { | ||
| let tokenEndpoint = options.tokenEndpoint; | ||
| let issuer = options.issuer; | ||
| if (!tokenEndpoint || !issuer) { | ||
| const metadata = await discover(apiUrl, { fetchImpl }); | ||
| tokenEndpoint = tokenEndpoint ?? metadata.token_endpoint; | ||
| issuer = issuer ?? metadata.issuer; | ||
| } | ||
| validateTokenEndpoint(tokenEndpoint); | ||
| const tokenResponse = await exchangeCode({ | ||
| tokenEndpoint, | ||
| clientId: options.clientId, | ||
| redirectUri: options.redirectUri, | ||
| code: options.code, | ||
| codeVerifier: options.codeVerifier | ||
| }, { fetchImpl }); | ||
| const claims = decodeJwtPayload(tokenResponse.access_token); | ||
| enforceIssuer(claims, issuer); | ||
| const workspaceId = extractWorkspaceClaim(claims); | ||
| const workspaces = extractWorkspacesClaim(claims); | ||
| const expiresIn = typeof tokenResponse.expires_in === "number" && tokenResponse.expires_in > 0 ? tokenResponse.expires_in : 3600; | ||
| return { | ||
| accessToken: tokenResponse.access_token, | ||
| refreshToken: tokenResponse.refresh_token ?? null, | ||
| expiresAt: Date.now() + expiresIn * 1000, | ||
| workspaceId, | ||
| workspaces | ||
| }; | ||
| }); | ||
| } | ||
| var SERVER_NAME_DEFAULT = "atlas"; | ||
| function buildConfig(options) { | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| const url = `${apiUrl}/mcp/${encodeURIComponent(options.workspaceId)}/sse`; | ||
| const isMultiWorkspace = (options.workspaces?.length ?? 0) > 0; | ||
| if (isMultiWorkspace && !options.workspaces.includes(options.workspaceId)) { | ||
| throw new AtlasMcpError(`buildConfig: workspaceId \`${options.workspaceId}\` is not in the granted workspaces list — pass a default that is one of: ${options.workspaces.join(", ")}.`, "workspace_not_in_grant_list"); | ||
| } | ||
| const block = isMultiWorkspace ? { | ||
| url, | ||
| headers: { Authorization: `Bearer ${options.accessToken}` }, | ||
| env: { ATLAS_DEFAULT_WORKSPACE: options.workspaceId } | ||
| } : { | ||
| url, | ||
| headers: { Authorization: `Bearer ${options.accessToken}` } | ||
| }; | ||
| const name = options.serverName ?? SERVER_NAME_DEFAULT; | ||
| switch (options.client) { | ||
| case "generic": | ||
| return isMultiWorkspace ? { | ||
| kind: "bare", | ||
| url: block.url, | ||
| headers: block.headers, | ||
| env: block.env | ||
| } : { kind: "bare", url: block.url, headers: block.headers }; | ||
| case "claude-desktop": | ||
| case "cursor": | ||
| case "continue": | ||
| case "chatgpt": | ||
| return { kind: "wrapped", mcpServers: { [name]: block } }; | ||
| default: | ||
| return assertNever(options.client); | ||
| } | ||
| } | ||
| function assertNever(x) { | ||
| throw new Error(`Unhandled MCP client id: ${String(x)}`); | ||
| } | ||
| async function connectMachineToMachine(_options) { | ||
| throw new AtlasMcpError("client_credentials grant is not yet enabled on the Atlas OAuth provider. Use the authorization-code flow (beginConnect / completeConnect) for now.", "grant_not_supported"); | ||
| } | ||
| function stripTrailingSlashes2(s) { | ||
| let i = s.length; | ||
| while (i > 0 && s[i - 1] === "/") | ||
| i--; | ||
| return i === s.length ? s : s.slice(0, i); | ||
| } | ||
| function extractWorkspaceClaim(payload) { | ||
| const claim = payload[WORKSPACE_CLAIM]; | ||
| if (typeof claim !== "string" || claim.length === 0) { | ||
| throw new AtlasMcpError(`Access token is missing the ${WORKSPACE_CLAIM} claim — was the token issued for an MCP scope?`, "missing_workspace_claim"); | ||
| } | ||
| return claim; | ||
| } | ||
| function extractWorkspacesClaim(payload) { | ||
| const raw = payload[WORKSPACES_CLAIM]; | ||
| if (raw === undefined) | ||
| return []; | ||
| if (!Array.isArray(raw)) { | ||
| console.warn(`[@useatlas/sdk] ${WORKSPACES_CLAIM} claim was present but not an array (got ${typeof raw}); falling back to single-workspace.`); | ||
| return []; | ||
| } | ||
| const filtered = raw.filter((entry) => typeof entry === "string" && entry.length > 0); | ||
| if (filtered.length !== raw.length) { | ||
| const dropped = raw.length - filtered.length; | ||
| console.warn(`[@useatlas/sdk] ${WORKSPACES_CLAIM} claim contained ${dropped} non-string or empty entr${dropped === 1 ? "y" : "ies"}; using only the ${filtered.length} valid entr${filtered.length === 1 ? "y" : "ies"}.`); | ||
| } | ||
| return filtered; | ||
| } | ||
| export { | ||
| connectMachineToMachine, | ||
| completeConnect, | ||
| buildConfig, | ||
| beginConnect, | ||
| AtlasMcpError | ||
| }; |
+27
-0
@@ -9,2 +9,3 @@ /** | ||
| import type { AuthMode, ChatErrorCode, Conversation, ConversationWithMessages, ConnectionHealth, ConnectionInfo, DeliveryChannel, ActionApprovalMode, ActionStatus, RollbackInfo, Recipient, ScheduledTask, ScheduledTaskWithRuns, ScheduledTaskRun, StarterPromptsResponse, TableInfo } from "@useatlas/types"; | ||
| import { type BeginConnectOptions, type BeginConnectResult, type BuildConfigOptions, type CompleteConnectOptions, type CompleteConnectResult, type ConnectMachineToMachineOptions, type ConnectMachineToMachineResult, type ListAgentsResponse, type McpClientConfig, type RevokeAgentResponse } from "./mcp"; | ||
| /** @deprecated Use `Recipient` instead. */ | ||
@@ -468,4 +469,30 @@ export type ScheduledTaskRecipient = Recipient; | ||
| chat(messages: ChatMessage[], opts?: ChatOptions): Promise<Response>; | ||
| /** | ||
| * MCP onboarding helpers pre-bound to the client's `baseUrl` so | ||
| * embedders don't repeat the apiUrl. The unbound versions live at | ||
| * `@useatlas/sdk/mcp` for callers who want to share an `apiUrl` | ||
| * across multiple clients or skip the AtlasClient construction. | ||
| */ | ||
| mcp: { | ||
| /** Initiate the OAuth 2.1 + DCR flow. Returns the URL to open + state to persist. */ | ||
| beginConnect(opts: Omit<BeginConnectOptions, "apiUrl">): Promise<BeginConnectResult>; | ||
| /** Exchange the OAuth `code` for an access token + workspace id. */ | ||
| completeConnect(opts: Omit<CompleteConnectOptions, "apiUrl">): Promise<CompleteConnectResult>; | ||
| /** Build a paste-ready config block for the requested MCP client. */ | ||
| buildConfig(opts: Omit<BuildConfigOptions, "apiUrl">): McpClientConfig; | ||
| /** Server-to-server flow (currently throws — gated on #2024). */ | ||
| connectMachineToMachine(opts: Omit<ConnectMachineToMachineOptions, "apiUrl">): Promise<ConnectMachineToMachineResult>; | ||
| /** | ||
| * List the calling user's connected MCP agents (OAuth clients) for | ||
| * the active workspace. Wraps `GET /api/v1/me/oauth-clients`. | ||
| */ | ||
| listAgents(): Promise<ListAgentsResponse>; | ||
| /** | ||
| * Revoke one of the calling user's MCP agents. Wraps | ||
| * `POST /api/v1/me/oauth-clients/:id/revoke`. | ||
| */ | ||
| revokeAgent(clientId: string): Promise<RevokeAgentResponse>; | ||
| }; | ||
| }; | ||
| /** The return type of `createAtlasClient`. */ | ||
| export type AtlasClient = ReturnType<typeof createAtlasClient>; |
+443
-0
@@ -0,1 +1,422 @@ | ||
| // ../oauth-helper/src/errors.ts | ||
| class OAuthHelperError extends Error { | ||
| code; | ||
| constructor(message, code, options) { | ||
| super(message, options); | ||
| this.name = "OAuthHelperError"; | ||
| this.code = code; | ||
| } | ||
| } | ||
| // ../oauth-helper/src/_internal/http.ts | ||
| var FETCH_TIMEOUT_MS = 30 * 1000; | ||
| async function describeOAuthErrorBody(res) { | ||
| let raw; | ||
| 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); | ||
| const parts = []; | ||
| 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 {} | ||
| return raw.length > 1024 ? `${raw.slice(0, 1024)}…` : raw; | ||
| } | ||
| // ../oauth-helper/src/discover.ts | ||
| function stripTrailingSlashes(s) { | ||
| let i = s.length; | ||
| while (i > 0 && s[i - 1] === "/") | ||
| i--; | ||
| return i === s.length ? s : s.slice(0, i); | ||
| } | ||
| var DISCOVERY_PATH = "/.well-known/oauth-authorization-server/api/auth"; | ||
| async function discover(apiUrl, options) { | ||
| const fetchImpl = options?.fetchImpl ?? fetch; | ||
| const url = `${stripTrailingSlashes(apiUrl)}${DISCOVERY_PATH}`; | ||
| let res; | ||
| 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 }); | ||
| }); | ||
| 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 | ||
| }; | ||
| } | ||
| // ../oauth-helper/src/register.ts | ||
| async function register(metadata, params, options) { | ||
| 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; | ||
| 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 }); | ||
| }); | ||
| 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; | ||
| } | ||
| // ../oauth-helper/src/_internal/encoding.ts | ||
| function defaultRandomBytes(length) { | ||
| const buf = new Uint8Array(length); | ||
| crypto.getRandomValues(buf); | ||
| return buf; | ||
| } | ||
| function encodeBase64Url(bytes) { | ||
| let bin = ""; | ||
| for (let i = 0;i < bytes.length; i++) | ||
| bin += String.fromCharCode(bytes[i]); | ||
| return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | ||
| } | ||
| // ../oauth-helper/src/pkce.ts | ||
| var VERIFIER_BYTES = 32; | ||
| var STATE_BYTES = 32; | ||
| async function generatePkce(options) { | ||
| const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes; | ||
| const codeVerifier = encodeBase64Url(randomBytes(VERIFIER_BYTES)); | ||
| const codeChallenge = await pkceChallenge(codeVerifier); | ||
| return { codeVerifier, codeChallenge, method: "S256" }; | ||
| } | ||
| function generateState(options) { | ||
| const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes; | ||
| return encodeBase64Url(randomBytes(STATE_BYTES)); | ||
| } | ||
| async function pkceChallenge(verifier) { | ||
| const data = new TextEncoder().encode(verifier); | ||
| const digest = await crypto.subtle.digest("SHA-256", data); | ||
| return encodeBase64Url(new Uint8Array(digest)); | ||
| } | ||
| // ../oauth-helper/src/authorize-url.ts | ||
| function buildAuthorizationUrl(params) { | ||
| 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()}`; | ||
| } | ||
| // ../oauth-helper/src/validate.ts | ||
| function validateHttpsUrl(input, code, label) { | ||
| let parsed; | ||
| 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); | ||
| } | ||
| function validateIssuerUrl(apiUrl) { | ||
| validateHttpsUrl(apiUrl, "invalid_api_url", "apiUrl"); | ||
| } | ||
| function validateTokenEndpoint(tokenEndpoint) { | ||
| validateHttpsUrl(tokenEndpoint, "invalid_token_endpoint", "tokenEndpoint"); | ||
| } | ||
| // ../oauth-helper/src/exchange.ts | ||
| async function exchangeCode(params, options) { | ||
| 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 | ||
| }); | ||
| let res; | ||
| 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 }); | ||
| }); | ||
| 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 | ||
| }; | ||
| } | ||
| // ../oauth-helper/src/jwt.ts | ||
| function decodeJwtPayload(jwtToken) { | ||
| 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); | ||
| } catch (err) { | ||
| throw new OAuthHelperError(`Could not decode JWT payload: ${err instanceof Error ? err.message : String(err)}`, "malformed_jwt", { cause: err }); | ||
| } | ||
| } | ||
| function enforceIssuer(payload, expectedIssuer) { | ||
| 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"); | ||
| } | ||
| } | ||
| // src/mcp.ts | ||
| class AtlasMcpError extends Error { | ||
| code; | ||
| constructor(message, code, options) { | ||
| super(message, options); | ||
| this.name = "AtlasMcpError"; | ||
| this.code = code; | ||
| } | ||
| } | ||
| async function liftHelper(fn) { | ||
| try { | ||
| return await fn(); | ||
| } catch (err) { | ||
| if (err instanceof OAuthHelperError) { | ||
| throw new AtlasMcpError(err.message, err.code, { cause: err }); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| function liftHelperSync(fn) { | ||
| try { | ||
| return fn(); | ||
| } catch (err) { | ||
| if (err instanceof OAuthHelperError) { | ||
| throw new AtlasMcpError(err.message, err.code, { cause: err }); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| var DEFAULT_SCOPES = ["mcp:read", "offline_access"]; | ||
| var WORKSPACE_CLAIM = "https://atlas.useatlas.dev/workspace_id"; | ||
| var WORKSPACES_CLAIM = "https://atlas.useatlas.dev/workspace_ids"; | ||
| async function beginConnect(options) { | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| liftHelperSync(() => validateIssuerUrl(apiUrl)); | ||
| const fetchImpl = options.fetchImpl ?? fetch; | ||
| const randomBytesImpl = options.randomBytesImpl; | ||
| const scopes = options.scopes ?? DEFAULT_SCOPES; | ||
| return liftHelper(async () => { | ||
| const metadata = await discover(apiUrl, { fetchImpl }); | ||
| const state = generateState({ randomBytesImpl }); | ||
| const { codeVerifier, codeChallenge } = await generatePkce({ randomBytesImpl }); | ||
| const clientId = await register(metadata, { | ||
| redirectUri: options.redirectUri, | ||
| clientName: options.clientName, | ||
| scopes | ||
| }, { fetchImpl }); | ||
| const authorizationUrl = buildAuthorizationUrl({ | ||
| authorizationEndpoint: metadata.authorization_endpoint, | ||
| clientId, | ||
| redirectUri: options.redirectUri, | ||
| state, | ||
| codeChallenge, | ||
| scopes | ||
| }); | ||
| return { | ||
| authorizationUrl, | ||
| state, | ||
| codeVerifier, | ||
| clientId, | ||
| tokenEndpoint: metadata.token_endpoint, | ||
| issuer: metadata.issuer | ||
| }; | ||
| }); | ||
| } | ||
| async function completeConnect(options) { | ||
| if (options.state !== options.expectedState) { | ||
| throw new AtlasMcpError(`OAuth state mismatch — possible CSRF. Got \`${options.state}\`, expected the value returned from beginConnect.`, "callback_state_mismatch"); | ||
| } | ||
| if (!options.code) { | ||
| throw new AtlasMcpError(`Authorization callback was missing the \`code\` parameter.`, "callback_missing_code"); | ||
| } | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| liftHelperSync(() => validateIssuerUrl(apiUrl)); | ||
| const fetchImpl = options.fetchImpl ?? fetch; | ||
| return liftHelper(async () => { | ||
| let tokenEndpoint = options.tokenEndpoint; | ||
| let issuer = options.issuer; | ||
| if (!tokenEndpoint || !issuer) { | ||
| const metadata = await discover(apiUrl, { fetchImpl }); | ||
| tokenEndpoint = tokenEndpoint ?? metadata.token_endpoint; | ||
| issuer = issuer ?? metadata.issuer; | ||
| } | ||
| validateTokenEndpoint(tokenEndpoint); | ||
| const tokenResponse = await exchangeCode({ | ||
| tokenEndpoint, | ||
| clientId: options.clientId, | ||
| redirectUri: options.redirectUri, | ||
| code: options.code, | ||
| codeVerifier: options.codeVerifier | ||
| }, { fetchImpl }); | ||
| const claims = decodeJwtPayload(tokenResponse.access_token); | ||
| enforceIssuer(claims, issuer); | ||
| const workspaceId = extractWorkspaceClaim(claims); | ||
| const workspaces = extractWorkspacesClaim(claims); | ||
| const expiresIn = typeof tokenResponse.expires_in === "number" && tokenResponse.expires_in > 0 ? tokenResponse.expires_in : 3600; | ||
| return { | ||
| accessToken: tokenResponse.access_token, | ||
| refreshToken: tokenResponse.refresh_token ?? null, | ||
| expiresAt: Date.now() + expiresIn * 1000, | ||
| workspaceId, | ||
| workspaces | ||
| }; | ||
| }); | ||
| } | ||
| var SERVER_NAME_DEFAULT = "atlas"; | ||
| function buildConfig(options) { | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| const url = `${apiUrl}/mcp/${encodeURIComponent(options.workspaceId)}/sse`; | ||
| const isMultiWorkspace = (options.workspaces?.length ?? 0) > 0; | ||
| if (isMultiWorkspace && !options.workspaces.includes(options.workspaceId)) { | ||
| throw new AtlasMcpError(`buildConfig: workspaceId \`${options.workspaceId}\` is not in the granted workspaces list — pass a default that is one of: ${options.workspaces.join(", ")}.`, "workspace_not_in_grant_list"); | ||
| } | ||
| const block = isMultiWorkspace ? { | ||
| url, | ||
| headers: { Authorization: `Bearer ${options.accessToken}` }, | ||
| env: { ATLAS_DEFAULT_WORKSPACE: options.workspaceId } | ||
| } : { | ||
| url, | ||
| headers: { Authorization: `Bearer ${options.accessToken}` } | ||
| }; | ||
| const name = options.serverName ?? SERVER_NAME_DEFAULT; | ||
| switch (options.client) { | ||
| case "generic": | ||
| return isMultiWorkspace ? { | ||
| kind: "bare", | ||
| url: block.url, | ||
| headers: block.headers, | ||
| env: block.env | ||
| } : { kind: "bare", url: block.url, headers: block.headers }; | ||
| case "claude-desktop": | ||
| case "cursor": | ||
| case "continue": | ||
| case "chatgpt": | ||
| return { kind: "wrapped", mcpServers: { [name]: block } }; | ||
| default: | ||
| return assertNever(options.client); | ||
| } | ||
| } | ||
| function assertNever(x) { | ||
| throw new Error(`Unhandled MCP client id: ${String(x)}`); | ||
| } | ||
| async function connectMachineToMachine(_options) { | ||
| throw new AtlasMcpError("client_credentials grant is not yet enabled on the Atlas OAuth provider. Use the authorization-code flow (beginConnect / completeConnect) for now.", "grant_not_supported"); | ||
| } | ||
| function stripTrailingSlashes2(s) { | ||
| let i = s.length; | ||
| while (i > 0 && s[i - 1] === "/") | ||
| i--; | ||
| return i === s.length ? s : s.slice(0, i); | ||
| } | ||
| function extractWorkspaceClaim(payload) { | ||
| const claim = payload[WORKSPACE_CLAIM]; | ||
| if (typeof claim !== "string" || claim.length === 0) { | ||
| throw new AtlasMcpError(`Access token is missing the ${WORKSPACE_CLAIM} claim — was the token issued for an MCP scope?`, "missing_workspace_claim"); | ||
| } | ||
| return claim; | ||
| } | ||
| function extractWorkspacesClaim(payload) { | ||
| const raw = payload[WORKSPACES_CLAIM]; | ||
| if (raw === undefined) | ||
| return []; | ||
| if (!Array.isArray(raw)) { | ||
| console.warn(`[@useatlas/sdk] ${WORKSPACES_CLAIM} claim was present but not an array (got ${typeof raw}); falling back to single-workspace.`); | ||
| return []; | ||
| } | ||
| const filtered = raw.filter((entry) => typeof entry === "string" && entry.length > 0); | ||
| if (filtered.length !== raw.length) { | ||
| const dropped = raw.length - filtered.length; | ||
| console.warn(`[@useatlas/sdk] ${WORKSPACES_CLAIM} claim contained ${dropped} non-string or empty entr${dropped === 1 ? "y" : "ies"}; using only the ${filtered.length} valid entr${filtered.length === 1 ? "y" : "ies"}.`); | ||
| } | ||
| return filtered; | ||
| } | ||
| // src/client.ts | ||
@@ -492,2 +913,24 @@ import { isChatErrorCode, isRetryableError } from "@useatlas/types"; | ||
| return res; | ||
| }, | ||
| mcp: { | ||
| async beginConnect(opts) { | ||
| return beginConnect({ apiUrl: base, ...opts }); | ||
| }, | ||
| async completeConnect(opts) { | ||
| return completeConnect({ apiUrl: base, ...opts }); | ||
| }, | ||
| buildConfig(opts) { | ||
| return buildConfig({ apiUrl: base, ...opts }); | ||
| }, | ||
| async connectMachineToMachine(opts) { | ||
| return connectMachineToMachine({ apiUrl: base, ...opts }); | ||
| }, | ||
| async listAgents() { | ||
| const res = await get("/api/v1/me/oauth-clients"); | ||
| return unwrap(res); | ||
| }, | ||
| async revokeAgent(clientId) { | ||
| const res = await post(`/api/v1/me/oauth-clients/${encodeURIComponent(clientId)}/revoke`, {}); | ||
| return unwrap(res); | ||
| } | ||
| } | ||
@@ -494,0 +937,0 @@ }; |
+1
-0
@@ -18,1 +18,2 @@ /** | ||
| export { fetchStarterPrompts, type FetchStarterPromptsConfig, type FetchStarterPromptsCredentials, } from "./fetch-starter-prompts"; | ||
| export { AtlasMcpError, beginConnect, buildConfig, completeConnect, connectMachineToMachine, type AtlasMcpErrorCode, type BeginConnectOptions, type BeginConnectResult, type BuildConfigOptions, type CompleteConnectOptions, type CompleteConnectResult, type ConnectMachineToMachineOptions, type ConnectMachineToMachineResult, type ListAgentsResponse, type McpBareConfig, type McpClientConfig, type McpClientId, type McpHttpServer, type McpWrappedConfig, type RevokeAgentResponse, } from "./mcp"; |
+448
-0
@@ -0,1 +1,422 @@ | ||
| // ../oauth-helper/src/errors.ts | ||
| class OAuthHelperError extends Error { | ||
| code; | ||
| constructor(message, code, options) { | ||
| super(message, options); | ||
| this.name = "OAuthHelperError"; | ||
| this.code = code; | ||
| } | ||
| } | ||
| // ../oauth-helper/src/_internal/http.ts | ||
| var FETCH_TIMEOUT_MS = 30 * 1000; | ||
| async function describeOAuthErrorBody(res) { | ||
| let raw; | ||
| 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); | ||
| const parts = []; | ||
| 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 {} | ||
| return raw.length > 1024 ? `${raw.slice(0, 1024)}…` : raw; | ||
| } | ||
| // ../oauth-helper/src/discover.ts | ||
| function stripTrailingSlashes(s) { | ||
| let i = s.length; | ||
| while (i > 0 && s[i - 1] === "/") | ||
| i--; | ||
| return i === s.length ? s : s.slice(0, i); | ||
| } | ||
| var DISCOVERY_PATH = "/.well-known/oauth-authorization-server/api/auth"; | ||
| async function discover(apiUrl, options) { | ||
| const fetchImpl = options?.fetchImpl ?? fetch; | ||
| const url = `${stripTrailingSlashes(apiUrl)}${DISCOVERY_PATH}`; | ||
| let res; | ||
| 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 }); | ||
| }); | ||
| 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 | ||
| }; | ||
| } | ||
| // ../oauth-helper/src/register.ts | ||
| async function register(metadata, params, options) { | ||
| 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; | ||
| 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 }); | ||
| }); | ||
| 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; | ||
| } | ||
| // ../oauth-helper/src/_internal/encoding.ts | ||
| function defaultRandomBytes(length) { | ||
| const buf = new Uint8Array(length); | ||
| crypto.getRandomValues(buf); | ||
| return buf; | ||
| } | ||
| function encodeBase64Url(bytes) { | ||
| let bin = ""; | ||
| for (let i = 0;i < bytes.length; i++) | ||
| bin += String.fromCharCode(bytes[i]); | ||
| return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | ||
| } | ||
| // ../oauth-helper/src/pkce.ts | ||
| var VERIFIER_BYTES = 32; | ||
| var STATE_BYTES = 32; | ||
| async function generatePkce(options) { | ||
| const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes; | ||
| const codeVerifier = encodeBase64Url(randomBytes(VERIFIER_BYTES)); | ||
| const codeChallenge = await pkceChallenge(codeVerifier); | ||
| return { codeVerifier, codeChallenge, method: "S256" }; | ||
| } | ||
| function generateState(options) { | ||
| const randomBytes = options?.randomBytesImpl ?? defaultRandomBytes; | ||
| return encodeBase64Url(randomBytes(STATE_BYTES)); | ||
| } | ||
| async function pkceChallenge(verifier) { | ||
| const data = new TextEncoder().encode(verifier); | ||
| const digest = await crypto.subtle.digest("SHA-256", data); | ||
| return encodeBase64Url(new Uint8Array(digest)); | ||
| } | ||
| // ../oauth-helper/src/authorize-url.ts | ||
| function buildAuthorizationUrl(params) { | ||
| 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()}`; | ||
| } | ||
| // ../oauth-helper/src/validate.ts | ||
| function validateHttpsUrl(input, code, label) { | ||
| let parsed; | ||
| 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); | ||
| } | ||
| function validateIssuerUrl(apiUrl) { | ||
| validateHttpsUrl(apiUrl, "invalid_api_url", "apiUrl"); | ||
| } | ||
| function validateTokenEndpoint(tokenEndpoint) { | ||
| validateHttpsUrl(tokenEndpoint, "invalid_token_endpoint", "tokenEndpoint"); | ||
| } | ||
| // ../oauth-helper/src/exchange.ts | ||
| async function exchangeCode(params, options) { | ||
| 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 | ||
| }); | ||
| let res; | ||
| 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 }); | ||
| }); | ||
| 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 | ||
| }; | ||
| } | ||
| // ../oauth-helper/src/jwt.ts | ||
| function decodeJwtPayload(jwtToken) { | ||
| 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); | ||
| } catch (err) { | ||
| throw new OAuthHelperError(`Could not decode JWT payload: ${err instanceof Error ? err.message : String(err)}`, "malformed_jwt", { cause: err }); | ||
| } | ||
| } | ||
| function enforceIssuer(payload, expectedIssuer) { | ||
| 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"); | ||
| } | ||
| } | ||
| // src/mcp.ts | ||
| class AtlasMcpError extends Error { | ||
| code; | ||
| constructor(message, code, options) { | ||
| super(message, options); | ||
| this.name = "AtlasMcpError"; | ||
| this.code = code; | ||
| } | ||
| } | ||
| async function liftHelper(fn) { | ||
| try { | ||
| return await fn(); | ||
| } catch (err) { | ||
| if (err instanceof OAuthHelperError) { | ||
| throw new AtlasMcpError(err.message, err.code, { cause: err }); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| function liftHelperSync(fn) { | ||
| try { | ||
| return fn(); | ||
| } catch (err) { | ||
| if (err instanceof OAuthHelperError) { | ||
| throw new AtlasMcpError(err.message, err.code, { cause: err }); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| var DEFAULT_SCOPES = ["mcp:read", "offline_access"]; | ||
| var WORKSPACE_CLAIM = "https://atlas.useatlas.dev/workspace_id"; | ||
| var WORKSPACES_CLAIM = "https://atlas.useatlas.dev/workspace_ids"; | ||
| async function beginConnect(options) { | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| liftHelperSync(() => validateIssuerUrl(apiUrl)); | ||
| const fetchImpl = options.fetchImpl ?? fetch; | ||
| const randomBytesImpl = options.randomBytesImpl; | ||
| const scopes = options.scopes ?? DEFAULT_SCOPES; | ||
| return liftHelper(async () => { | ||
| const metadata = await discover(apiUrl, { fetchImpl }); | ||
| const state = generateState({ randomBytesImpl }); | ||
| const { codeVerifier, codeChallenge } = await generatePkce({ randomBytesImpl }); | ||
| const clientId = await register(metadata, { | ||
| redirectUri: options.redirectUri, | ||
| clientName: options.clientName, | ||
| scopes | ||
| }, { fetchImpl }); | ||
| const authorizationUrl = buildAuthorizationUrl({ | ||
| authorizationEndpoint: metadata.authorization_endpoint, | ||
| clientId, | ||
| redirectUri: options.redirectUri, | ||
| state, | ||
| codeChallenge, | ||
| scopes | ||
| }); | ||
| return { | ||
| authorizationUrl, | ||
| state, | ||
| codeVerifier, | ||
| clientId, | ||
| tokenEndpoint: metadata.token_endpoint, | ||
| issuer: metadata.issuer | ||
| }; | ||
| }); | ||
| } | ||
| async function completeConnect(options) { | ||
| if (options.state !== options.expectedState) { | ||
| throw new AtlasMcpError(`OAuth state mismatch — possible CSRF. Got \`${options.state}\`, expected the value returned from beginConnect.`, "callback_state_mismatch"); | ||
| } | ||
| if (!options.code) { | ||
| throw new AtlasMcpError(`Authorization callback was missing the \`code\` parameter.`, "callback_missing_code"); | ||
| } | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| liftHelperSync(() => validateIssuerUrl(apiUrl)); | ||
| const fetchImpl = options.fetchImpl ?? fetch; | ||
| return liftHelper(async () => { | ||
| let tokenEndpoint = options.tokenEndpoint; | ||
| let issuer = options.issuer; | ||
| if (!tokenEndpoint || !issuer) { | ||
| const metadata = await discover(apiUrl, { fetchImpl }); | ||
| tokenEndpoint = tokenEndpoint ?? metadata.token_endpoint; | ||
| issuer = issuer ?? metadata.issuer; | ||
| } | ||
| validateTokenEndpoint(tokenEndpoint); | ||
| const tokenResponse = await exchangeCode({ | ||
| tokenEndpoint, | ||
| clientId: options.clientId, | ||
| redirectUri: options.redirectUri, | ||
| code: options.code, | ||
| codeVerifier: options.codeVerifier | ||
| }, { fetchImpl }); | ||
| const claims = decodeJwtPayload(tokenResponse.access_token); | ||
| enforceIssuer(claims, issuer); | ||
| const workspaceId = extractWorkspaceClaim(claims); | ||
| const workspaces = extractWorkspacesClaim(claims); | ||
| const expiresIn = typeof tokenResponse.expires_in === "number" && tokenResponse.expires_in > 0 ? tokenResponse.expires_in : 3600; | ||
| return { | ||
| accessToken: tokenResponse.access_token, | ||
| refreshToken: tokenResponse.refresh_token ?? null, | ||
| expiresAt: Date.now() + expiresIn * 1000, | ||
| workspaceId, | ||
| workspaces | ||
| }; | ||
| }); | ||
| } | ||
| var SERVER_NAME_DEFAULT = "atlas"; | ||
| function buildConfig(options) { | ||
| const apiUrl = stripTrailingSlashes2(options.apiUrl); | ||
| const url = `${apiUrl}/mcp/${encodeURIComponent(options.workspaceId)}/sse`; | ||
| const isMultiWorkspace = (options.workspaces?.length ?? 0) > 0; | ||
| if (isMultiWorkspace && !options.workspaces.includes(options.workspaceId)) { | ||
| throw new AtlasMcpError(`buildConfig: workspaceId \`${options.workspaceId}\` is not in the granted workspaces list — pass a default that is one of: ${options.workspaces.join(", ")}.`, "workspace_not_in_grant_list"); | ||
| } | ||
| const block = isMultiWorkspace ? { | ||
| url, | ||
| headers: { Authorization: `Bearer ${options.accessToken}` }, | ||
| env: { ATLAS_DEFAULT_WORKSPACE: options.workspaceId } | ||
| } : { | ||
| url, | ||
| headers: { Authorization: `Bearer ${options.accessToken}` } | ||
| }; | ||
| const name = options.serverName ?? SERVER_NAME_DEFAULT; | ||
| switch (options.client) { | ||
| case "generic": | ||
| return isMultiWorkspace ? { | ||
| kind: "bare", | ||
| url: block.url, | ||
| headers: block.headers, | ||
| env: block.env | ||
| } : { kind: "bare", url: block.url, headers: block.headers }; | ||
| case "claude-desktop": | ||
| case "cursor": | ||
| case "continue": | ||
| case "chatgpt": | ||
| return { kind: "wrapped", mcpServers: { [name]: block } }; | ||
| default: | ||
| return assertNever(options.client); | ||
| } | ||
| } | ||
| function assertNever(x) { | ||
| throw new Error(`Unhandled MCP client id: ${String(x)}`); | ||
| } | ||
| async function connectMachineToMachine(_options) { | ||
| throw new AtlasMcpError("client_credentials grant is not yet enabled on the Atlas OAuth provider. Use the authorization-code flow (beginConnect / completeConnect) for now.", "grant_not_supported"); | ||
| } | ||
| function stripTrailingSlashes2(s) { | ||
| let i = s.length; | ||
| while (i > 0 && s[i - 1] === "/") | ||
| i--; | ||
| return i === s.length ? s : s.slice(0, i); | ||
| } | ||
| function extractWorkspaceClaim(payload) { | ||
| const claim = payload[WORKSPACE_CLAIM]; | ||
| if (typeof claim !== "string" || claim.length === 0) { | ||
| throw new AtlasMcpError(`Access token is missing the ${WORKSPACE_CLAIM} claim — was the token issued for an MCP scope?`, "missing_workspace_claim"); | ||
| } | ||
| return claim; | ||
| } | ||
| function extractWorkspacesClaim(payload) { | ||
| const raw = payload[WORKSPACES_CLAIM]; | ||
| if (raw === undefined) | ||
| return []; | ||
| if (!Array.isArray(raw)) { | ||
| console.warn(`[@useatlas/sdk] ${WORKSPACES_CLAIM} claim was present but not an array (got ${typeof raw}); falling back to single-workspace.`); | ||
| return []; | ||
| } | ||
| const filtered = raw.filter((entry) => typeof entry === "string" && entry.length > 0); | ||
| if (filtered.length !== raw.length) { | ||
| const dropped = raw.length - filtered.length; | ||
| console.warn(`[@useatlas/sdk] ${WORKSPACES_CLAIM} claim contained ${dropped} non-string or empty entr${dropped === 1 ? "y" : "ies"}; using only the ${filtered.length} valid entr${filtered.length === 1 ? "y" : "ies"}.`); | ||
| } | ||
| return filtered; | ||
| } | ||
| // src/client.ts | ||
@@ -492,2 +913,24 @@ import { isChatErrorCode, isRetryableError } from "@useatlas/types"; | ||
| return res; | ||
| }, | ||
| mcp: { | ||
| async beginConnect(opts) { | ||
| return beginConnect({ apiUrl: base, ...opts }); | ||
| }, | ||
| async completeConnect(opts) { | ||
| return completeConnect({ apiUrl: base, ...opts }); | ||
| }, | ||
| buildConfig(opts) { | ||
| return buildConfig({ apiUrl: base, ...opts }); | ||
| }, | ||
| async connectMachineToMachine(opts) { | ||
| return connectMachineToMachine({ apiUrl: base, ...opts }); | ||
| }, | ||
| async listAgents() { | ||
| const res = await get("/api/v1/me/oauth-clients"); | ||
| return unwrap(res); | ||
| }, | ||
| async revokeAgent(clientId) { | ||
| const res = await post(`/api/v1/me/oauth-clients/${encodeURIComponent(clientId)}/revoke`, {}); | ||
| return unwrap(res); | ||
| } | ||
| } | ||
@@ -552,3 +995,8 @@ }; | ||
| createAtlasClient, | ||
| connectMachineToMachine, | ||
| completeConnect, | ||
| buildConfig, | ||
| beginConnect, | ||
| AtlasMcpError, | ||
| AtlasError | ||
| }; |
+12
-4
| { | ||
| "name": "@useatlas/sdk", | ||
| "version": "0.0.12", | ||
| "version": "0.0.14", | ||
| "description": "TypeScript SDK for the Atlas text-to-SQL agent API", | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "rm -rf dist && bun build src/index.ts src/client.ts --outdir dist --target node --packages external && bun x tsc -p tsconfig.build.json", | ||
| "build": "rm -rf dist && bun build src/index.ts src/client.ts src/mcp.ts --outdir dist --target node --external '@useatlas/types' && bun x tsc -p tsconfig.build.json", | ||
| "prepublishOnly": "bun run build", | ||
| "test": "bun test src/__tests__/client.test.ts src/__tests__/stream.test.ts src/__tests__/integration.test.ts src/__tests__/stream-integration.test.ts src/__tests__/fetch-starter-prompts.test.ts" | ||
| "test": "bun test src/__tests__/client.test.ts src/__tests__/stream.test.ts src/__tests__/integration.test.ts src/__tests__/stream-integration.test.ts src/__tests__/fetch-starter-prompts.test.ts src/__tests__/mcp.test.ts" | ||
| }, | ||
@@ -21,2 +21,7 @@ "exports": { | ||
| "default": "./dist/client.js" | ||
| }, | ||
| "./mcp": { | ||
| "types": "./dist/mcp.d.ts", | ||
| "import": "./dist/mcp.js", | ||
| "default": "./dist/mcp.js" | ||
| } | ||
@@ -48,4 +53,7 @@ }, | ||
| "dependencies": { | ||
| "@useatlas/types": "^0.0.16" | ||
| "@useatlas/types": "^0.0.25" | ||
| }, | ||
| "devDependencies": { | ||
| "@atlas/oauth-helper": "workspace:*" | ||
| } | ||
| } |
+39
-0
@@ -206,2 +206,41 @@ # @useatlas/sdk | ||
| ### Context Warnings (Degraded Answer Signal) | ||
| The chat route emits `data-context-warning` frames whenever the agent ran | ||
| past a non-fatal degradation — the org semantic layer or learned-patterns | ||
| lookup failed, or the workspace is approaching its plan budget — and the | ||
| run was allowed to proceed against reduced context. These are not stream | ||
| events from `streamQuery()`; they arrive on the AI-SDK UI Message Stream | ||
| channel that `chat()` returns. If you are routing a raw `Response` from | ||
| `atlas.chat()` through your own SSE consumer, parse each | ||
| `data-context-warning` frame body with `parseContextWarning` from | ||
| `@useatlas/types`: | ||
| ```typescript | ||
| import { parseContextWarning, type ChatContextWarning } from "@useatlas/types"; | ||
| // Inside your SSE frame handler: | ||
| if (frame.type === "data-context-warning") { | ||
| const warning: ChatContextWarning | null = parseContextWarning(frame.data); | ||
| if (warning) { | ||
| console.warn(`[${warning.code}]`, warning.title, warning.detail ?? ""); | ||
| // Surface a per-message warning banner — the answer is still usable | ||
| // but was generated against reduced context. Do NOT treat it as a | ||
| // hard error. | ||
| } else { | ||
| // Log on null so a future server-side wire-shape change (unknown | ||
| // code, missing title, wrong severity literal) is observable in dev | ||
| // rather than silently dropped. In production, gate this behind a | ||
| // one-shot ref so a runaway stream of malformed frames doesn't | ||
| // flood the console — see the in-tree `useContextWarnings` hook for | ||
| // an example dedup pattern. | ||
| console.warn("[atlas-sdk] dropped malformed data-context-warning frame", frame.data); | ||
| } | ||
| } | ||
| ``` | ||
| The frame's `severity: "warning"` discriminator separates these from | ||
| `data-error` frames, so a single SSE consumer can route both without | ||
| misclassifying a degraded answer as a failure. | ||
| ## Error Handling | ||
@@ -208,0 +247,0 @@ |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
134788
100.27%10
25%3198
102.92%298
15.06%1
Infinity%1
Infinity%29
163.64%+ Added
- Removed
Updated