@zenrows/mcp 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -7
- package/dist/auth/ensure-key.d.ts +59 -0
- package/dist/auth/ensure-key.js +184 -0
- package/dist/batch-api.d.ts +89 -0
- package/dist/batch-api.js +182 -0
- package/dist/http.js +5 -2
- package/dist/index.js +31 -4
- package/dist/server.js +42 -9
- package/dist/tools/batch.d.ts +2 -0
- package/dist/tools/batch.js +245 -0
- package/dist/tools/extract.d.ts +40 -0
- package/dist/tools/extract.js +237 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ https://mcp.zenrows.com/mcp
|
|
|
41
41
|
|
|
42
42
|
**Transport:** Streamable HTTP
|
|
43
43
|
|
|
44
|
-
**Authentication:** OAuth
|
|
44
|
+
**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).
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
Authorization: Bearer YOUR_ZENROWS_API_KEY
|
|
@@ -49,6 +49,8 @@ Authorization: Bearer YOUR_ZENROWS_API_KEY
|
|
|
49
49
|
|
|
50
50
|
Most MCP clients accept this through an `authorization` shorthand field on the tool config and forward it as the Bearer token automatically. Some clients use a free-form `headers` field instead. Either approach works.
|
|
51
51
|
|
|
52
|
+
> Remote MCP does **not** auto-create accounts. Use OAuth “Create Free account” in the client, or pass an existing API key.
|
|
53
|
+
|
|
52
54
|
#### Example: OpenAI Responses API
|
|
53
55
|
|
|
54
56
|
```python
|
|
@@ -84,11 +86,15 @@ Use the local stdio configuration when your MCP client runs the server as a loca
|
|
|
84
86
|
|
|
85
87
|
**Package:** [`@zenrows/mcp`](https://www.npmjs.com/package/@zenrows/mcp) on npm
|
|
86
88
|
|
|
87
|
-
**Authentication:**
|
|
89
|
+
**Authentication:**
|
|
90
|
+
|
|
91
|
+
1. `ZENROWS_API_KEY` environment variable, or
|
|
92
|
+
2. Key previously stored in `~/.zenrows/secrets.json`, or
|
|
93
|
+
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`.
|
|
88
94
|
|
|
89
95
|
**Requirements:** [Node.js](https://nodejs.org/) installed (for `npx` to work).
|
|
90
96
|
|
|
91
|
-
**Configuration:**
|
|
97
|
+
**Configuration (with your own key):**
|
|
92
98
|
|
|
93
99
|
```json
|
|
94
100
|
{
|
|
@@ -104,16 +110,33 @@ Use the local stdio configuration when your MCP client runs the server as a loca
|
|
|
104
110
|
}
|
|
105
111
|
```
|
|
106
112
|
|
|
113
|
+
**Zero-config (auto-signup):**
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"mcpServers": {
|
|
118
|
+
"zenrows": {
|
|
119
|
+
"command": "npx",
|
|
120
|
+
"args": ["-y", "@zenrows/mcp"]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
107
126
|
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.
|
|
108
127
|
|
|
109
128
|
---
|
|
110
129
|
|
|
111
130
|
## Tools
|
|
112
131
|
|
|
113
|
-
The Zenrows MCP exposes
|
|
132
|
+
The Zenrows MCP exposes these tool families:
|
|
114
133
|
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
| Tool | Purpose |
|
|
135
|
+
|------|---------|
|
|
136
|
+
| **`scrape`** | Full-page content → Markdown, plain text, HTML, PDF, or screenshot (plus helper outputs). |
|
|
137
|
+
| **`extract`** | Structured JSON (`extract=auto`, autoparse, or `css_extractor`) + optional stealth flags. `extract=auto` is open beta (currently free; billing may apply later). |
|
|
138
|
+
| **`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`. |
|
|
139
|
+
| **`browser_*`** | 30+ tools for full browser automation (navigation, clicks, forms, JS, cookies, tabs, sessions). |
|
|
117
140
|
|
|
118
141
|
The AI selects the right tool from your prompt. You don't call tools directly in code.
|
|
119
142
|
|
|
@@ -127,7 +150,7 @@ See the [full tool reference](https://docs.zenrows.com/mcp/overview#tools) for e
|
|
|
127
150
|
git clone https://github.com/ZenRows/zenrows-mcp
|
|
128
151
|
cd zenrows-mcp
|
|
129
152
|
npm install
|
|
130
|
-
cp .env.example .env #
|
|
153
|
+
cp .env.example .env # Optional: add your API key (stdio can auto-signup)
|
|
131
154
|
npm run dev # Run with .env loaded (requires Node.js 20.6+)
|
|
132
155
|
npm run build # Compile to dist/
|
|
133
156
|
npm run inspect # Open the MCP inspector UI
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const ENV_KEY = "ZENROWS_API_KEY";
|
|
2
|
+
export declare const AUTO_SIGNUP_ENV = "ZENROWS_AUTO_SIGNUP";
|
|
3
|
+
export declare const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
|
|
4
|
+
export declare const DISCOVERY_URL_ENV = "ZENROWS_DISCOVERY_URL";
|
|
5
|
+
export declare const AGENT_SIGNUP_API_URL = "https://app.zenrows.com/api/agent/signup";
|
|
6
|
+
export declare const WELL_KNOWN_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource";
|
|
7
|
+
export interface AgentAccount {
|
|
8
|
+
accountId: string;
|
|
9
|
+
unclaimed: boolean;
|
|
10
|
+
claimUrl: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SignupResponse {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
accountId: string;
|
|
16
|
+
claimUrl: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class AuthError extends Error {
|
|
19
|
+
code: string;
|
|
20
|
+
constructor(code: string, message: string);
|
|
21
|
+
}
|
|
22
|
+
/** Override home for tests / custom installs (absolute path to the `.zenrows` dir parent, or the dir itself if it ends with `.zenrows`). */
|
|
23
|
+
export declare const ZENROWS_HOME_ENV = "ZENROWS_HOME";
|
|
24
|
+
/** Test-only: clear discovery cache between cases. */
|
|
25
|
+
export declare function _resetDiscoveryCache(): void;
|
|
26
|
+
export declare function getZenrowsDir(): string;
|
|
27
|
+
export declare function readStoredApiKey(): string | undefined;
|
|
28
|
+
export declare function readAccount(): AgentAccount | null;
|
|
29
|
+
export declare function saveApiKey(apiKey: string): void;
|
|
30
|
+
export declare function writeAccount(acct: AgentAccount): void;
|
|
31
|
+
/** Resolve key: env → ~/.zenrows/secrets.json. Does not signup. */
|
|
32
|
+
export declare function resolveApiKey(): {
|
|
33
|
+
key?: string;
|
|
34
|
+
source: "env" | "secrets-file" | "none";
|
|
35
|
+
};
|
|
36
|
+
export declare function autoSignupEnabled(): boolean;
|
|
37
|
+
export declare function discoverSignupUrl(opts?: {
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
}): Promise<string | null>;
|
|
40
|
+
export declare function signupCandidates(opts?: {
|
|
41
|
+
fetchImpl?: typeof fetch;
|
|
42
|
+
}): Promise<string[]>;
|
|
43
|
+
export declare function signupAgent(opts?: {
|
|
44
|
+
url?: string;
|
|
45
|
+
fetchImpl?: typeof fetch;
|
|
46
|
+
userAgent?: string;
|
|
47
|
+
}): Promise<SignupResponse>;
|
|
48
|
+
/**
|
|
49
|
+
* Ensure an API key is available for stdio.
|
|
50
|
+
* Returns the key and optional claim metadata when a new account was provisioned.
|
|
51
|
+
*/
|
|
52
|
+
export declare function ensureApiKey(opts?: {
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
userAgent?: string;
|
|
55
|
+
onProvision?: (a: AgentAccount) => void;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
apiKey: string;
|
|
58
|
+
provisioned?: AgentAccount;
|
|
59
|
+
}>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve-or-provision the Zenrows API key for stdio MCP.
|
|
3
|
+
*
|
|
4
|
+
* Persistence lives under ~/.zenrows/ (secrets.json + account.json, mode 0600).
|
|
5
|
+
* Remote HTTP transport must NOT call this — Bearer/OAuth only.
|
|
6
|
+
*/
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
export const ENV_KEY = "ZENROWS_API_KEY";
|
|
11
|
+
export const AUTO_SIGNUP_ENV = "ZENROWS_AUTO_SIGNUP";
|
|
12
|
+
export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
|
|
13
|
+
export const DISCOVERY_URL_ENV = "ZENROWS_DISCOVERY_URL";
|
|
14
|
+
export const AGENT_SIGNUP_API_URL = "https://app.zenrows.com/api/agent/signup";
|
|
15
|
+
export const WELL_KNOWN_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource";
|
|
16
|
+
export class AuthError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "AuthError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Override home for tests / custom installs (absolute path to the `.zenrows` dir parent, or the dir itself if it ends with `.zenrows`). */
|
|
25
|
+
export const ZENROWS_HOME_ENV = "ZENROWS_HOME";
|
|
26
|
+
function zenrowsDir() {
|
|
27
|
+
const override = process.env[ZENROWS_HOME_ENV]?.trim();
|
|
28
|
+
if (override) {
|
|
29
|
+
return override.endsWith(".zenrows") ? override : join(override, ".zenrows");
|
|
30
|
+
}
|
|
31
|
+
return join(homedir(), ".zenrows");
|
|
32
|
+
}
|
|
33
|
+
/** Test-only: clear discovery cache between cases. */
|
|
34
|
+
export function _resetDiscoveryCache() {
|
|
35
|
+
discoveredSignupUrl = undefined;
|
|
36
|
+
}
|
|
37
|
+
export function getZenrowsDir() {
|
|
38
|
+
return zenrowsDir();
|
|
39
|
+
}
|
|
40
|
+
function secretsPath() {
|
|
41
|
+
return join(zenrowsDir(), "secrets.json");
|
|
42
|
+
}
|
|
43
|
+
function accountPath() {
|
|
44
|
+
return join(zenrowsDir(), "account.json");
|
|
45
|
+
}
|
|
46
|
+
function ensureDir() {
|
|
47
|
+
const dir = zenrowsDir();
|
|
48
|
+
if (!existsSync(dir))
|
|
49
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
}
|
|
51
|
+
function readJsonFile(file) {
|
|
52
|
+
if (!existsSync(file))
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writeJsonSecure(file, data) {
|
|
62
|
+
ensureDir();
|
|
63
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
64
|
+
try {
|
|
65
|
+
chmodSync(file, 0o600);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// best-effort on platforms without POSIX permissions
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function readStoredApiKey() {
|
|
72
|
+
const stored = readJsonFile(secretsPath());
|
|
73
|
+
const key = stored?.apiKey?.trim();
|
|
74
|
+
return key || undefined;
|
|
75
|
+
}
|
|
76
|
+
export function readAccount() {
|
|
77
|
+
return readJsonFile(accountPath());
|
|
78
|
+
}
|
|
79
|
+
export function saveApiKey(apiKey) {
|
|
80
|
+
writeJsonSecure(secretsPath(), { apiKey: apiKey.trim() });
|
|
81
|
+
}
|
|
82
|
+
export function writeAccount(acct) {
|
|
83
|
+
writeJsonSecure(accountPath(), acct);
|
|
84
|
+
}
|
|
85
|
+
/** Resolve key: env → ~/.zenrows/secrets.json. Does not signup. */
|
|
86
|
+
export function resolveApiKey() {
|
|
87
|
+
const env = process.env[ENV_KEY]?.trim();
|
|
88
|
+
if (env)
|
|
89
|
+
return { key: env, source: "env" };
|
|
90
|
+
const stored = readStoredApiKey();
|
|
91
|
+
if (stored)
|
|
92
|
+
return { key: stored, source: "secrets-file" };
|
|
93
|
+
return { source: "none" };
|
|
94
|
+
}
|
|
95
|
+
export function autoSignupEnabled() {
|
|
96
|
+
return process.env[AUTO_SIGNUP_ENV] !== "false";
|
|
97
|
+
}
|
|
98
|
+
let discoveredSignupUrl;
|
|
99
|
+
export async function discoverSignupUrl(opts = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const base = process.env[DISCOVERY_URL_ENV]?.trim() || new URL(AGENT_SIGNUP_API_URL).origin;
|
|
102
|
+
const url = base.replace(/\/$/, "") + WELL_KNOWN_PROTECTED_RESOURCE;
|
|
103
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
104
|
+
const res = await doFetch(url, {
|
|
105
|
+
method: "GET",
|
|
106
|
+
headers: { Accept: "application/json", "User-Agent": "zenrows/mcp" },
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
return null;
|
|
110
|
+
const json = (await res.json());
|
|
111
|
+
const endpoint = json?.agent_auth?.signup_endpoint;
|
|
112
|
+
if (typeof endpoint === "string" && endpoint.trim())
|
|
113
|
+
return endpoint.trim();
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function signupCandidates(opts = {}) {
|
|
121
|
+
const fromEnv = process.env[SIGNUP_URL_ENV];
|
|
122
|
+
if (fromEnv && fromEnv.trim())
|
|
123
|
+
return [fromEnv.trim()];
|
|
124
|
+
if (discoveredSignupUrl === undefined) {
|
|
125
|
+
discoveredSignupUrl = await discoverSignupUrl(opts);
|
|
126
|
+
}
|
|
127
|
+
const urls = [];
|
|
128
|
+
if (discoveredSignupUrl && discoveredSignupUrl !== AGENT_SIGNUP_API_URL) {
|
|
129
|
+
urls.push(discoveredSignupUrl);
|
|
130
|
+
}
|
|
131
|
+
urls.push(AGENT_SIGNUP_API_URL);
|
|
132
|
+
return urls;
|
|
133
|
+
}
|
|
134
|
+
export async function signupAgent(opts = {}) {
|
|
135
|
+
const urls = opts.url ? [opts.url] : await signupCandidates({ fetchImpl: opts.fetchImpl });
|
|
136
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
137
|
+
const headers = {
|
|
138
|
+
"content-type": "application/json",
|
|
139
|
+
"User-Agent": opts.userAgent ?? "zenrows/mcp",
|
|
140
|
+
"X-ZR-Source": "mcp",
|
|
141
|
+
};
|
|
142
|
+
let lastMessage = "No signup endpoint was reachable.";
|
|
143
|
+
for (const url of urls) {
|
|
144
|
+
let res;
|
|
145
|
+
try {
|
|
146
|
+
res = await doFetch(url, { method: "POST", headers });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
lastMessage = err instanceof Error ? err.message : String(err);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (res.status === 201)
|
|
153
|
+
return (await res.json());
|
|
154
|
+
const body = await res.text();
|
|
155
|
+
if (res.status === 429) {
|
|
156
|
+
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.");
|
|
157
|
+
}
|
|
158
|
+
lastMessage = `HTTP ${res.status}: ${body.slice(0, 240)}`;
|
|
159
|
+
}
|
|
160
|
+
throw new AuthError("SIGNUP_FAILED", `Automatic account provisioning failed. ${lastMessage}`);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Ensure an API key is available for stdio.
|
|
164
|
+
* Returns the key and optional claim metadata when a new account was provisioned.
|
|
165
|
+
*/
|
|
166
|
+
export async function ensureApiKey(opts = {}) {
|
|
167
|
+
const existing = resolveApiKey();
|
|
168
|
+
if (existing.key)
|
|
169
|
+
return { apiKey: existing.key };
|
|
170
|
+
if (!autoSignupEnabled()) {
|
|
171
|
+
throw new AuthError("AUTH_MISSING", "ZENROWS_API_KEY is required (auto-signup disabled via ZENROWS_AUTO_SIGNUP=false).");
|
|
172
|
+
}
|
|
173
|
+
const res = await signupAgent({ fetchImpl: opts.fetchImpl, userAgent: opts.userAgent });
|
|
174
|
+
saveApiKey(res.apiKey);
|
|
175
|
+
const account = {
|
|
176
|
+
accountId: res.accountId,
|
|
177
|
+
unclaimed: true,
|
|
178
|
+
claimUrl: res.claimUrl,
|
|
179
|
+
createdAt: new Date().toISOString(),
|
|
180
|
+
};
|
|
181
|
+
writeAccount(account);
|
|
182
|
+
opts.onProvision?.(account);
|
|
183
|
+
return { apiKey: res.apiKey, provisioned: account };
|
|
184
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the Zenrows Batch API (https://async.api.zenrows.com/v1).
|
|
3
|
+
* Auth via X-API-Key header. Errors are application/problem+json (RFC 7807).
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1";
|
|
6
|
+
export declare const BATCH_API_BASE_ENV = "ZENROWS_BATCH_API_BASE";
|
|
7
|
+
export declare function batchBase(): string;
|
|
8
|
+
export interface JobStats {
|
|
9
|
+
total: number;
|
|
10
|
+
completed: number;
|
|
11
|
+
successful: number;
|
|
12
|
+
failed: number;
|
|
13
|
+
}
|
|
14
|
+
export interface JobRun {
|
|
15
|
+
status: string;
|
|
16
|
+
stats: JobStats;
|
|
17
|
+
run_id?: string;
|
|
18
|
+
[k: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface Job {
|
|
21
|
+
job_id: string;
|
|
22
|
+
latest_run: JobRun;
|
|
23
|
+
[k: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface ResultRow {
|
|
26
|
+
external_id?: string;
|
|
27
|
+
task_id: string;
|
|
28
|
+
status?: string;
|
|
29
|
+
result_url?: string;
|
|
30
|
+
[k: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface ResultsPage {
|
|
33
|
+
results: ResultRow[];
|
|
34
|
+
next_cursor: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface ProblemJson {
|
|
37
|
+
type?: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
status?: number;
|
|
40
|
+
detail?: string;
|
|
41
|
+
code?: string;
|
|
42
|
+
invalid_tasks?: Array<{
|
|
43
|
+
index: number;
|
|
44
|
+
reason: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
export declare const TERMINAL_STATUSES: ReadonlySet<string>;
|
|
48
|
+
export declare class BatchError extends Error {
|
|
49
|
+
code: string;
|
|
50
|
+
status?: number;
|
|
51
|
+
detail?: string;
|
|
52
|
+
constructor(opts: {
|
|
53
|
+
code: string;
|
|
54
|
+
message: string;
|
|
55
|
+
status?: number;
|
|
56
|
+
detail?: string;
|
|
57
|
+
});
|
|
58
|
+
toJSON(): {
|
|
59
|
+
code: string;
|
|
60
|
+
message: string;
|
|
61
|
+
status?: number;
|
|
62
|
+
detail?: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
interface RequestOpts {
|
|
66
|
+
apiKey: string;
|
|
67
|
+
body?: unknown;
|
|
68
|
+
query?: Record<string, string | undefined>;
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
userAgent?: string;
|
|
71
|
+
fetchImpl?: typeof fetch;
|
|
72
|
+
}
|
|
73
|
+
export declare function batchRequest<T>(method: string, path: string, opts: RequestOpts): Promise<T>;
|
|
74
|
+
interface CallOpts {
|
|
75
|
+
apiKey: string;
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
userAgent?: string;
|
|
78
|
+
fetchImpl?: typeof fetch;
|
|
79
|
+
}
|
|
80
|
+
export declare function createJob(body: unknown, opts: CallOpts): Promise<Job>;
|
|
81
|
+
export declare function getJob(id: string, opts: CallOpts): Promise<Job>;
|
|
82
|
+
export declare function stopJob(id: string, opts: CallOpts): Promise<Job>;
|
|
83
|
+
export declare function listResults(id: string, opts: CallOpts & {
|
|
84
|
+
status?: "successful" | "failed" | "all";
|
|
85
|
+
}): Promise<ResultRow[]>;
|
|
86
|
+
export declare function waitForJob(id: string, opts: CallOpts & {
|
|
87
|
+
pollTimeoutMs?: number;
|
|
88
|
+
}): Promise<Job>;
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the Zenrows Batch API (https://async.api.zenrows.com/v1).
|
|
3
|
+
* Auth via X-API-Key header. Errors are application/problem+json (RFC 7807).
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1";
|
|
6
|
+
export const BATCH_API_BASE_ENV = "ZENROWS_BATCH_API_BASE";
|
|
7
|
+
export function batchBase() {
|
|
8
|
+
const env = process.env[BATCH_API_BASE_ENV];
|
|
9
|
+
const base = env && env.trim() ? env.trim() : DEFAULT_BATCH_API_BASE;
|
|
10
|
+
return base.replace(/\/+$/, "");
|
|
11
|
+
}
|
|
12
|
+
export const TERMINAL_STATUSES = new Set(["completed", "stopped", "deleted"]);
|
|
13
|
+
export class BatchError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
status;
|
|
16
|
+
detail;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
super(opts.message);
|
|
19
|
+
this.name = "BatchError";
|
|
20
|
+
this.code = opts.code;
|
|
21
|
+
this.status = opts.status;
|
|
22
|
+
this.detail = opts.detail;
|
|
23
|
+
}
|
|
24
|
+
toJSON() {
|
|
25
|
+
return { code: this.code, message: this.message, status: this.status, detail: this.detail };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function batchRequest(method, path, opts) {
|
|
29
|
+
const url = new URL(batchBase() + path);
|
|
30
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
31
|
+
if (v !== undefined && v !== null)
|
|
32
|
+
url.searchParams.set(k, v);
|
|
33
|
+
}
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
|
|
36
|
+
const headers = {
|
|
37
|
+
"X-API-Key": opts.apiKey,
|
|
38
|
+
Accept: "application/json",
|
|
39
|
+
"User-Agent": opts.userAgent ?? "zenrows/mcp",
|
|
40
|
+
};
|
|
41
|
+
if (opts.body !== undefined)
|
|
42
|
+
headers["Content-Type"] = "application/json";
|
|
43
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await doFetch(url.toString(), {
|
|
47
|
+
method,
|
|
48
|
+
headers,
|
|
49
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
throw new BatchError({
|
|
56
|
+
code: "BACKEND_UNAVAILABLE",
|
|
57
|
+
message: `Could not reach the Zenrows Batch API: ${err instanceof Error ? err.message : String(err)}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
if (res.status < 200 || res.status >= 300) {
|
|
63
|
+
throw problemToError(res.status, text, method, path);
|
|
64
|
+
}
|
|
65
|
+
if (!text)
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new BatchError({
|
|
72
|
+
code: "BATCH_FAILED",
|
|
73
|
+
message: "The Batch API response was not valid JSON.",
|
|
74
|
+
detail: text.slice(0, 240),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function problemToError(status, body, method, path) {
|
|
79
|
+
let problem = {};
|
|
80
|
+
try {
|
|
81
|
+
problem = JSON.parse(body);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// non-JSON — fall through
|
|
85
|
+
}
|
|
86
|
+
const serverCode = problem.code ?? "";
|
|
87
|
+
const detail = problem.detail || problem.title || body.slice(0, 240) || `HTTP ${status}`;
|
|
88
|
+
const cause = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${method} ${path}: ${detail}`;
|
|
89
|
+
if (status === 403) {
|
|
90
|
+
return new BatchError({
|
|
91
|
+
code: "BATCH_ACCESS_DENIED",
|
|
92
|
+
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.",
|
|
93
|
+
status,
|
|
94
|
+
detail: cause,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (status === 401) {
|
|
98
|
+
return new BatchError({
|
|
99
|
+
code: "AUTH_INVALID",
|
|
100
|
+
message: "Zenrows rejected the API key for the Batch API.",
|
|
101
|
+
status,
|
|
102
|
+
detail: cause,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (status === 404) {
|
|
106
|
+
return new BatchError({
|
|
107
|
+
code: "BATCH_NOT_FOUND",
|
|
108
|
+
message: "Batch job, run, or task not found.",
|
|
109
|
+
status,
|
|
110
|
+
detail: cause,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (status === 429) {
|
|
114
|
+
return new BatchError({
|
|
115
|
+
code: "BATCH_QUOTA_EXCEEDED",
|
|
116
|
+
message: "Batch quota exceeded (e.g. max concurrent active jobs). Wait for an in-flight job to finish or cancel one, then retry.",
|
|
117
|
+
status,
|
|
118
|
+
detail: cause,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (status === 402) {
|
|
122
|
+
return new BatchError({
|
|
123
|
+
code: "BATCH_QUOTA_EXCEEDED",
|
|
124
|
+
message: "Subscription has no credit available for the Batch API.",
|
|
125
|
+
status,
|
|
126
|
+
detail: cause,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const invalid = problem.invalid_tasks?.length
|
|
130
|
+
? ` invalid_tasks: ${problem.invalid_tasks
|
|
131
|
+
.slice(0, 10)
|
|
132
|
+
.map((t) => `#${t.index}: ${t.reason}`)
|
|
133
|
+
.join("; ")}`
|
|
134
|
+
: "";
|
|
135
|
+
return new BatchError({
|
|
136
|
+
code: "BATCH_FAILED",
|
|
137
|
+
message: `Batch request failed (HTTP ${status}).${invalid}`,
|
|
138
|
+
status,
|
|
139
|
+
detail: cause,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export function createJob(body, opts) {
|
|
143
|
+
return batchRequest("POST", "/jobs", { ...opts, body });
|
|
144
|
+
}
|
|
145
|
+
export function getJob(id, opts) {
|
|
146
|
+
return batchRequest("GET", `/jobs/${encodeURIComponent(id)}`, opts);
|
|
147
|
+
}
|
|
148
|
+
export function stopJob(id, opts) {
|
|
149
|
+
return batchRequest("POST", `/jobs/${encodeURIComponent(id)}/stop`, opts);
|
|
150
|
+
}
|
|
151
|
+
export async function listResults(id, opts) {
|
|
152
|
+
const all = [];
|
|
153
|
+
let cursor;
|
|
154
|
+
do {
|
|
155
|
+
const page = await batchRequest("GET", `/jobs/${encodeURIComponent(id)}/results`, {
|
|
156
|
+
...opts,
|
|
157
|
+
query: { status: opts.status, cursor },
|
|
158
|
+
});
|
|
159
|
+
if (page?.results)
|
|
160
|
+
all.push(...page.results);
|
|
161
|
+
cursor = page?.next_cursor ?? undefined;
|
|
162
|
+
} while (cursor);
|
|
163
|
+
return all;
|
|
164
|
+
}
|
|
165
|
+
export async function waitForJob(id, opts) {
|
|
166
|
+
const deadline = Date.now() + (opts.pollTimeoutMs ?? opts.timeoutMs ?? 600_000);
|
|
167
|
+
let delay = 2000;
|
|
168
|
+
for (;;) {
|
|
169
|
+
const job = await getJob(id, opts);
|
|
170
|
+
if (job.latest_run && TERMINAL_STATUSES.has(job.latest_run.status))
|
|
171
|
+
return job;
|
|
172
|
+
if (Date.now() > deadline) {
|
|
173
|
+
throw new BatchError({
|
|
174
|
+
code: "BATCH_FAILED",
|
|
175
|
+
message: `Timed out waiting for batch job ${id} to finish.`,
|
|
176
|
+
detail: `The run did not reach a terminal state within ${Math.round((opts.pollTimeoutMs ?? opts.timeoutMs ?? 600_000) / 1000)}s.`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
180
|
+
delay = Math.min(delay * 1.5, 15_000);
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { cors } from "hono/cors";
|
|
3
4
|
import { serve } from "@hono/node-server";
|
|
4
5
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
5
6
|
import { createServer } from "./server.js";
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require("../package.json");
|
|
6
9
|
// ─── app ──────────────────────────────────────────────────────────────────────
|
|
7
10
|
const app = new Hono();
|
|
8
11
|
// CORS — required for browser-side MCP clients (Claude.ai, Cursor web, etc.)
|
|
@@ -53,7 +56,7 @@ function mcpServerCard() {
|
|
|
53
56
|
return {
|
|
54
57
|
$schema: "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
|
|
55
58
|
name: "io.zenrows/mcp",
|
|
56
|
-
version:
|
|
59
|
+
version: pkg.version,
|
|
57
60
|
description: "ZenRows MCP — scrape and extract from protected sites via the Universal Scraper API (anti-bot bypass, JS rendering, proxies).",
|
|
58
61
|
websiteUrl: "https://www.zenrows.com/mcp",
|
|
59
62
|
remotes: [
|
|
@@ -69,7 +72,7 @@ function mcpServerCard() {
|
|
|
69
72
|
protocolVersion: "2025-06-18",
|
|
70
73
|
serverInfo: {
|
|
71
74
|
name: "ZenRows",
|
|
72
|
-
version:
|
|
75
|
+
version: pkg.version,
|
|
73
76
|
description: "ZenRows Universal Scraper API via MCP",
|
|
74
77
|
homepage: "https://www.zenrows.com/mcp",
|
|
75
78
|
},
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "module";
|
|
2
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { AuthError, ensureApiKey, getZenrowsDir, resolveApiKey, } from "./auth/ensure-key.js";
|
|
3
5
|
import { createServer } from "./server.js";
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const pkg = require("../package.json");
|
|
8
|
+
let apiKey;
|
|
9
|
+
try {
|
|
10
|
+
const existing = resolveApiKey();
|
|
11
|
+
if (existing.key) {
|
|
12
|
+
process.stderr.write(`Using existing API key from ${existing.source} (secrets dir: ${getZenrowsDir()})\n`);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
const signup = process.env.ZENROWS_AGENT_SIGNUP_URL?.trim() ||
|
|
16
|
+
"https://app.zenrows.com/api/agent/signup (default prod)";
|
|
17
|
+
process.stderr.write(`No API key — will auto-signup via: ${signup}\n`);
|
|
18
|
+
}
|
|
19
|
+
const resolved = await ensureApiKey({
|
|
20
|
+
userAgent: `zenrows/mcp ${pkg.version}`,
|
|
21
|
+
onProvision: (acct) => {
|
|
22
|
+
process.stderr.write(`Created a Zenrows Free plan account.\n` +
|
|
23
|
+
`Claim it anytime (keeps your usage): ${acct.claimUrl}\n` +
|
|
24
|
+
`Key stored in ${getZenrowsDir()}/secrets.json\n`);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
apiKey = resolved.apiKey;
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const msg = err instanceof AuthError
|
|
31
|
+
? `Error: ${err.message}\n`
|
|
32
|
+
: `Error: ${err instanceof Error ? err.message : String(err)}\n`;
|
|
33
|
+
process.stderr.write(msg);
|
|
7
34
|
process.exit(1);
|
|
8
35
|
}
|
|
9
36
|
const server = createServer(apiKey);
|
|
10
37
|
const transport = new StdioServerTransport();
|
|
11
38
|
await server.connect(transport);
|
|
12
|
-
process.stderr.write(
|
|
39
|
+
process.stderr.write(`Zenrows MCP server running on stdio (secrets dir: ${getZenrowsDir()})\n`);
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { getZenrowsDir, readAccount } from "./auth/ensure-key.js";
|
|
5
|
+
import { registerBatchTools } from "./tools/batch.js";
|
|
4
6
|
import { registerBrowserTools } from "./tools/browser.js";
|
|
7
|
+
import { registerExtractTool } from "./tools/extract.js";
|
|
5
8
|
const require = createRequire(import.meta.url);
|
|
6
9
|
const pkg = require("../package.json");
|
|
7
10
|
const ZENROWS_API_URL = "https://api.zenrows.com/v1/";
|
|
@@ -23,24 +26,22 @@ export function createServer(apiKey, clientName) {
|
|
|
23
26
|
readOnlyHint: true,
|
|
24
27
|
destructiveHint: false,
|
|
25
28
|
},
|
|
26
|
-
description: `Scrape any webpage and return its content using Zenrows.
|
|
29
|
+
description: `Scrape any webpage and return its content using Zenrows (Fetch).
|
|
27
30
|
|
|
28
|
-
Use
|
|
29
|
-
|
|
31
|
+
Use for full-page content (markdown/HTML/PDF/screenshot). For structured JSON
|
|
32
|
+
fields (products, articles, listings), prefer the extract tool when it fits —
|
|
33
|
+
it returns parsed fields instead of a full page body.
|
|
30
34
|
|
|
31
35
|
When to enable options:
|
|
32
36
|
- js_render: page uses React/Vue/Angular, loads content dynamically, or content
|
|
33
37
|
appears missing on the first attempt
|
|
34
38
|
- premium_proxy: site returns 403/blocked errors even with js_render enabled
|
|
35
39
|
- wait_for: specific content loads after initial render (requires js_render)
|
|
36
|
-
- css_extractor: you only need specific elements, not the whole page
|
|
37
|
-
- autoparse: structured data pages like products or articles
|
|
38
40
|
|
|
39
41
|
Examples:
|
|
40
42
|
Basic: { url: "https://example.com" }
|
|
41
43
|
Dynamic: { url: "https://spa.com", js_render: true }
|
|
42
|
-
Protected:{ url: "https://protected.com", js_render: true, premium_proxy: true }
|
|
43
|
-
Extract: { url: "https://shop.com", css_extractor: '{"title":"h1","price":".price"}' }`,
|
|
44
|
+
Protected:{ url: "https://protected.com", js_render: true, premium_proxy: true }`,
|
|
44
45
|
inputSchema: {
|
|
45
46
|
url: z.string().url().describe("The webpage URL to scrape"),
|
|
46
47
|
js_render: z
|
|
@@ -232,7 +233,7 @@ Examples:
|
|
|
232
233
|
}));
|
|
233
234
|
server.registerPrompt("extract_structured_data", {
|
|
234
235
|
title: "Extract Structured Data",
|
|
235
|
-
description: "
|
|
236
|
+
description: "Extract specific structured data from a webpage using CSS selectors.",
|
|
236
237
|
argsSchema: {
|
|
237
238
|
url: z.string().url().describe("The webpage URL to extract data from"),
|
|
238
239
|
fields: z
|
|
@@ -245,7 +246,7 @@ Examples:
|
|
|
245
246
|
role: "user",
|
|
246
247
|
content: {
|
|
247
248
|
type: "text",
|
|
248
|
-
text: `
|
|
249
|
+
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.`,
|
|
249
250
|
},
|
|
250
251
|
},
|
|
251
252
|
],
|
|
@@ -267,7 +268,39 @@ Examples:
|
|
|
267
268
|
},
|
|
268
269
|
],
|
|
269
270
|
}));
|
|
271
|
+
registerExtractTool(server, apiKey, getClientName);
|
|
272
|
+
registerBatchTools(server, apiKey);
|
|
270
273
|
const BROWSER_URL = process.env.ZENROWS_BROWSER_URL ?? "https://mcp.zenrows.com";
|
|
271
274
|
registerBrowserTools(server, apiKey, BROWSER_URL, getClientName);
|
|
275
|
+
// Always expose account resource; handler re-reads disk so ZENROWS_HOME is visible.
|
|
276
|
+
server.registerResource("zenrows-account", "zenrows://account", {
|
|
277
|
+
description: "Local Zenrows agent account metadata (claim URL for unclaimed Free plans). Re-reads ~/.zenrows or $ZENROWS_HOME on each read.",
|
|
278
|
+
mimeType: "application/json",
|
|
279
|
+
}, async () => {
|
|
280
|
+
const acct = readAccount();
|
|
281
|
+
const home = getZenrowsDir();
|
|
282
|
+
const body = acct
|
|
283
|
+
? {
|
|
284
|
+
accountId: acct.accountId,
|
|
285
|
+
unclaimed: acct.unclaimed,
|
|
286
|
+
claimUrl: acct.claimUrl,
|
|
287
|
+
createdAt: acct.createdAt,
|
|
288
|
+
zenrowsHome: home,
|
|
289
|
+
}
|
|
290
|
+
: {
|
|
291
|
+
unclaimed: false,
|
|
292
|
+
message: "No local agent account file (key from env or missing).",
|
|
293
|
+
zenrowsHome: home,
|
|
294
|
+
};
|
|
295
|
+
return {
|
|
296
|
+
contents: [
|
|
297
|
+
{
|
|
298
|
+
uri: "zenrows://account",
|
|
299
|
+
mimeType: "application/json",
|
|
300
|
+
text: JSON.stringify(body, null, 2),
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
});
|
|
272
305
|
return server;
|
|
273
306
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BatchError, createJob, getJob, listResults, stopJob, waitForJob, } from "../batch-api.js";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const pkg = require("../../package.json");
|
|
6
|
+
function err(data) {
|
|
7
|
+
return {
|
|
8
|
+
content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data) }],
|
|
9
|
+
isError: true,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function json(data) {
|
|
13
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
14
|
+
}
|
|
15
|
+
function batchErr(e) {
|
|
16
|
+
if (e instanceof BatchError)
|
|
17
|
+
return err(e.toJSON());
|
|
18
|
+
return err({
|
|
19
|
+
code: "BATCH_FAILED",
|
|
20
|
+
message: e instanceof Error ? e.message : String(e),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function normalizeParams(obj) {
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
26
|
+
if (v === undefined || v === null)
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof v === "string")
|
|
29
|
+
out[k] = v;
|
|
30
|
+
else if (typeof v === "boolean" || typeof v === "number")
|
|
31
|
+
out[k] = String(v);
|
|
32
|
+
else
|
|
33
|
+
out[k] = JSON.stringify(v);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
const taskSchema = z.object({
|
|
38
|
+
url: z.string().url().describe("Target URL for this task"),
|
|
39
|
+
external_id: z.string().optional().describe("Optional stable id echoed back on results"),
|
|
40
|
+
metadata: z.unknown().optional().describe("Opaque per-task metadata carried through to results"),
|
|
41
|
+
zenrows_params: z
|
|
42
|
+
.record(z.union([z.string(), z.number(), z.boolean()]))
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Per-task Zenrows scrape params (js_render, premium_proxy, extract, autoparse, …)"),
|
|
45
|
+
});
|
|
46
|
+
export function registerBatchTools(server, apiKey) {
|
|
47
|
+
const ua = `zenrows/mcp ${pkg.version}`;
|
|
48
|
+
const call = { apiKey, userAgent: ua };
|
|
49
|
+
server.registerTool("batch_create", {
|
|
50
|
+
annotations: { title: "Create Batch Job", readOnlyHint: false, destructiveHint: false },
|
|
51
|
+
description: `Submit a cloud Batch job that fans out many URLs asynchronously (Zenrows Batch API beta).
|
|
52
|
+
|
|
53
|
+
NOT the same as browser_batch — this hits https://async.api.zenrows.com/v1 with X-API-Key.
|
|
54
|
+
Use for large URL lists; prefer scrape/extract for one-off pages.
|
|
55
|
+
|
|
56
|
+
Returns job_id + latest_run.status/stats. Poll with batch_status / batch_wait, then batch_results.
|
|
57
|
+
If you get BATCH_ACCESS_DENIED, the account lacks Batch beta access.`,
|
|
58
|
+
inputSchema: {
|
|
59
|
+
tasks: z
|
|
60
|
+
.array(taskSchema)
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("List of tasks (each needs a url). Prefer this over urls when you need per-task params."),
|
|
63
|
+
urls: z
|
|
64
|
+
.array(z.string().url())
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("Shorthand: list of URLs (converted to tasks). Ignored when tasks is provided."),
|
|
67
|
+
js_render: z.boolean().optional().describe("Job-level js_render for all tasks"),
|
|
68
|
+
premium_proxy: z.boolean().optional().describe("Job-level premium_proxy for all tasks"),
|
|
69
|
+
proxy_country: z
|
|
70
|
+
.string()
|
|
71
|
+
.optional()
|
|
72
|
+
.describe("Job-level ISO country code (requires premium_proxy or mode=auto)"),
|
|
73
|
+
response_type: z
|
|
74
|
+
.enum(["markdown", "plaintext", "html", "pdf"])
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("Job-level response_type"),
|
|
77
|
+
zenrows_params: z
|
|
78
|
+
.record(z.union([z.string(), z.number(), z.boolean()]))
|
|
79
|
+
.optional()
|
|
80
|
+
.describe("Additional job-level zenrows_params merged with the flags above"),
|
|
81
|
+
wait: z
|
|
82
|
+
.boolean()
|
|
83
|
+
.optional()
|
|
84
|
+
.describe("If true, poll until the job reaches a terminal state before returning"),
|
|
85
|
+
wait_timeout_ms: z
|
|
86
|
+
.number()
|
|
87
|
+
.int()
|
|
88
|
+
.min(1000)
|
|
89
|
+
.max(3_600_000)
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("Max wait time when wait=true (default 600000)"),
|
|
92
|
+
},
|
|
93
|
+
}, async (params) => {
|
|
94
|
+
const tasksIn = params.tasks && params.tasks.length > 0
|
|
95
|
+
? params.tasks
|
|
96
|
+
: (params.urls ?? []).map((url) => ({ url }));
|
|
97
|
+
if (!tasksIn.length) {
|
|
98
|
+
return err({
|
|
99
|
+
code: "INVALID_USAGE",
|
|
100
|
+
message: "Provide tasks (preferred) or urls with at least one URL.",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const jobParams = { ...(params.zenrows_params ?? {}) };
|
|
104
|
+
if (params.js_render)
|
|
105
|
+
jobParams.js_render = true;
|
|
106
|
+
if (params.premium_proxy)
|
|
107
|
+
jobParams.premium_proxy = true;
|
|
108
|
+
if (params.proxy_country)
|
|
109
|
+
jobParams.proxy_country = params.proxy_country.toLowerCase();
|
|
110
|
+
if (params.response_type)
|
|
111
|
+
jobParams.response_type = params.response_type;
|
|
112
|
+
const body = {
|
|
113
|
+
type: "regular",
|
|
114
|
+
status: "closed",
|
|
115
|
+
tasks: tasksIn.map((t) => {
|
|
116
|
+
const task = { url: t.url };
|
|
117
|
+
if (t.external_id)
|
|
118
|
+
task.external_id = t.external_id;
|
|
119
|
+
if (t.metadata !== undefined)
|
|
120
|
+
task.metadata = t.metadata;
|
|
121
|
+
if (t.zenrows_params)
|
|
122
|
+
task.zenrows_params = normalizeParams(t.zenrows_params);
|
|
123
|
+
return task;
|
|
124
|
+
}),
|
|
125
|
+
...(Object.keys(jobParams).length
|
|
126
|
+
? { zenrows_params: normalizeParams(jobParams) }
|
|
127
|
+
: {}),
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const job = await createJob(body, call);
|
|
131
|
+
const finished = params.wait === true
|
|
132
|
+
? await waitForJob(job.job_id, {
|
|
133
|
+
...call,
|
|
134
|
+
pollTimeoutMs: params.wait_timeout_ms ?? 600_000,
|
|
135
|
+
})
|
|
136
|
+
: job;
|
|
137
|
+
const run = finished.latest_run ?? {};
|
|
138
|
+
return json({
|
|
139
|
+
ok: true,
|
|
140
|
+
job_id: finished.job_id,
|
|
141
|
+
status: run.status,
|
|
142
|
+
stats: run.stats,
|
|
143
|
+
job: finished,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
return batchErr(e);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
server.registerTool("batch_status", {
|
|
151
|
+
annotations: { title: "Batch Job Status", readOnlyHint: true, destructiveHint: false },
|
|
152
|
+
description: "Get status and stats for a Zenrows Batch job (latest_run.status + latest_run.stats).",
|
|
153
|
+
inputSchema: {
|
|
154
|
+
job_id: z.string().describe("Batch job id returned by batch_create"),
|
|
155
|
+
},
|
|
156
|
+
}, async ({ job_id }) => {
|
|
157
|
+
try {
|
|
158
|
+
const job = await getJob(job_id, call);
|
|
159
|
+
const run = job.latest_run ?? {};
|
|
160
|
+
return json({
|
|
161
|
+
ok: true,
|
|
162
|
+
job_id: job.job_id,
|
|
163
|
+
status: run.status,
|
|
164
|
+
stats: run.stats,
|
|
165
|
+
job,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
return batchErr(e);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
server.registerTool("batch_results", {
|
|
173
|
+
annotations: { title: "Batch Job Results", readOnlyHint: true, destructiveHint: false },
|
|
174
|
+
description: `List result rows for a Batch job (cursor-paginated server-side; returns the full list).
|
|
175
|
+
|
|
176
|
+
Each row may include task_id, external_id, status, and a short-lived result_url for the body.
|
|
177
|
+
Download result_url soon — presigned links expire.`,
|
|
178
|
+
inputSchema: {
|
|
179
|
+
job_id: z.string().describe("Batch job id"),
|
|
180
|
+
status: z
|
|
181
|
+
.enum(["successful", "failed", "all"])
|
|
182
|
+
.optional()
|
|
183
|
+
.describe("Filter results by status (default: all)"),
|
|
184
|
+
},
|
|
185
|
+
}, async ({ job_id, status }) => {
|
|
186
|
+
try {
|
|
187
|
+
const results = await listResults(job_id, { ...call, status });
|
|
188
|
+
return json({ ok: true, job_id, count: results.length, results });
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return batchErr(e);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
server.registerTool("batch_cancel", {
|
|
195
|
+
annotations: { title: "Cancel Batch Job", readOnlyHint: false, destructiveHint: true },
|
|
196
|
+
description: "Stop an in-flight Batch job run (POST /jobs/:id/stop).",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
job_id: z.string().describe("Batch job id to stop"),
|
|
199
|
+
},
|
|
200
|
+
}, async ({ job_id }) => {
|
|
201
|
+
try {
|
|
202
|
+
const job = await stopJob(job_id, call);
|
|
203
|
+
const run = job.latest_run ?? {};
|
|
204
|
+
return json({
|
|
205
|
+
ok: true,
|
|
206
|
+
job_id: job.job_id,
|
|
207
|
+
status: run.status,
|
|
208
|
+
stats: run.stats,
|
|
209
|
+
job,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
return batchErr(e);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
server.registerTool("batch_wait", {
|
|
217
|
+
annotations: { title: "Wait for Batch Job", readOnlyHint: true, destructiveHint: false },
|
|
218
|
+
description: "Poll batch_status until the job reaches a terminal state (completed, stopped, or deleted).",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
job_id: z.string().describe("Batch job id"),
|
|
221
|
+
timeout_ms: z
|
|
222
|
+
.number()
|
|
223
|
+
.int()
|
|
224
|
+
.min(1000)
|
|
225
|
+
.max(3_600_000)
|
|
226
|
+
.optional()
|
|
227
|
+
.describe("Max wait time in ms (default 600000)"),
|
|
228
|
+
},
|
|
229
|
+
}, async ({ job_id, timeout_ms }) => {
|
|
230
|
+
try {
|
|
231
|
+
const job = await waitForJob(job_id, { ...call, pollTimeoutMs: timeout_ms ?? 600_000 });
|
|
232
|
+
const run = job.latest_run ?? {};
|
|
233
|
+
return json({
|
|
234
|
+
ok: true,
|
|
235
|
+
job_id: job.job_id,
|
|
236
|
+
status: run.status,
|
|
237
|
+
stats: run.stats,
|
|
238
|
+
job,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
return batchErr(e);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export declare function zrErrorCode(body: string): string | undefined;
|
|
3
|
+
export type ExtractMode = "auto" | "autoparse" | "css";
|
|
4
|
+
export type ExtractStealthOpts = {
|
|
5
|
+
css_extractor?: string;
|
|
6
|
+
js_render?: boolean;
|
|
7
|
+
premium_proxy?: boolean;
|
|
8
|
+
proxy_country?: string;
|
|
9
|
+
mode_auto?: boolean;
|
|
10
|
+
wait_for?: string;
|
|
11
|
+
wait?: number;
|
|
12
|
+
};
|
|
13
|
+
export type ExtractInput = ExtractStealthOpts & {
|
|
14
|
+
url: string;
|
|
15
|
+
mode?: ExtractMode;
|
|
16
|
+
fallback_autoparse?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type ExtractSuccess = {
|
|
19
|
+
ok: true;
|
|
20
|
+
mode: ExtractMode;
|
|
21
|
+
fellBackToAutoparse: boolean;
|
|
22
|
+
empty: boolean;
|
|
23
|
+
data: unknown;
|
|
24
|
+
html?: string;
|
|
25
|
+
raw?: string;
|
|
26
|
+
};
|
|
27
|
+
export type ExtractFailure = {
|
|
28
|
+
ok: false;
|
|
29
|
+
errorText: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function buildExtractParams(apiKey: string, url: string, mode: ExtractMode, opts: ExtractStealthOpts): URLSearchParams;
|
|
32
|
+
/**
|
|
33
|
+
* Core extract logic (testable). AUTH010 on mode=auto retries once with autoparse
|
|
34
|
+
* unless fallback_autoparse is false — same behavior as the CLI extract adapter.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runExtract(apiKey: string, params: ExtractInput, options?: {
|
|
37
|
+
getClientName?: () => string | undefined;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
}): Promise<ExtractSuccess | ExtractFailure>;
|
|
40
|
+
export declare function registerExtractTool(server: McpServer, apiKey: string, getClientName: () => string | undefined): void;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const pkg = require("../../package.json");
|
|
5
|
+
const ZENROWS_API_URL = "https://api.zenrows.com/v1/";
|
|
6
|
+
function err(text) {
|
|
7
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
8
|
+
}
|
|
9
|
+
function json(data) {
|
|
10
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
11
|
+
}
|
|
12
|
+
export function zrErrorCode(body) {
|
|
13
|
+
try {
|
|
14
|
+
const j = JSON.parse(body);
|
|
15
|
+
if (typeof j.code === "string")
|
|
16
|
+
return j.code;
|
|
17
|
+
const m = typeof j.error === "string" ? j.error.match(/\((AUTH\d+)\)/) : null;
|
|
18
|
+
return m?.[1];
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isEmptyData(data) {
|
|
25
|
+
if (data === null || data === undefined)
|
|
26
|
+
return true;
|
|
27
|
+
if (Array.isArray(data))
|
|
28
|
+
return data.length === 0;
|
|
29
|
+
if (typeof data === "object")
|
|
30
|
+
return Object.keys(data).length === 0;
|
|
31
|
+
if (typeof data === "string")
|
|
32
|
+
return data.trim() === "";
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
export function buildExtractParams(apiKey, url, mode, opts) {
|
|
36
|
+
const sp = new URLSearchParams({ apikey: apiKey, url });
|
|
37
|
+
if (mode === "auto")
|
|
38
|
+
sp.set("extract", "auto");
|
|
39
|
+
if (mode === "autoparse")
|
|
40
|
+
sp.set("autoparse", "true");
|
|
41
|
+
if (mode === "css" && opts.css_extractor)
|
|
42
|
+
sp.set("css_extractor", opts.css_extractor);
|
|
43
|
+
if (opts.mode_auto)
|
|
44
|
+
sp.set("mode", "auto");
|
|
45
|
+
if (opts.js_render)
|
|
46
|
+
sp.set("js_render", "true");
|
|
47
|
+
if (opts.premium_proxy)
|
|
48
|
+
sp.set("premium_proxy", "true");
|
|
49
|
+
if (opts.proxy_country)
|
|
50
|
+
sp.set("proxy_country", opts.proxy_country.toUpperCase());
|
|
51
|
+
if (opts.wait_for)
|
|
52
|
+
sp.set("wait_for", opts.wait_for);
|
|
53
|
+
if (opts.wait != null)
|
|
54
|
+
sp.set("wait", String(opts.wait));
|
|
55
|
+
return sp;
|
|
56
|
+
}
|
|
57
|
+
async function callZenrows(apiKey, searchParams, getClientName, fetchImpl) {
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetchImpl(`${ZENROWS_API_URL}?${searchParams}`, {
|
|
61
|
+
headers: {
|
|
62
|
+
"User-Agent": `zenrows/mcp ${pkg.version}`,
|
|
63
|
+
...(getClientName() ? { "x-mcp-client-name": getClientName() } : {}),
|
|
64
|
+
"x-mcp-tool": "extract",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
status: 0,
|
|
72
|
+
body: `Network error contacting Zenrows: ${e instanceof Error ? e.message : String(e)}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { ok: response.ok, status: response.status, body: await response.text() };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Core extract logic (testable). AUTH010 on mode=auto retries once with autoparse
|
|
79
|
+
* unless fallback_autoparse is false — same behavior as the CLI extract adapter.
|
|
80
|
+
*/
|
|
81
|
+
export async function runExtract(apiKey, params, options = {}) {
|
|
82
|
+
const getClientName = options.getClientName ?? (() => undefined);
|
|
83
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
84
|
+
const mode = params.mode ?? "auto";
|
|
85
|
+
if (mode === "css" && !params.css_extractor) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
errorText: JSON.stringify({
|
|
89
|
+
code: "INVALID_USAGE",
|
|
90
|
+
message: "mode=css requires css_extractor JSON selector map.",
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const opts = {
|
|
95
|
+
css_extractor: params.css_extractor,
|
|
96
|
+
js_render: params.js_render,
|
|
97
|
+
premium_proxy: params.premium_proxy,
|
|
98
|
+
proxy_country: params.proxy_country,
|
|
99
|
+
mode_auto: params.mode_auto,
|
|
100
|
+
wait_for: params.wait_for,
|
|
101
|
+
wait: params.wait,
|
|
102
|
+
};
|
|
103
|
+
let usedMode = mode;
|
|
104
|
+
let result = await callZenrows(apiKey, buildExtractParams(apiKey, params.url, mode, opts), getClientName, fetchImpl);
|
|
105
|
+
let fellBackToAutoparse = false;
|
|
106
|
+
if (!result.ok &&
|
|
107
|
+
mode === "auto" &&
|
|
108
|
+
params.fallback_autoparse !== false &&
|
|
109
|
+
result.status === 402 &&
|
|
110
|
+
zrErrorCode(result.body) === "AUTH010") {
|
|
111
|
+
usedMode = "autoparse";
|
|
112
|
+
fellBackToAutoparse = true;
|
|
113
|
+
result = await callZenrows(apiKey, buildExtractParams(apiKey, params.url, "autoparse", opts), getClientName, fetchImpl);
|
|
114
|
+
}
|
|
115
|
+
if (!result.ok) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
errorText: result.status === 0
|
|
119
|
+
? result.body
|
|
120
|
+
: JSON.stringify({
|
|
121
|
+
code: zrErrorCode(result.body) ?? "EXTRACT_FAILED",
|
|
122
|
+
message: `Zenrows error ${result.status}`,
|
|
123
|
+
detail: result.body.slice(0, 500),
|
|
124
|
+
mode: usedMode,
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(result.body);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
mode: usedMode,
|
|
136
|
+
fellBackToAutoparse,
|
|
137
|
+
empty: true,
|
|
138
|
+
data: null,
|
|
139
|
+
raw: result.body,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
let data = parsed;
|
|
143
|
+
let html;
|
|
144
|
+
if (usedMode === "auto" && parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
145
|
+
const envelope = parsed;
|
|
146
|
+
if ("parsed" in envelope) {
|
|
147
|
+
data = envelope.parsed;
|
|
148
|
+
html = typeof envelope.html === "string" ? envelope.html : undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
mode: usedMode,
|
|
154
|
+
fellBackToAutoparse,
|
|
155
|
+
empty: isEmptyData(data),
|
|
156
|
+
data,
|
|
157
|
+
...(html !== undefined ? { html } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export function registerExtractTool(server, apiKey, getClientName) {
|
|
161
|
+
server.registerTool("extract", {
|
|
162
|
+
annotations: {
|
|
163
|
+
title: "Extract Structured Data",
|
|
164
|
+
readOnlyHint: true,
|
|
165
|
+
destructiveHint: false,
|
|
166
|
+
},
|
|
167
|
+
description: `Extract structured data from a webpage via Zenrows.
|
|
168
|
+
|
|
169
|
+
Prefer this over scrape when you need JSON fields (products, articles, listings)
|
|
170
|
+
rather than a full page body.
|
|
171
|
+
Modes:
|
|
172
|
+
- auto (default): extract=auto — site-tailored Extract (open beta; currently free,
|
|
173
|
+
billing may apply later; may fall back to autoparse if the domain is not enabled)
|
|
174
|
+
- autoparse: general-purpose structured JSON on any domain
|
|
175
|
+
- css: css_extractor with an explicit selector map
|
|
176
|
+
|
|
177
|
+
Stealth: js_render, premium_proxy, proxy_country, or mode_auto (Adaptive Stealth Mode).
|
|
178
|
+
For full-page markdown/HTML/screenshots, use scrape instead.`,
|
|
179
|
+
inputSchema: {
|
|
180
|
+
url: z.string().url().describe("The webpage URL to extract from"),
|
|
181
|
+
mode: z
|
|
182
|
+
.enum(["auto", "autoparse", "css"])
|
|
183
|
+
.optional()
|
|
184
|
+
.default("auto")
|
|
185
|
+
.describe("Extraction mode: auto (extract=auto, default), autoparse, or css (requires css_extractor)"),
|
|
186
|
+
css_extractor: z
|
|
187
|
+
.string()
|
|
188
|
+
.optional()
|
|
189
|
+
.describe('Required when mode=css. JSON map of field→selector, e.g. \'{"title":"h1","price":".price"}\''),
|
|
190
|
+
js_render: z
|
|
191
|
+
.boolean()
|
|
192
|
+
.optional()
|
|
193
|
+
.describe("Enable headless JS rendering (SPAs / dynamic content)"),
|
|
194
|
+
premium_proxy: z
|
|
195
|
+
.boolean()
|
|
196
|
+
.optional()
|
|
197
|
+
.describe("Use premium residential proxies (anti-bot). Higher credit cost."),
|
|
198
|
+
proxy_country: z
|
|
199
|
+
.string()
|
|
200
|
+
.optional()
|
|
201
|
+
.describe("ISO 3166-1 alpha-2 country code. Requires premium_proxy or mode_auto."),
|
|
202
|
+
mode_auto: z
|
|
203
|
+
.boolean()
|
|
204
|
+
.optional()
|
|
205
|
+
.describe("Enable Adaptive Stealth Mode (mode=auto) for tougher sites"),
|
|
206
|
+
wait_for: z
|
|
207
|
+
.string()
|
|
208
|
+
.optional()
|
|
209
|
+
.describe("CSS selector to wait for before extracting. Requires js_render."),
|
|
210
|
+
wait: z
|
|
211
|
+
.number()
|
|
212
|
+
.int()
|
|
213
|
+
.min(0)
|
|
214
|
+
.max(30000)
|
|
215
|
+
.optional()
|
|
216
|
+
.describe("Milliseconds to wait after load. Requires js_render."),
|
|
217
|
+
fallback_autoparse: z
|
|
218
|
+
.boolean()
|
|
219
|
+
.optional()
|
|
220
|
+
.default(true)
|
|
221
|
+
.describe("When mode=auto and the domain is not in Extract open beta (AUTH010), retry once with autoparse (default true)"),
|
|
222
|
+
},
|
|
223
|
+
}, async (params) => {
|
|
224
|
+
const outcome = await runExtract(apiKey, params, { getClientName });
|
|
225
|
+
if (!outcome.ok)
|
|
226
|
+
return err(outcome.errorText);
|
|
227
|
+
return json({
|
|
228
|
+
ok: true,
|
|
229
|
+
mode: outcome.mode,
|
|
230
|
+
fellBackToAutoparse: outcome.fellBackToAutoparse,
|
|
231
|
+
empty: outcome.empty,
|
|
232
|
+
data: outcome.data,
|
|
233
|
+
...(outcome.html !== undefined ? { html: outcome.html } : {}),
|
|
234
|
+
...(outcome.raw !== undefined ? { raw: outcome.raw } : {}),
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenrows/mcp",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Zenrows MCP server — Fetch and Browser Sessions for AI coding assistants",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Zenrows MCP server — Fetch, Extract, Batch, and Browser Sessions for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"zenrows-mcp": "dist/index.js"
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
"lint": "eslint src/**/*.ts",
|
|
21
21
|
"lint:fix": "eslint src/**/*.ts --fix",
|
|
22
22
|
"prepare": "npm run build",
|
|
23
|
-
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint",
|
|
23
|
+
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm test",
|
|
24
24
|
"publish-beta": "npm publish --tag beta",
|
|
25
|
+
"test": "node --import tsx --test tests/*.test.ts",
|
|
25
26
|
"typecheck": "tsc --noEmit"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|