Sign In

@zenrows/mcp

Package Overview
Dependencies
Maintainers
2
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@zenrows/mcp - npm Package Compare versions

Comparing version
2.1.2
to
2.2.0
+59
dist/auth/ensure-key.d.ts
export declare const ENV_KEY = "ZENROWS_API_KEY";
export declare const AUTO_SIGNUP_ENV = "ZENROWS_AUTO_SIGNUP";
export declare const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
export declare const DISCOVERY_URL_ENV = "ZENROWS_DISCOVERY_URL";
export declare const AGENT_SIGNUP_API_URL = "https://app.zenrows.com/api/agent/signup";
export declare const WELL_KNOWN_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource";
export interface AgentAccount {
accountId: string;
unclaimed: boolean;
claimUrl: string;
createdAt: string;
}
export interface SignupResponse {
apiKey: string;
accountId: string;
claimUrl: string;
}
export declare class AuthError extends Error {
code: string;
constructor(code: string, message: string);
}
/** Override home for tests / custom installs (absolute path to the `.zenrows` dir parent, or the dir itself if it ends with `.zenrows`). */
export declare const ZENROWS_HOME_ENV = "ZENROWS_HOME";
/** Test-only: clear discovery cache between cases. */
export declare function _resetDiscoveryCache(): void;
export declare function getZenrowsDir(): string;
export declare function readStoredApiKey(): string | undefined;
export declare function readAccount(): AgentAccount | null;
export declare function saveApiKey(apiKey: string): void;
export declare function writeAccount(acct: AgentAccount): void;
/** Resolve key: env → ~/.zenrows/secrets.json. Does not signup. */
export declare function resolveApiKey(): {
key?: string;
source: "env" | "secrets-file" | "none";
};
export declare function autoSignupEnabled(): boolean;
export declare function discoverSignupUrl(opts?: {
fetchImpl?: typeof fetch;
}): Promise<string | null>;
export declare function signupCandidates(opts?: {
fetchImpl?: typeof fetch;
}): Promise<string[]>;
export declare function signupAgent(opts?: {
url?: string;
fetchImpl?: typeof fetch;
userAgent?: string;
}): Promise<SignupResponse>;
/**
* Ensure an API key is available for stdio.
* Returns the key and optional claim metadata when a new account was provisioned.
*/
export declare function ensureApiKey(opts?: {
fetchImpl?: typeof fetch;
userAgent?: string;
onProvision?: (a: AgentAccount) => void;
}): Promise<{
apiKey: string;
provisioned?: AgentAccount;
}>;
/**
* Resolve-or-provision the Zenrows API key for stdio MCP.
*
* Persistence lives under ~/.zenrows/ (secrets.json + account.json, mode 0600).
* Remote HTTP transport must NOT call this — Bearer/OAuth only.
*/
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export const ENV_KEY = "ZENROWS_API_KEY";
export const AUTO_SIGNUP_ENV = "ZENROWS_AUTO_SIGNUP";
export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
export const DISCOVERY_URL_ENV = "ZENROWS_DISCOVERY_URL";
export const AGENT_SIGNUP_API_URL = "https://app.zenrows.com/api/agent/signup";
export const WELL_KNOWN_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource";
export class AuthError extends Error {
code;
constructor(code, message) {
super(message);
this.name = "AuthError";
this.code = code;
}
}
/** Override home for tests / custom installs (absolute path to the `.zenrows` dir parent, or the dir itself if it ends with `.zenrows`). */
export const ZENROWS_HOME_ENV = "ZENROWS_HOME";
function zenrowsDir() {
const override = process.env[ZENROWS_HOME_ENV]?.trim();
if (override) {
return override.endsWith(".zenrows") ? override : join(override, ".zenrows");
}
return join(homedir(), ".zenrows");
}
/** Test-only: clear discovery cache between cases. */
export function _resetDiscoveryCache() {
discoveredSignupUrl = undefined;
}
export function getZenrowsDir() {
return zenrowsDir();
}
function secretsPath() {
return join(zenrowsDir(), "secrets.json");
}
function accountPath() {
return join(zenrowsDir(), "account.json");
}
function ensureDir() {
const dir = zenrowsDir();
if (!existsSync(dir))
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
function readJsonFile(file) {
if (!existsSync(file))
return null;
try {
return JSON.parse(readFileSync(file, "utf8"));
}
catch {
return null;
}
}
function writeJsonSecure(file, data) {
ensureDir();
writeFileSync(file, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
try {
chmodSync(file, 0o600);
}
catch {
// best-effort on platforms without POSIX permissions
}
}
export function readStoredApiKey() {
const stored = readJsonFile(secretsPath());
const key = stored?.apiKey?.trim();
return key || undefined;
}
export function readAccount() {
return readJsonFile(accountPath());
}
export function saveApiKey(apiKey) {
writeJsonSecure(secretsPath(), { apiKey: apiKey.trim() });
}
export function writeAccount(acct) {
writeJsonSecure(accountPath(), acct);
}
/** Resolve key: env → ~/.zenrows/secrets.json. Does not signup. */
export function resolveApiKey() {
const env = process.env[ENV_KEY]?.trim();
if (env)
return { key: env, source: "env" };
const stored = readStoredApiKey();
if (stored)
return { key: stored, source: "secrets-file" };
return { source: "none" };
}
export function autoSignupEnabled() {
return process.env[AUTO_SIGNUP_ENV] !== "false";
}
let discoveredSignupUrl;
export async function discoverSignupUrl(opts = {}) {
try {
const base = process.env[DISCOVERY_URL_ENV]?.trim() || new URL(AGENT_SIGNUP_API_URL).origin;
const url = base.replace(/\/$/, "") + WELL_KNOWN_PROTECTED_RESOURCE;
const doFetch = opts.fetchImpl ?? fetch;
const res = await doFetch(url, {
method: "GET",
headers: { Accept: "application/json", "User-Agent": "zenrows/mcp" },
});
if (!res.ok)
return null;
const json = (await res.json());
const endpoint = json?.agent_auth?.signup_endpoint;
if (typeof endpoint === "string" && endpoint.trim())
return endpoint.trim();
return null;
}
catch {
return null;
}
}
export async function signupCandidates(opts = {}) {
const fromEnv = process.env[SIGNUP_URL_ENV];
if (fromEnv && fromEnv.trim())
return [fromEnv.trim()];
if (discoveredSignupUrl === undefined) {
discoveredSignupUrl = await discoverSignupUrl(opts);
}
const urls = [];
if (discoveredSignupUrl && discoveredSignupUrl !== AGENT_SIGNUP_API_URL) {
urls.push(discoveredSignupUrl);
}
urls.push(AGENT_SIGNUP_API_URL);
return urls;
}
export async function signupAgent(opts = {}) {
const urls = opts.url ? [opts.url] : await signupCandidates({ fetchImpl: opts.fetchImpl });
const doFetch = opts.fetchImpl ?? fetch;
const headers = {
"content-type": "application/json",
"User-Agent": opts.userAgent ?? "zenrows/mcp",
"X-ZR-Source": "mcp",
};
let lastMessage = "No signup endpoint was reachable.";
for (const url of urls) {
let res;
try {
res = await doFetch(url, { method: "POST", headers });
}
catch (err) {
lastMessage = err instanceof Error ? err.message : String(err);
continue;
}
if (res.status === 201)
return (await res.json());
const body = await res.text();
if (res.status === 429) {
throw new AuthError("SIGNUP_RATE_LIMITED", "Zenrows blocked auto-signup: too many new accounts from this network. Wait and retry, or set ZENROWS_API_KEY.");
}
lastMessage = `HTTP ${res.status}: ${body.slice(0, 240)}`;
}
throw new AuthError("SIGNUP_FAILED", `Automatic account provisioning failed. ${lastMessage}`);
}
/**
* Ensure an API key is available for stdio.
* Returns the key and optional claim metadata when a new account was provisioned.
*/
export async function ensureApiKey(opts = {}) {
const existing = resolveApiKey();
if (existing.key)
return { apiKey: existing.key };
if (!autoSignupEnabled()) {
throw new AuthError("AUTH_MISSING", "ZENROWS_API_KEY is required (auto-signup disabled via ZENROWS_AUTO_SIGNUP=false).");
}
const res = await signupAgent({ fetchImpl: opts.fetchImpl, userAgent: opts.userAgent });
saveApiKey(res.apiKey);
const account = {
accountId: res.accountId,
unclaimed: true,
claimUrl: res.claimUrl,
createdAt: new Date().toISOString(),
};
writeAccount(account);
opts.onProvision?.(account);
return { apiKey: res.apiKey, provisioned: account };
}
/**
* Client for the Zenrows Batch API (https://async.api.zenrows.com/v1).
* Auth via X-API-Key header. Errors are application/problem+json (RFC 7807).
*/
export declare const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1";
export declare const BATCH_API_BASE_ENV = "ZENROWS_BATCH_API_BASE";
export declare function batchBase(): string;
export interface JobStats {
total: number;
completed: number;
successful: number;
failed: number;
}
export interface JobRun {
status: string;
stats: JobStats;
run_id?: string;
[k: string]: unknown;
}
export interface Job {
job_id: string;
latest_run: JobRun;
[k: string]: unknown;
}
export interface ResultRow {
external_id?: string;
task_id: string;
status?: string;
result_url?: string;
[k: string]: unknown;
}
export interface ResultsPage {
results: ResultRow[];
next_cursor: string | null;
}
export interface ProblemJson {
type?: string;
title?: string;
status?: number;
detail?: string;
code?: string;
invalid_tasks?: Array<{
index: number;
reason: string;
}>;
}
export declare const TERMINAL_STATUSES: ReadonlySet<string>;
export declare class BatchError extends Error {
code: string;
status?: number;
detail?: string;
constructor(opts: {
code: string;
message: string;
status?: number;
detail?: string;
});
toJSON(): {
code: string;
message: string;
status?: number;
detail?: string;
};
}
interface RequestOpts {
apiKey: string;
body?: unknown;
query?: Record<string, string | undefined>;
timeoutMs?: number;
userAgent?: string;
fetchImpl?: typeof fetch;
}
export declare function batchRequest<T>(method: string, path: string, opts: RequestOpts): Promise<T>;
interface CallOpts {
apiKey: string;
timeoutMs?: number;
userAgent?: string;
fetchImpl?: typeof fetch;
}
export declare function createJob(body: unknown, opts: CallOpts): Promise<Job>;
export declare function getJob(id: string, opts: CallOpts): Promise<Job>;
export declare function stopJob(id: string, opts: CallOpts): Promise<Job>;
export declare function listResults(id: string, opts: CallOpts & {
status?: "successful" | "failed" | "all";
}): Promise<ResultRow[]>;
export declare function waitForJob(id: string, opts: CallOpts & {
pollTimeoutMs?: number;
}): Promise<Job>;
export {};
/**
* Client for the Zenrows Batch API (https://async.api.zenrows.com/v1).
* Auth via X-API-Key header. Errors are application/problem+json (RFC 7807).
*/
export const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1";
export const BATCH_API_BASE_ENV = "ZENROWS_BATCH_API_BASE";
export function batchBase() {
const env = process.env[BATCH_API_BASE_ENV];
const base = env && env.trim() ? env.trim() : DEFAULT_BATCH_API_BASE;
return base.replace(/\/+$/, "");
}
export const TERMINAL_STATUSES = new Set(["completed", "stopped", "deleted"]);
export class BatchError extends Error {
code;
status;
detail;
constructor(opts) {
super(opts.message);
this.name = "BatchError";
this.code = opts.code;
this.status = opts.status;
this.detail = opts.detail;
}
toJSON() {
return { code: this.code, message: this.message, status: this.status, detail: this.detail };
}
}
export async function batchRequest(method, path, opts) {
const url = new URL(batchBase() + path);
for (const [k, v] of Object.entries(opts.query ?? {})) {
if (v !== undefined && v !== null)
url.searchParams.set(k, v);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
const headers = {
"X-API-Key": opts.apiKey,
Accept: "application/json",
"User-Agent": opts.userAgent ?? "zenrows/mcp",
};
if (opts.body !== undefined)
headers["Content-Type"] = "application/json";
const doFetch = opts.fetchImpl ?? fetch;
let res;
try {
res = await doFetch(url.toString(), {
method,
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
signal: controller.signal,
});
}
catch (err) {
clearTimeout(timeout);
throw new BatchError({
code: "BACKEND_UNAVAILABLE",
message: `Could not reach the Zenrows Batch API: ${err instanceof Error ? err.message : String(err)}`,
});
}
clearTimeout(timeout);
const text = await res.text();
if (res.status < 200 || res.status >= 300) {
throw problemToError(res.status, text, method, path);
}
if (!text)
return null;
try {
return JSON.parse(text);
}
catch {
throw new BatchError({
code: "BATCH_FAILED",
message: "The Batch API response was not valid JSON.",
detail: text.slice(0, 240),
});
}
}
function problemToError(status, body, method, path) {
let problem = {};
try {
problem = JSON.parse(body);
}
catch {
// non-JSON — fall through
}
const serverCode = problem.code ?? "";
const detail = problem.detail || problem.title || body.slice(0, 240) || `HTTP ${status}`;
const cause = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${method} ${path}: ${detail}`;
if (status === 403) {
return new BatchError({
code: "BATCH_ACCESS_DENIED",
message: "The Batch API rejected this request (access denied). The Batch API is in beta and this account does not have beta access. Request access from Zenrows, or fan out with scrape/extract per URL.",
status,
detail: cause,
});
}
if (status === 401) {
return new BatchError({
code: "AUTH_INVALID",
message: "Zenrows rejected the API key for the Batch API.",
status,
detail: cause,
});
}
if (status === 404) {
return new BatchError({
code: "BATCH_NOT_FOUND",
message: "Batch job, run, or task not found.",
status,
detail: cause,
});
}
if (status === 429) {
return new BatchError({
code: "BATCH_QUOTA_EXCEEDED",
message: "Batch quota exceeded (e.g. max concurrent active jobs). Wait for an in-flight job to finish or cancel one, then retry.",
status,
detail: cause,
});
}
if (status === 402) {
return new BatchError({
code: "BATCH_QUOTA_EXCEEDED",
message: "Subscription has no credit available for the Batch API.",
status,
detail: cause,
});
}
const invalid = problem.invalid_tasks?.length
? ` invalid_tasks: ${problem.invalid_tasks
.slice(0, 10)
.map((t) => `#${t.index}: ${t.reason}`)
.join("; ")}`
: "";
return new BatchError({
code: "BATCH_FAILED",
message: `Batch request failed (HTTP ${status}).${invalid}`,
status,
detail: cause,
});
}
export function createJob(body, opts) {
return batchRequest("POST", "/jobs", { ...opts, body });
}
export function getJob(id, opts) {
return batchRequest("GET", `/jobs/${encodeURIComponent(id)}`, opts);
}
export function stopJob(id, opts) {
return batchRequest("POST", `/jobs/${encodeURIComponent(id)}/stop`, opts);
}
export async function listResults(id, opts) {
const all = [];
let cursor;
do {
const page = await batchRequest("GET", `/jobs/${encodeURIComponent(id)}/results`, {
...opts,
query: { status: opts.status, cursor },
});
if (page?.results)
all.push(...page.results);
cursor = page?.next_cursor ?? undefined;
} while (cursor);
return all;
}
export async function waitForJob(id, opts) {
const deadline = Date.now() + (opts.pollTimeoutMs ?? opts.timeoutMs ?? 600_000);
let delay = 2000;
for (;;) {
const job = await getJob(id, opts);
if (job.latest_run && TERMINAL_STATUSES.has(job.latest_run.status))
return job;
if (Date.now() > deadline) {
throw new BatchError({
code: "BATCH_FAILED",
message: `Timed out waiting for batch job ${id} to finish.`,
detail: `The run did not reach a terminal state within ${Math.round((opts.pollTimeoutMs ?? opts.timeoutMs ?? 600_000) / 1000)}s.`,
});
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 15_000);
}
}
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export declare function registerBatchTools(server: McpServer, apiKey: string): void;
import { createRequire } from "module";
import { z } from "zod";
import { BatchError, createJob, getJob, listResults, stopJob, waitForJob, } from "../batch-api.js";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json");
function err(data) {
return {
content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data) }],
isError: true,
};
}
function json(data) {
return { content: [{ type: "text", text: JSON.stringify(data) }] };
}
function batchErr(e) {
if (e instanceof BatchError)
return err(e.toJSON());
return err({
code: "BATCH_FAILED",
message: e instanceof Error ? e.message : String(e),
});
}
function normalizeParams(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (v === undefined || v === null)
continue;
if (typeof v === "string")
out[k] = v;
else if (typeof v === "boolean" || typeof v === "number")
out[k] = String(v);
else
out[k] = JSON.stringify(v);
}
return out;
}
const taskSchema = z.object({
url: z.string().url().describe("Target URL for this task"),
external_id: z.string().optional().describe("Optional stable id echoed back on results"),
metadata: z.unknown().optional().describe("Opaque per-task metadata carried through to results"),
zenrows_params: z
.record(z.union([z.string(), z.number(), z.boolean()]))
.optional()
.describe("Per-task Zenrows scrape params (js_render, premium_proxy, extract, autoparse, …)"),
});
export function registerBatchTools(server, apiKey) {
const ua = `zenrows/mcp ${pkg.version}`;
const call = { apiKey, userAgent: ua };
server.registerTool("batch_create", {
annotations: { title: "Create Batch Job", readOnlyHint: false, destructiveHint: false },
description: `Submit a cloud Batch job that fans out many URLs asynchronously (Zenrows Batch API beta).
NOT the same as browser_batch — this hits https://async.api.zenrows.com/v1 with X-API-Key.
Use for large URL lists; prefer scrape/extract for one-off pages.
Returns job_id + latest_run.status/stats. Poll with batch_status / batch_wait, then batch_results.
If you get BATCH_ACCESS_DENIED, the account lacks Batch beta access.`,
inputSchema: {
tasks: z
.array(taskSchema)
.optional()
.describe("List of tasks (each needs a url). Prefer this over urls when you need per-task params."),
urls: z
.array(z.string().url())
.optional()
.describe("Shorthand: list of URLs (converted to tasks). Ignored when tasks is provided."),
js_render: z.boolean().optional().describe("Job-level js_render for all tasks"),
premium_proxy: z.boolean().optional().describe("Job-level premium_proxy for all tasks"),
proxy_country: z
.string()
.optional()
.describe("Job-level ISO country code (requires premium_proxy or mode=auto)"),
response_type: z
.enum(["markdown", "plaintext", "html", "pdf"])
.optional()
.describe("Job-level response_type"),
zenrows_params: z
.record(z.union([z.string(), z.number(), z.boolean()]))
.optional()
.describe("Additional job-level zenrows_params merged with the flags above"),
wait: z
.boolean()
.optional()
.describe("If true, poll until the job reaches a terminal state before returning"),
wait_timeout_ms: z
.number()
.int()
.min(1000)
.max(3_600_000)
.optional()
.describe("Max wait time when wait=true (default 600000)"),
},
}, async (params) => {
const tasksIn = params.tasks && params.tasks.length > 0
? params.tasks
: (params.urls ?? []).map((url) => ({ url }));
if (!tasksIn.length) {
return err({
code: "INVALID_USAGE",
message: "Provide tasks (preferred) or urls with at least one URL.",
});
}
const jobParams = { ...(params.zenrows_params ?? {}) };
if (params.js_render)
jobParams.js_render = true;
if (params.premium_proxy)
jobParams.premium_proxy = true;
if (params.proxy_country)
jobParams.proxy_country = params.proxy_country.toLowerCase();
if (params.response_type)
jobParams.response_type = params.response_type;
const body = {
type: "regular",
status: "closed",
tasks: tasksIn.map((t) => {
const task = { url: t.url };
if (t.external_id)
task.external_id = t.external_id;
if (t.metadata !== undefined)
task.metadata = t.metadata;
if (t.zenrows_params)
task.zenrows_params = normalizeParams(t.zenrows_params);
return task;
}),
...(Object.keys(jobParams).length
? { zenrows_params: normalizeParams(jobParams) }
: {}),
};
try {
const job = await createJob(body, call);
const finished = params.wait === true
? await waitForJob(job.job_id, {
...call,
pollTimeoutMs: params.wait_timeout_ms ?? 600_000,
})
: job;
const run = finished.latest_run ?? {};
return json({
ok: true,
job_id: finished.job_id,
status: run.status,
stats: run.stats,
job: finished,
});
}
catch (e) {
return batchErr(e);
}
});
server.registerTool("batch_status", {
annotations: { title: "Batch Job Status", readOnlyHint: true, destructiveHint: false },
description: "Get status and stats for a Zenrows Batch job (latest_run.status + latest_run.stats).",
inputSchema: {
job_id: z.string().describe("Batch job id returned by batch_create"),
},
}, async ({ job_id }) => {
try {
const job = await getJob(job_id, call);
const run = job.latest_run ?? {};
return json({
ok: true,
job_id: job.job_id,
status: run.status,
stats: run.stats,
job,
});
}
catch (e) {
return batchErr(e);
}
});
server.registerTool("batch_results", {
annotations: { title: "Batch Job Results", readOnlyHint: true, destructiveHint: false },
description: `List result rows for a Batch job (cursor-paginated server-side; returns the full list).
Each row may include task_id, external_id, status, and a short-lived result_url for the body.
Download result_url soon — presigned links expire.`,
inputSchema: {
job_id: z.string().describe("Batch job id"),
status: z
.enum(["successful", "failed", "all"])
.optional()
.describe("Filter results by status (default: all)"),
},
}, async ({ job_id, status }) => {
try {
const results = await listResults(job_id, { ...call, status });
return json({ ok: true, job_id, count: results.length, results });
}
catch (e) {
return batchErr(e);
}
});
server.registerTool("batch_cancel", {
annotations: { title: "Cancel Batch Job", readOnlyHint: false, destructiveHint: true },
description: "Stop an in-flight Batch job run (POST /jobs/:id/stop).",
inputSchema: {
job_id: z.string().describe("Batch job id to stop"),
},
}, async ({ job_id }) => {
try {
const job = await stopJob(job_id, call);
const run = job.latest_run ?? {};
return json({
ok: true,
job_id: job.job_id,
status: run.status,
stats: run.stats,
job,
});
}
catch (e) {
return batchErr(e);
}
});
server.registerTool("batch_wait", {
annotations: { title: "Wait for Batch Job", readOnlyHint: true, destructiveHint: false },
description: "Poll batch_status until the job reaches a terminal state (completed, stopped, or deleted).",
inputSchema: {
job_id: z.string().describe("Batch job id"),
timeout_ms: z
.number()
.int()
.min(1000)
.max(3_600_000)
.optional()
.describe("Max wait time in ms (default 600000)"),
},
}, async ({ job_id, timeout_ms }) => {
try {
const job = await waitForJob(job_id, { ...call, pollTimeoutMs: timeout_ms ?? 600_000 });
const run = job.latest_run ?? {};
return json({
ok: true,
job_id: job.job_id,
status: run.status,
stats: run.stats,
job,
});
}
catch (e) {
return batchErr(e);
}
});
}
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export declare function zrErrorCode(body: string): string | undefined;
export type ExtractMode = "auto" | "autoparse" | "css";
export type ExtractStealthOpts = {
css_extractor?: string;
js_render?: boolean;
premium_proxy?: boolean;
proxy_country?: string;
mode_auto?: boolean;
wait_for?: string;
wait?: number;
};
export type ExtractInput = ExtractStealthOpts & {
url: string;
mode?: ExtractMode;
fallback_autoparse?: boolean;
};
export type ExtractSuccess = {
ok: true;
mode: ExtractMode;
fellBackToAutoparse: boolean;
empty: boolean;
data: unknown;
html?: string;
raw?: string;
};
export type ExtractFailure = {
ok: false;
errorText: string;
};
export declare function buildExtractParams(apiKey: string, url: string, mode: ExtractMode, opts: ExtractStealthOpts): URLSearchParams;
/**
* Core extract logic (testable). AUTH010 on mode=auto retries once with autoparse
* unless fallback_autoparse is false — same behavior as the CLI extract adapter.
*/
export declare function runExtract(apiKey: string, params: ExtractInput, options?: {
getClientName?: () => string | undefined;
fetchImpl?: typeof fetch;
}): Promise<ExtractSuccess | ExtractFailure>;
export declare function registerExtractTool(server: McpServer, apiKey: string, getClientName: () => string | undefined): void;
import { createRequire } from "module";
import { z } from "zod";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json");
const ZENROWS_API_URL = "https://api.zenrows.com/v1/";
function err(text) {
return { content: [{ type: "text", text }], isError: true };
}
function json(data) {
return { content: [{ type: "text", text: JSON.stringify(data) }] };
}
export function zrErrorCode(body) {
try {
const j = JSON.parse(body);
if (typeof j.code === "string")
return j.code;
const m = typeof j.error === "string" ? j.error.match(/\((AUTH\d+)\)/) : null;
return m?.[1];
}
catch {
return undefined;
}
}
function isEmptyData(data) {
if (data === null || data === undefined)
return true;
if (Array.isArray(data))
return data.length === 0;
if (typeof data === "object")
return Object.keys(data).length === 0;
if (typeof data === "string")
return data.trim() === "";
return false;
}
export function buildExtractParams(apiKey, url, mode, opts) {
const sp = new URLSearchParams({ apikey: apiKey, url });
if (mode === "auto")
sp.set("extract", "auto");
if (mode === "autoparse")
sp.set("autoparse", "true");
if (mode === "css" && opts.css_extractor)
sp.set("css_extractor", opts.css_extractor);
if (opts.mode_auto)
sp.set("mode", "auto");
if (opts.js_render)
sp.set("js_render", "true");
if (opts.premium_proxy)
sp.set("premium_proxy", "true");
if (opts.proxy_country)
sp.set("proxy_country", opts.proxy_country.toUpperCase());
if (opts.wait_for)
sp.set("wait_for", opts.wait_for);
if (opts.wait != null)
sp.set("wait", String(opts.wait));
return sp;
}
async function callZenrows(apiKey, searchParams, getClientName, fetchImpl) {
let response;
try {
response = await fetchImpl(`${ZENROWS_API_URL}?${searchParams}`, {
headers: {
"User-Agent": `zenrows/mcp ${pkg.version}`,
...(getClientName() ? { "x-mcp-client-name": getClientName() } : {}),
"x-mcp-tool": "extract",
},
});
}
catch (e) {
return {
ok: false,
status: 0,
body: `Network error contacting Zenrows: ${e instanceof Error ? e.message : String(e)}`,
};
}
return { ok: response.ok, status: response.status, body: await response.text() };
}
/**
* Core extract logic (testable). AUTH010 on mode=auto retries once with autoparse
* unless fallback_autoparse is false — same behavior as the CLI extract adapter.
*/
export async function runExtract(apiKey, params, options = {}) {
const getClientName = options.getClientName ?? (() => undefined);
const fetchImpl = options.fetchImpl ?? fetch;
const mode = params.mode ?? "auto";
if (mode === "css" && !params.css_extractor) {
return {
ok: false,
errorText: JSON.stringify({
code: "INVALID_USAGE",
message: "mode=css requires css_extractor JSON selector map.",
}),
};
}
const opts = {
css_extractor: params.css_extractor,
js_render: params.js_render,
premium_proxy: params.premium_proxy,
proxy_country: params.proxy_country,
mode_auto: params.mode_auto,
wait_for: params.wait_for,
wait: params.wait,
};
let usedMode = mode;
let result = await callZenrows(apiKey, buildExtractParams(apiKey, params.url, mode, opts), getClientName, fetchImpl);
let fellBackToAutoparse = false;
if (!result.ok &&
mode === "auto" &&
params.fallback_autoparse !== false &&
result.status === 402 &&
zrErrorCode(result.body) === "AUTH010") {
usedMode = "autoparse";
fellBackToAutoparse = true;
result = await callZenrows(apiKey, buildExtractParams(apiKey, params.url, "autoparse", opts), getClientName, fetchImpl);
}
if (!result.ok) {
return {
ok: false,
errorText: result.status === 0
? result.body
: JSON.stringify({
code: zrErrorCode(result.body) ?? "EXTRACT_FAILED",
message: `Zenrows error ${result.status}`,
detail: result.body.slice(0, 500),
mode: usedMode,
}),
};
}
let parsed;
try {
parsed = JSON.parse(result.body);
}
catch {
return {
ok: true,
mode: usedMode,
fellBackToAutoparse,
empty: true,
data: null,
raw: result.body,
};
}
let data = parsed;
let html;
if (usedMode === "auto" && parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const envelope = parsed;
if ("parsed" in envelope) {
data = envelope.parsed;
html = typeof envelope.html === "string" ? envelope.html : undefined;
}
}
return {
ok: true,
mode: usedMode,
fellBackToAutoparse,
empty: isEmptyData(data),
data,
...(html !== undefined ? { html } : {}),
};
}
export function registerExtractTool(server, apiKey, getClientName) {
server.registerTool("extract", {
annotations: {
title: "Extract Structured Data",
readOnlyHint: true,
destructiveHint: false,
},
description: `Extract structured data from a webpage via Zenrows.
Prefer this over scrape when you need JSON fields (products, articles, listings)
rather than a full page body.
Modes:
- auto (default): extract=auto — site-tailored Extract (open beta; currently free,
billing may apply later; may fall back to autoparse if the domain is not enabled)
- autoparse: general-purpose structured JSON on any domain
- css: css_extractor with an explicit selector map
Stealth: js_render, premium_proxy, proxy_country, or mode_auto (Adaptive Stealth Mode).
For full-page markdown/HTML/screenshots, use scrape instead.`,
inputSchema: {
url: z.string().url().describe("The webpage URL to extract from"),
mode: z
.enum(["auto", "autoparse", "css"])
.optional()
.default("auto")
.describe("Extraction mode: auto (extract=auto, default), autoparse, or css (requires css_extractor)"),
css_extractor: z
.string()
.optional()
.describe('Required when mode=css. JSON map of field→selector, e.g. \'{"title":"h1","price":".price"}\''),
js_render: z
.boolean()
.optional()
.describe("Enable headless JS rendering (SPAs / dynamic content)"),
premium_proxy: z
.boolean()
.optional()
.describe("Use premium residential proxies (anti-bot). Higher credit cost."),
proxy_country: z
.string()
.optional()
.describe("ISO 3166-1 alpha-2 country code. Requires premium_proxy or mode_auto."),
mode_auto: z
.boolean()
.optional()
.describe("Enable Adaptive Stealth Mode (mode=auto) for tougher sites"),
wait_for: z
.string()
.optional()
.describe("CSS selector to wait for before extracting. Requires js_render."),
wait: z
.number()
.int()
.min(0)
.max(30000)
.optional()
.describe("Milliseconds to wait after load. Requires js_render."),
fallback_autoparse: z
.boolean()
.optional()
.default(true)
.describe("When mode=auto and the domain is not in Extract open beta (AUTH010), retry once with autoparse (default true)"),
},
}, async (params) => {
const outcome = await runExtract(apiKey, params, { getClientName });
if (!outcome.ok)
return err(outcome.errorText);
return json({
ok: true,
mode: outcome.mode,
fellBackToAutoparse: outcome.fellBackToAutoparse,
empty: outcome.empty,
data: outcome.data,
...(outcome.html !== undefined ? { html: outcome.html } : {}),
...(outcome.raw !== undefined ? { raw: outcome.raw } : {}),
});
});
}
+31
-4
#!/usr/bin/env node
import { createRequire } from "module";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { AuthError, ensureApiKey, getZenrowsDir, resolveApiKey, } from "./auth/ensure-key.js";
import { createServer } from "./server.js";
const apiKey = process.env.ZENROWS_API_KEY;
if (!apiKey) {
process.stderr.write("Error: ZENROWS_API_KEY environment variable is required\n");
const require = createRequire(import.meta.url);
const pkg = require("../package.json");
let apiKey;
try {
const existing = resolveApiKey();
if (existing.key) {
process.stderr.write(`Using existing API key from ${existing.source} (secrets dir: ${getZenrowsDir()})\n`);
}
else {
const signup = process.env.ZENROWS_AGENT_SIGNUP_URL?.trim() ||
"https://app.zenrows.com/api/agent/signup (default prod)";
process.stderr.write(`No API key — will auto-signup via: ${signup}\n`);
}
const resolved = await ensureApiKey({
userAgent: `zenrows/mcp ${pkg.version}`,
onProvision: (acct) => {
process.stderr.write(`Created a Zenrows Free plan account.\n` +
`Claim it anytime (keeps your usage): ${acct.claimUrl}\n` +
`Key stored in ${getZenrowsDir()}/secrets.json\n`);
},
});
apiKey = resolved.apiKey;
}
catch (err) {
const msg = err instanceof AuthError
? `Error: ${err.message}\n`
: `Error: ${err instanceof Error ? err.message : String(err)}\n`;
process.stderr.write(msg);
process.exit(1);

@@ -12,2 +39,2 @@ }

await server.connect(transport);
process.stderr.write("Zenrows MCP server running on stdio\n");
process.stderr.write(`Zenrows MCP server running on stdio (secrets dir: ${getZenrowsDir()})\n`);
import { createRequire } from "module";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { getZenrowsDir, readAccount } from "./auth/ensure-key.js";
import { registerBatchTools } from "./tools/batch.js";
import { registerBrowserTools } from "./tools/browser.js";
import { registerExtractTool } from "./tools/extract.js";
const require = createRequire(import.meta.url);

@@ -26,6 +29,7 @@ const pkg = require("../package.json");

},
description: `Scrape any webpage and return its content using Zenrows.
description: `Scrape any webpage and return its content using Zenrows (Fetch).
Use this tool to fetch webpage content for analysis. By default it returns clean
markdown, which is ideal for LLM processing.
Use for full-page content (markdown/HTML/PDF/screenshot). For structured JSON
fields (products, articles, listings), prefer the extract tool when it fits —
it returns parsed fields instead of a full page body.

@@ -37,4 +41,2 @@ When to enable options:

- wait_for: specific content loads after initial render (requires js_render)
- css_extractor: you only need specific elements, not the whole page
- autoparse: structured data pages like products or articles

@@ -44,4 +46,3 @@ Examples:

Dynamic: { url: "https://spa.com", js_render: true }
Protected:{ url: "https://protected.com", js_render: true, premium_proxy: true }
Extract: { url: "https://shop.com", css_extractor: '{"title":"h1","price":".price"}' }`,
Protected:{ url: "https://protected.com", js_render: true, premium_proxy: true }`,
inputSchema: {

@@ -238,3 +239,3 @@ url: z.string().url().describe("The webpage URL to scrape"),

title: "Extract Structured Data",
description: "Scrape a webpage and extract specific structured data using CSS selectors.",
description: "Extract specific structured data from a webpage using CSS selectors.",
argsSchema: {

@@ -252,3 +253,3 @@ url: z.string().url().describe("The webpage URL to extract data from"),

type: "text",
text: `Scrape ${url} using the Zenrows MCP scrape tool with css_extractor set to ${fields}. Return the extracted data as a clean JSON object.`,
text: `Use the Zenrows MCP extract tool on ${url} with mode=css and css_extractor set to ${fields}. Return the extracted data as a clean JSON object.`,
},

@@ -275,5 +276,37 @@ },

}));
registerExtractTool(server, apiKey, getClientName);
registerBatchTools(server, apiKey);
const BROWSER_URL = process.env.ZENROWS_BROWSER_URL ?? "https://mcp.zenrows.com";
registerBrowserTools(server, apiKey, BROWSER_URL, getClientName);
// Always expose account resource; handler re-reads disk so ZENROWS_HOME is visible.
server.registerResource("zenrows-account", "zenrows://account", {
description: "Local Zenrows agent account metadata (claim URL for unclaimed Free plans). Re-reads ~/.zenrows or $ZENROWS_HOME on each read.",
mimeType: "application/json",
}, async () => {
const acct = readAccount();
const home = getZenrowsDir();
const body = acct
? {
accountId: acct.accountId,
unclaimed: acct.unclaimed,
claimUrl: acct.claimUrl,
createdAt: acct.createdAt,
zenrowsHome: home,
}
: {
unclaimed: false,
message: "No local agent account file (key from env or missing).",
zenrowsHome: home,
};
return {
contents: [
{
uri: "zenrows://account",
mimeType: "application/json",
text: JSON.stringify(body, null, 2),
},
],
};
});
return server;
}
+4
-3
{
"name": "@zenrows/mcp",
"version": "2.1.2",
"description": "Zenrows MCP server — Fetch and Browser Sessions for AI coding assistants",
"version": "2.2.0",
"description": "Zenrows MCP server — Fetch, Extract, Batch, and Browser Sessions for AI coding assistants",
"type": "module",

@@ -23,4 +23,5 @@ "bin": {

"prepare": "npm run build",
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint",
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm test",
"publish-beta": "npm publish --tag beta",
"test": "node --import tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit"

@@ -27,0 +28,0 @@ },

@@ -44,3 +44,3 @@ <p align="center">

**Authentication:** OAuth-based. Pass your Zenrows API key as a Bearer token in the `Authorization` header on every request.
**Authentication:** OAuth or API key as Bearer token. Pass your Zenrows API key in the `Authorization` header on every request (or complete OAuth in clients that support it).

@@ -53,2 +53,4 @@ ```

> Remote MCP does **not** auto-create accounts. Use OAuth “Create Free account” in the client, or pass an existing API key.
#### Example: OpenAI Responses API

@@ -89,7 +91,11 @@

**Authentication:** API key via the `ZENROWS_API_KEY` environment variable.
**Authentication:**
1. `ZENROWS_API_KEY` environment variable, or
2. Key previously stored in `~/.zenrows/secrets.json`, or
3. **Auto-signup** (default): if neither is set, stdio provisions a Free plan account via `POST /api/agent/signup`, persists the key + claim metadata under `~/.zenrows/` (`secrets.json` + `account.json`, mode `0600`), and prints a claim URL on stderr. Opt out with `ZENROWS_AUTO_SIGNUP=false`.
**Requirements:** [Node.js](https://nodejs.org/) installed (for `npx` to work).
**Configuration:**
**Configuration (with your own key):**

@@ -110,2 +116,15 @@ ```json

**Zero-config (auto-signup):**
```json
{
"mcpServers": {
"zenrows": {
"command": "npx",
"args": ["-y", "@zenrows/mcp"]
}
}
}
```
The exact location of this config varies by client. See the [per-client setup guides](https://docs.zenrows.com/mcp/overview#per-client-setup-guides) for the file path for your client.

@@ -117,6 +136,10 @@

The Zenrows MCP exposes two families of tools:
The Zenrows MCP exposes these tool families:
- **`scrape`**: single-request fetch returning Markdown, plain text, HTML, JSON, PDF, or screenshot. Backed by [Fetch](https://docs.zenrows.com/fetch/api-reference).
- **`browser_*`**: 30+ tools for full browser automation including navigation, clicks, form fills, JavaScript execution, cookies, tabs, and persistent sessions. Backed by [Browser Sessions](https://docs.zenrows.com/browser-sessions/introduction).
| Tool | Purpose |
|------|---------|
| **`scrape`** | Full-page content → Markdown, plain text, HTML, PDF, or screenshot (plus helper outputs). |
| **`extract`** | Structured JSON (`extract=auto`, autoparse, or `css_extractor`) + optional stealth flags. `extract=auto` is open beta (currently free; billing may apply later). |
| **`batch_create` / `batch_status` / `batch_results` / `batch_cancel` / `batch_wait`** | Cloud Batch API fan-out (`async.api.zenrows.com`). Beta; may return `BATCH_ACCESS_DENIED`. Not `browser_batch`. |
| **`browser_*`** | 30+ tools for full browser automation (navigation, clicks, forms, JS, cookies, tabs, sessions). |

@@ -135,3 +158,3 @@ The AI selects the right tool from your prompt. You don't call tools directly in code.

npm install
cp .env.example .env # Add your API key
cp .env.example .env # Optional: add your API key (stdio can auto-signup)
npm run dev # Run with .env loaded (requires Node.js 20.6+)

@@ -138,0 +161,0 @@ npm run build # Compile to dist/