Redis

/* * * Methods copied from wrangler, same names used where possible * * */ import fs, { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import xdgAppPaths from 'xdg-app-paths' import TOML from '@iarna/toml' import assert from 'node:assert' import { mcpCloudflareVersion } from './helpers' import { fetch, Headers, Response, RequestInit, HeadersInit } from 'undici' export function isDirectory(configPath: string) { try { return fs.statSync(configPath).isDirectory() } catch (error) { // ignore error return false } } export function getGlobalWranglerConfigPath() { const configDir = xdgAppPaths('.wrangler').config() // New XDG compliant config path const legacyConfigDir = path.join(os.homedir(), '.wrangler') // Legacy config in user's home directory // Check for the .wrangler directory in root if it is not there then use the XDG compliant path. if (isDirectory(legacyConfigDir)) { return legacyConfigDir } else { return configDir } } const TOML_ERROR_NAME = 'TomlError' const TOML_ERROR_SUFFIX = ' at row ' type TomlError = Error & { line: number col: number } export function parseTOML(input: string, file?: string): TOML.JsonMap | never { try { // Normalize CRLF to LF to avoid hitting https://github.com/iarna/iarna-toml/issues/33. const normalizedInput = input.replace(/\r\n/g, '\n') return TOML.parse(normalizedInput) } catch (err) { const { name, message, line, col } = err as TomlError if (name !== TOML_ERROR_NAME) { throw err } const text = message.substring(0, message.lastIndexOf(TOML_ERROR_SUFFIX)) const lineText = input.split('\n')[line] const location = { lineText, line: line + 1, column: col - 1, file, fileText: input, } throw new Error(`Error parsing TOML: ${text} at ${JSON.stringify(location)}`) } } const JSON_ERROR_SUFFIX = ' in JSON at position ' /** * A wrapper around `JSON.parse` that throws a `ParseError`. */ export function parseJSON<T>(input: string, file?: string): T { try { return JSON.parse(input) } catch (err) { const { message } = err as Error const index = message.lastIndexOf(JSON_ERROR_SUFFIX) if (index < 0) { throw err } const text = message.substring(0, index) const position = parseInt(message.substring(index + JSON_ERROR_SUFFIX.length)) const location = { file, fileText: input, position } throw new Error(`Error parsing JSON: ${text} at ${JSON.stringify(location)}`) } } /** * The tokens related to authentication. */ export interface AuthTokens { accessToken?: AccessToken refreshToken?: RefreshToken scopes?: Scope[] } interface RefreshToken { value: string } interface AccessToken { value: string expiry: string } type Scope = string interface State extends AuthTokens { authorizationCode?: string codeChallenge?: string codeVerifier?: string hasAuthCodeBeenExchangedForAccessToken?: boolean stateQueryParam?: string scopes?: Scope[] } export let LocalState: State = {} function getAuthConfigFilePath() { const configDir = getGlobalWranglerConfigPath() return path.join(configDir, 'config', 'default.toml') } export function getAuthTokens() { const configPath = getAuthConfigFilePath() if (!fs.existsSync(configPath)) throw new Error(`No config file found at ${configPath}`) const toml = parseTOML(readFileSync(configPath, 'utf8')) as { oauth_token?: string refresh_token?: string expiration_time?: string scopes?: string[] } // console.log('WE GOT IT') // console.log(toml) const { oauth_token, refresh_token, expiration_time, scopes } = toml LocalState = { accessToken: { value: oauth_token!, // If there is no `expiration_time` field then set it to an old date, to cause it to expire immediately. expiry: expiration_time ?? '2000-01-01:00:00:00+00:00', }, refreshToken: { value: refresh_token ?? '' }, scopes: scopes ?? [], } } export function isAccessTokenExpired(): boolean { const { accessToken } = LocalState return Boolean(accessToken && new Date() >= new Date(accessToken.expiry)) } export async function refreshToken(): Promise<boolean> { // refresh try { await exchangeRefreshTokenForAccessToken() writeAuthConfigFile({ oauth_token: LocalState.accessToken?.value, expiration_time: LocalState.accessToken?.expiry, refresh_token: LocalState.refreshToken?.value, scopes: LocalState.scopes, }) return true } catch (err) { return false } } export interface UserAuthConfig { oauth_token?: string refresh_token?: string expiration_time?: string scopes?: string[] /** @deprecated - this field was only provided by the deprecated v1 `wrangler config` command. */ api_token?: string } /** * Writes a a wrangler config file (auth credentials) to disk, * and updates the user auth state with the new credentials. */ export function writeAuthConfigFile(config: UserAuthConfig) { const configPath = getAuthConfigFilePath() mkdirSync(path.dirname(configPath), { recursive: true, }) writeFileSync(path.join(configPath), TOML.stringify(config as TOML.JsonMap), { encoding: 'utf-8', }) } const WRANGLER_CLIENT_ID = '54d11594-84e4-41aa-b438-e81b8fa78ee7' async function fetchAuthToken(body: URLSearchParams) { const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', } return await fetch('https://dash.cloudflare.com/oauth2/token', { method: 'POST', body: body.toString(), headers, }) } async function exchangeRefreshTokenForAccessToken() { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: LocalState.refreshToken?.value ?? '', client_id: WRANGLER_CLIENT_ID, }) const response = await fetchAuthToken(params) if (response.status >= 400) { let tokenExchangeResErr = undefined try { tokenExchangeResErr = await response.text() tokenExchangeResErr = JSON.parse(tokenExchangeResErr) } catch (e) { // If it can't parse to JSON ignore the error } if (tokenExchangeResErr !== undefined) { // We will throw the parsed error if it parsed correctly, otherwise we throw an unknown error. throw typeof tokenExchangeResErr === 'string' ? new Error(tokenExchangeResErr) : tokenExchangeResErr } else { throw new Error('Failed to parse Error from exchangeRefreshTokenForAccessToken') } } else { const json = (await getJSONFromResponse(response)) as TokenResponse if ('error' in json) { throw json.error } const { access_token, expires_in, refresh_token, scope } = json let scopes: Scope[] = [] const accessToken: AccessToken = { value: access_token, expiry: new Date(Date.now() + expires_in * 1000).toISOString(), } LocalState.accessToken = accessToken if (refresh_token) { LocalState.refreshToken = { value: refresh_token, } } if (scope) { // Multiple scopes are passed and delimited by spaces, // despite using the singular name "scope". scopes = scope.split(' ') as Scope[] LocalState.scopes = scopes } } } type TokenResponse = | { access_token: string expires_in: number refresh_token: string scope: string } | { error: string } async function getJSONFromResponse(response: Response) { const text = await response.text() try { return JSON.parse(text) } catch (e) { // Sometime we get an error response where the body is HTML if (text.match(/<!DOCTYPE html>/)) { console.error( 'The body of the response was HTML rather than JSON. Check the debug logs to see the full body of the response.', ) if (text.match(/challenge-platform/)) { console.error( `It looks like you might have hit a bot challenge page. This may be transient but if not, please contact Cloudflare to find out what can be done. When you contact Cloudflare, please provide your Ray ID: ${response.headers.get('cf-ray')}`, ) } } console.debug('Full body of response\n\n', text) throw new Error(`Invalid JSON in response: status: ${response.status} ${response.statusText}`, { cause: e }) } } export async function fetchInternal<ResponseType>( resource: string, init: RequestInit = {}, queryParams?: URLSearchParams, abortSignal?: AbortSignal, ): Promise<ResponseType> { const method = init.method ?? 'GET' const response = await performApiFetch(resource, init, queryParams, abortSignal) const jsonText = await response.text() // logger.debug( // "-- START CF API RESPONSE:", // response.statusText, // response.status // ); const logHeaders = cloneHeaders(response.headers) delete logHeaders['Authorization'] // logger.debugWithSanitization("HEADERS:", JSON.stringify(logHeaders, null, 2)); // logger.debugWithSanitization("RESPONSE:", jsonText); // logger.debug("-- END CF API RESPONSE"); // HTTP 204 and HTTP 205 responses do not return a body. We need to special-case this // as otherwise parseJSON will throw an error back to the user. if (!jsonText && (response.status === 204 || response.status === 205)) { const emptyBody = `{"result": {}, "success": true, "errors": [], "messages": []}` return parseJSON<ResponseType>(emptyBody) } try { return parseJSON<ResponseType>(jsonText) } catch (err) { throw new Error( JSON.stringify({ text: 'Received a malformed response from the API', notes: [ { text: truncate(jsonText, 100), }, { text: `${method} ${resource} -> ${response.status} ${response.statusText}`, }, ], status: response.status, }), ) } } /* * performApiFetch does everything required to make a CF API request, * but doesn't parse the response as JSON. For normal V4 API responses, * use `fetchInternal` * */ export async function performApiFetch( resource: string, init: RequestInit = {}, queryParams?: URLSearchParams, abortSignal?: AbortSignal, ) { const method = init.method ?? 'GET' assert(resource.startsWith('/'), `CF API fetch - resource path must start with a "/" but got "${resource}"`) // await requireLoggedIn(); const apiToken = requireApiToken() const headers = cloneHeaders(init.headers) addAuthorizationHeaderIfUnspecified(headers, apiToken) addUserAgent(headers) const queryString = queryParams ? `?${queryParams.toString()}` : '' // logger.debug( // `-- START CF API REQUEST: ${method} ${getCloudflareApiBaseUrl()}${resource}${queryString}` // ); const logHeaders = cloneHeaders(headers) delete logHeaders['Authorization'] // logger.debugWithSanitization("HEADERS:", JSON.stringify(logHeaders, null, 2)); // logger.debugWithSanitization("INIT:", JSON.stringify({ ...init }, null, 2)); // if (init.body instanceof FormData) { // logger.debugWithSanitization( // "BODY:", // await new Response(init.body).text(), // null, // 2 // ); // } // logger.debug("-- END CF API REQUEST"); return await fetch(`${getCloudflareApiBaseUrl()}${resource}${queryString}`, { method, ...init, headers, signal: abortSignal, }) } export type ApiCredentials = | { apiToken: string } | { authKey: string authEmail: string } export function requireApiToken(): ApiCredentials { const credentials = LocalState.accessToken?.value if (!credentials) { throw new Error('No API token found.') } return { apiToken: credentials } } function cloneHeaders(headers: HeadersInit | undefined): Record<string, string> { return headers instanceof Headers ? Object.fromEntries(headers.entries()) : Array.isArray(headers) ? Object.fromEntries(headers) : ({ ...headers } as Record<string, string>) } function addAuthorizationHeaderIfUnspecified(headers: Record<string, string>, auth: ApiCredentials): void { if (!('Authorization' in headers)) { if ('apiToken' in auth) { headers['Authorization'] = `Bearer ${auth.apiToken}` } else { headers['X-Auth-Key'] = auth.authKey headers['X-Auth-Email'] = auth.authEmail } } } function addUserAgent(headers: Record<string, string>): void { headers['User-Agent'] = `mcp-cloudflare/${mcpCloudflareVersion}` } export const getCloudflareApiBaseUrl = () => 'https://api.cloudflare.com/client/v4' function truncate(text: string, maxLength: number): string { const { length } = text if (length <= maxLength) { return text } return `${text.substring(0, maxLength)}... (length = ${length})` } export interface FetchError { code: number message: string error_chain?: FetchError[] } export interface FetchResult<ResponseType = unknown> { success: boolean result: ResponseType errors: FetchError[] messages?: string[] result_info?: unknown } export type AccountInfo = { name: string; id: string }