@zenrows/mcp 2.2.0 → 2.2.3
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/dist/auth/claim-hint.d.ts +16 -0
- package/dist/auth/claim-hint.js +71 -0
- package/dist/http.js +31 -6
- package/dist/index.js +2 -3
- package/dist/server.js +9 -6
- package/dist/tools/account.d.ts +18 -0
- package/dist/tools/account.js +98 -0
- package/dist/tools/batch.js +23 -23
- package/dist/tools/browser.js +194 -47
- package/dist/tools/extract.js +8 -13
- package/package.json +4 -2
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function isQuotaOrPlanError(opts?: {
|
|
2
|
+
status?: number;
|
|
3
|
+
body?: string;
|
|
4
|
+
code?: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
}): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* If the local agent account is still unclaimed and this looks like a quota/plan
|
|
9
|
+
* failure, append the claim URL so agents can nudge the user.
|
|
10
|
+
*/
|
|
11
|
+
export declare function appendClaimHint(text: string, opts?: {
|
|
12
|
+
status?: number;
|
|
13
|
+
body?: string;
|
|
14
|
+
code?: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
}): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append a claim-account nudge when an unclaimed Free agent hits quota/plan limits.
|
|
3
|
+
*/
|
|
4
|
+
import { readAccount } from "./ensure-key.js";
|
|
5
|
+
const CLAIM_NUDGE = "Claim your Free account to keep usage and upgrade: ";
|
|
6
|
+
/**
|
|
7
|
+
* Codes that are not an allowance problem, so a claim nudge would be noise.
|
|
8
|
+
*
|
|
9
|
+
* AUTH006 is the concurrency limit, and is belt-and-braces: it comes back as 429, so it
|
|
10
|
+
* would not reach the 402 branch below in the first place. It is listed to keep the
|
|
11
|
+
* intent legible rather than because anything depends on it.
|
|
12
|
+
*
|
|
13
|
+
* AUTH004 used to be listed here on the belief that it
|
|
14
|
+
* was concurrency too — it is not. AUTH004 is "Usage Exceeded": the allowance itself is
|
|
15
|
+
* spent (docs `api-error-codes#AUTH004`; gateway logs carry `err: "usage exceeded"`,
|
|
16
|
+
* `msg: "user allowance failure"`). Skipping it suppressed the nudge at the one moment it
|
|
17
|
+
* is worth the most — an unclaimed Free agent that has just run through its allowance and
|
|
18
|
+
* would otherwise lose the account along with its usage history.
|
|
19
|
+
*/
|
|
20
|
+
const SKIP_CODES = new Set(["AUTH006"]);
|
|
21
|
+
function extractCode(body, code) {
|
|
22
|
+
if (code && typeof code === "string")
|
|
23
|
+
return code;
|
|
24
|
+
if (!body)
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const j = JSON.parse(body);
|
|
28
|
+
if (typeof j.code === "string")
|
|
29
|
+
return j.code;
|
|
30
|
+
const m = typeof j.error === "string" ? j.error.match(/\((AUTH\d+)\)/) : null;
|
|
31
|
+
return m?.[1];
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
const m = body.match(/\b(AUTH\d+|BATCH_QUOTA_EXCEEDED)\b/);
|
|
35
|
+
return m?.[1];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function isQuotaOrPlanError(opts = {}) {
|
|
39
|
+
const code = extractCode(opts.body, opts.code);
|
|
40
|
+
if (code && SKIP_CODES.has(code))
|
|
41
|
+
return false;
|
|
42
|
+
if (opts.status === 402)
|
|
43
|
+
return true;
|
|
44
|
+
if (code === "BATCH_QUOTA_EXCEEDED")
|
|
45
|
+
return true;
|
|
46
|
+
const hay = `${opts.message ?? ""} ${opts.body ?? ""} ${code ?? ""}`;
|
|
47
|
+
if (/\bHTTP\s*402\b/i.test(hay) || /\berror\s+402\b/i.test(hay))
|
|
48
|
+
return true;
|
|
49
|
+
return /\b(no credit|credits?\s+(exhausted|exceeded|available)|quota exceeded|subscription has no credit)\b/i.test(hay);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* If the local agent account is still unclaimed and this looks like a quota/plan
|
|
53
|
+
* failure, append the claim URL so agents can nudge the user.
|
|
54
|
+
*/
|
|
55
|
+
export function appendClaimHint(text, opts = {}) {
|
|
56
|
+
const probe = {
|
|
57
|
+
status: opts.status,
|
|
58
|
+
body: opts.body ?? text,
|
|
59
|
+
code: opts.code,
|
|
60
|
+
message: opts.message ?? text,
|
|
61
|
+
};
|
|
62
|
+
if (!isQuotaOrPlanError(probe))
|
|
63
|
+
return text;
|
|
64
|
+
const acct = readAccount();
|
|
65
|
+
if (!acct?.unclaimed || !acct.claimUrl)
|
|
66
|
+
return text;
|
|
67
|
+
const hint = `${CLAIM_NUDGE}${acct.claimUrl}`;
|
|
68
|
+
if (text.includes(acct.claimUrl) || text.includes(CLAIM_NUDGE.trim()))
|
|
69
|
+
return text;
|
|
70
|
+
return `${text}\n\n${hint}`;
|
|
71
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -28,6 +28,27 @@ function extractApiKey(req) {
|
|
|
28
28
|
}
|
|
29
29
|
const AUTH_SERVER = process.env.OAUTH_AUTH_SERVER ?? "https://app.zenrows.com";
|
|
30
30
|
const MCP_SERVER = process.env.MCP_SERVER ?? "https://mcp.zenrows.com";
|
|
31
|
+
/**
|
|
32
|
+
* The scope this resource issues, named after what it actually grants.
|
|
33
|
+
*
|
|
34
|
+
* The access token IS the account's Zenrows API key: the authorization server hands
|
|
35
|
+
* the key back at /oauth/mcp/token, and every tool here calls the API with it. So an
|
|
36
|
+
* approved client can do anything the key can do, and one scope named `api` is the
|
|
37
|
+
* whole truth.
|
|
38
|
+
*
|
|
39
|
+
* It used to be declared as `[]`, which is worse than saying nothing: an empty list
|
|
40
|
+
* reads to a least-privilege client as "this resource has no scopes", when what we
|
|
41
|
+
* meant was "we never wrote them down".
|
|
42
|
+
*
|
|
43
|
+
* Splitting this — reading a page under one scope, spending credits on a
|
|
44
|
+
* 100,000-URL batch under another — is not an edit to this array. The token would
|
|
45
|
+
* have to carry the grant, and today it cannot: it is an opaque API key, minted by
|
|
46
|
+
* app.zenrows.com, and a raw key pasted straight into the Authorization header is a
|
|
47
|
+
* supported way to connect. Until the token can say what it was granted, this list
|
|
48
|
+
* must not claim more than one, because a scope we do not enforce is a lie told to
|
|
49
|
+
* exactly the clients careful enough to read it.
|
|
50
|
+
*/
|
|
51
|
+
const SCOPES_SUPPORTED = ["api"];
|
|
31
52
|
app.get("/mcp/.well-known/oauth-authorization-server", (c) => c.redirect("/.well-known/oauth-authorization-server", 301));
|
|
32
53
|
// RFC 9728 — OAuth Protected Resource Metadata
|
|
33
54
|
// MCP clients fetch this first to discover the authorization server(s) for this resource.
|
|
@@ -35,7 +56,8 @@ app.get("/.well-known/oauth-protected-resource", (c) => c.json({
|
|
|
35
56
|
resource: MCP_SERVER,
|
|
36
57
|
authorization_servers: [MCP_SERVER],
|
|
37
58
|
bearer_methods_supported: ["header", "query"],
|
|
38
|
-
scopes_supported:
|
|
59
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
60
|
+
resource_documentation: "https://docs.zenrows.com",
|
|
39
61
|
}));
|
|
40
62
|
// RFC 8414 — OAuth Authorization Server Metadata
|
|
41
63
|
// Issuer must match the URL of the server serving this document (MCP_SERVER, not AUTH_SERVER),
|
|
@@ -49,6 +71,7 @@ app.get("/.well-known/oauth-authorization-server", (c) => c.json({
|
|
|
49
71
|
grant_types_supported: ["authorization_code"],
|
|
50
72
|
code_challenge_methods_supported: ["S256"],
|
|
51
73
|
token_endpoint_auth_methods_supported: ["none"],
|
|
74
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
52
75
|
}));
|
|
53
76
|
// MCP Server Card (SEP-2127 canonical + SEP-1649 legacy paths).
|
|
54
77
|
// Lets agents discover this remote MCP before connecting.
|
|
@@ -57,7 +80,7 @@ function mcpServerCard() {
|
|
|
57
80
|
$schema: "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
|
|
58
81
|
name: "io.zenrows/mcp",
|
|
59
82
|
version: pkg.version,
|
|
60
|
-
description: "
|
|
83
|
+
description: "Zenrows MCP — scrape and extract from protected sites via Fetch (anti-bot bypass, JS rendering, proxies).",
|
|
61
84
|
websiteUrl: "https://www.zenrows.com/mcp",
|
|
62
85
|
remotes: [
|
|
63
86
|
{
|
|
@@ -71,9 +94,9 @@ function mcpServerCard() {
|
|
|
71
94
|
// Older SEP-1649-shaped fields some scanners still expect.
|
|
72
95
|
protocolVersion: "2025-06-18",
|
|
73
96
|
serverInfo: {
|
|
74
|
-
name: "
|
|
97
|
+
name: "Zenrows",
|
|
75
98
|
version: pkg.version,
|
|
76
|
-
description: "
|
|
99
|
+
description: "Zenrows Fetch API via MCP",
|
|
77
100
|
homepage: "https://www.zenrows.com/mcp",
|
|
78
101
|
},
|
|
79
102
|
transport: {
|
|
@@ -135,10 +158,12 @@ app.all("/mcp", async (c) => {
|
|
|
135
158
|
return c.json({
|
|
136
159
|
error: "Missing API key. Use Authorization: Bearer <key> header or ?apikey=<key> query param.",
|
|
137
160
|
}, 401, {
|
|
138
|
-
|
|
161
|
+
// RFC 6750: name the scope in the challenge too, so a client learns what it is
|
|
162
|
+
// being asked for without a second fetch of the metadata document.
|
|
163
|
+
"WWW-Authenticate": `Bearer realm="${AUTH_SERVER}", scope="${SCOPES_SUPPORTED.join(" ")}", resource_metadata="${MCP_SERVER}/.well-known/oauth-protected-resource"`,
|
|
139
164
|
// CloudFront strips WWW-Authenticate — add Link header as RFC 8615 fallback
|
|
140
165
|
// so MCP clients can still discover the OAuth server
|
|
141
|
-
|
|
166
|
+
Link: `<${MCP_SERVER}/.well-known/oauth-protected-resource>; rel="oauth-protected-resource"`,
|
|
142
167
|
});
|
|
143
168
|
}
|
|
144
169
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { AuthError, ensureApiKey, getZenrowsDir, resolveApiKey
|
|
4
|
+
import { AuthError, ensureApiKey, getZenrowsDir, resolveApiKey } from "./auth/ensure-key.js";
|
|
5
5
|
import { createServer } from "./server.js";
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
const pkg = require("../package.json");
|
|
@@ -12,8 +12,7 @@ try {
|
|
|
12
12
|
process.stderr.write(`Using existing API key from ${existing.source} (secrets dir: ${getZenrowsDir()})\n`);
|
|
13
13
|
}
|
|
14
14
|
else {
|
|
15
|
-
const signup = process.env.ZENROWS_AGENT_SIGNUP_URL?.trim() ||
|
|
16
|
-
"https://app.zenrows.com/api/agent/signup (default prod)";
|
|
15
|
+
const signup = process.env.ZENROWS_AGENT_SIGNUP_URL?.trim() || "https://app.zenrows.com/api/agent/signup (default prod)";
|
|
17
16
|
process.stderr.write(`No API key — will auto-signup via: ${signup}\n`);
|
|
18
17
|
}
|
|
19
18
|
const resolved = await ensureApiKey({
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { appendClaimHint } from "./auth/claim-hint.js";
|
|
4
5
|
import { getZenrowsDir, readAccount } from "./auth/ensure-key.js";
|
|
6
|
+
import { registerAccountTools } from "./tools/account.js";
|
|
5
7
|
import { registerBatchTools } from "./tools/batch.js";
|
|
6
8
|
import { registerBrowserTools } from "./tools/browser.js";
|
|
7
9
|
import { registerExtractTool } from "./tools/extract.js";
|
|
@@ -158,11 +160,7 @@ Examples:
|
|
|
158
160
|
// 'html' is the Zenrows default (no param); all other values are passed through.
|
|
159
161
|
const isScreenshot = params.screenshot || params.screenshot_fullpage || params.screenshot_selector;
|
|
160
162
|
const effectiveType = params.response_type ?? DEFAULT_RESPONSE_TYPE;
|
|
161
|
-
if (!params.autoparse &&
|
|
162
|
-
!params.css_extractor &&
|
|
163
|
-
!params.outputs &&
|
|
164
|
-
!isScreenshot &&
|
|
165
|
-
effectiveType !== "html") {
|
|
163
|
+
if (!params.autoparse && !params.css_extractor && !params.outputs && !isScreenshot && effectiveType !== "html") {
|
|
166
164
|
searchParams.set("response_type", effectiveType);
|
|
167
165
|
}
|
|
168
166
|
let response;
|
|
@@ -188,8 +186,12 @@ Examples:
|
|
|
188
186
|
}
|
|
189
187
|
if (!response.ok) {
|
|
190
188
|
const body = await response.text();
|
|
189
|
+
const text = appendClaimHint(`Zenrows error ${response.status}: ${body}`, {
|
|
190
|
+
status: response.status,
|
|
191
|
+
body,
|
|
192
|
+
});
|
|
191
193
|
return {
|
|
192
|
-
content: [{ type: "text", text
|
|
194
|
+
content: [{ type: "text", text }],
|
|
193
195
|
isError: true,
|
|
194
196
|
};
|
|
195
197
|
}
|
|
@@ -270,6 +272,7 @@ Examples:
|
|
|
270
272
|
}));
|
|
271
273
|
registerExtractTool(server, apiKey, getClientName);
|
|
272
274
|
registerBatchTools(server, apiKey);
|
|
275
|
+
registerAccountTools(server, apiKey);
|
|
273
276
|
const BROWSER_URL = process.env.ZENROWS_BROWSER_URL ?? "https://mcp.zenrows.com";
|
|
274
277
|
registerBrowserTools(server, apiKey, BROWSER_URL, getClientName);
|
|
275
278
|
// Always expose account resource; handler re-reads disk so ZENROWS_HOME is visible.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
type TextContent = {
|
|
3
|
+
type: "text";
|
|
4
|
+
text: string;
|
|
5
|
+
};
|
|
6
|
+
export type AccountOpts = {
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Read the plan's usage. Split out from the handler so it can be exercised without an
|
|
11
|
+
* MCP server or a live account — the endpoint is the one thing here we cannot try
|
|
12
|
+
* against production from a test.
|
|
13
|
+
*/
|
|
14
|
+
export declare function runAccountUsage(apiKey: string, opts?: AccountOpts): Promise<{
|
|
15
|
+
content: TextContent[];
|
|
16
|
+
}>;
|
|
17
|
+
export declare function registerAccountTools(server: McpServer, apiKey: string, opts?: AccountOpts): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { appendClaimHint } from "../auth/claim-hint.js";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const pkg = require("../../package.json");
|
|
5
|
+
/**
|
|
6
|
+
* Let an agent read its own allowance before it runs out of it.
|
|
7
|
+
*
|
|
8
|
+
* Until now nothing on this server could answer "how many credits do I have left?".
|
|
9
|
+
* Responses carry `X-Request-Cost` and `X-Request-Credits` — what a call *cost*, after
|
|
10
|
+
* the fact — but no counterpart to `Concurrency-Limit` / `Concurrency-Remaining`, so an
|
|
11
|
+
* agent could total its own spend and still not know the ceiling. It found out by
|
|
12
|
+
* hitting a 402 telling it to buy a subscription (ACT-1581, ACT-1577).
|
|
13
|
+
*
|
|
14
|
+
* `/v1/subscriptions/self/details` has always had the answer. It does not count against
|
|
15
|
+
* concurrency, which is what makes it safe to call before a batch or on a retry.
|
|
16
|
+
*/
|
|
17
|
+
const SUBSCRIPTION_DETAILS_URL = "https://api.zenrows.com/v1/subscriptions/self/details";
|
|
18
|
+
function err(text, opts = {}) {
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: appendClaimHint(text, {
|
|
24
|
+
status: opts.status,
|
|
25
|
+
body: opts.body ?? text,
|
|
26
|
+
message: text,
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
isError: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function json(data) {
|
|
34
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the plan's usage. Split out from the handler so it can be exercised without an
|
|
38
|
+
* MCP server or a live account — the endpoint is the one thing here we cannot try
|
|
39
|
+
* against production from a test.
|
|
40
|
+
*/
|
|
41
|
+
export async function runAccountUsage(apiKey, opts = {}) {
|
|
42
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
res = await doFetch(SUBSCRIPTION_DETAILS_URL, {
|
|
46
|
+
headers: {
|
|
47
|
+
"X-API-Key": apiKey,
|
|
48
|
+
"User-Agent": `zenrows-mcp/${pkg.version}`,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return err(`Could not reach the Zenrows subscription endpoint: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
const body = await res.text();
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
return err(`Zenrows returned ${res.status} for the subscription details endpoint.\n${body}`, {
|
|
58
|
+
status: res.status,
|
|
59
|
+
body,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Passed through verbatim. The response shape is not part of any documented contract,
|
|
63
|
+
// so reshaping it here would mean inventing field names that could drift away from
|
|
64
|
+
// what the API actually sends.
|
|
65
|
+
try {
|
|
66
|
+
return json(JSON.parse(body));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return json({ raw: body });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function registerAccountTools(server, apiKey, opts = {}) {
|
|
73
|
+
server.registerTool("account_usage", {
|
|
74
|
+
annotations: {
|
|
75
|
+
title: "Check Credit Usage",
|
|
76
|
+
readOnlyHint: true,
|
|
77
|
+
destructiveHint: false,
|
|
78
|
+
openWorldHint: false,
|
|
79
|
+
},
|
|
80
|
+
description: `Read the current plan's credit allowance and how much of it is spent.
|
|
81
|
+
|
|
82
|
+
Call this BEFORE a large batch, and after any 402 / AUTH004, to find out whether the
|
|
83
|
+
account is out of credits and when the allowance renews. It is free and does not consume
|
|
84
|
+
a concurrency slot, so it is safe to poll between runs.
|
|
85
|
+
|
|
86
|
+
Credit costs per request: 1 basic, 5 js_render, 10 premium_proxy, 25 both. On a small
|
|
87
|
+
plan a few hundred protected requests can exhaust a month, so check before fanning out.
|
|
88
|
+
|
|
89
|
+
AUTH004 ("usage exceeded") means this allowance is spent. It renews at the end of the
|
|
90
|
+
billing period, so it is not a permanent block: never retry-loop against it. If the
|
|
91
|
+
human does not want to wait for the renewal, relay the way to continue now: add a
|
|
92
|
+
credit pack at https://app.zenrows.com/billing?topup=open (opens the purchase
|
|
93
|
+
directly) or upgrade at https://app.zenrows.com/plans. Prices are per plan; quote them
|
|
94
|
+
only from this tool's response, never from memory.
|
|
95
|
+
AUTH006 is the concurrency limit, which is a different thing entirely.`,
|
|
96
|
+
inputSchema: {},
|
|
97
|
+
}, async () => runAccountUsage(apiKey, opts));
|
|
98
|
+
}
|
package/dist/tools/batch.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { appendClaimHint } from "../auth/claim-hint.js";
|
|
4
|
+
import { BatchError, createJob, getJob, listResults, stopJob, waitForJob } from "../batch-api.js";
|
|
4
5
|
const require = createRequire(import.meta.url);
|
|
5
6
|
const pkg = require("../../package.json");
|
|
6
|
-
function err(data) {
|
|
7
|
+
function err(data, hint) {
|
|
8
|
+
const raw = typeof data === "string" ? data : JSON.stringify(data);
|
|
7
9
|
return {
|
|
8
|
-
content: [
|
|
10
|
+
content: [
|
|
11
|
+
{
|
|
12
|
+
type: "text",
|
|
13
|
+
text: appendClaimHint(raw, {
|
|
14
|
+
status: hint?.status,
|
|
15
|
+
code: hint?.code,
|
|
16
|
+
message: hint?.message ?? raw,
|
|
17
|
+
body: raw,
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
9
21
|
isError: true,
|
|
10
22
|
};
|
|
11
23
|
}
|
|
@@ -13,8 +25,9 @@ function json(data) {
|
|
|
13
25
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
14
26
|
}
|
|
15
27
|
function batchErr(e) {
|
|
16
|
-
if (e instanceof BatchError)
|
|
17
|
-
return err(e.toJSON());
|
|
28
|
+
if (e instanceof BatchError) {
|
|
29
|
+
return err(e.toJSON(), { status: e.status, code: e.code, message: e.message });
|
|
30
|
+
}
|
|
18
31
|
return err({
|
|
19
32
|
code: "BATCH_FAILED",
|
|
20
33
|
message: e instanceof Error ? e.message : String(e),
|
|
@@ -70,18 +83,12 @@ If you get BATCH_ACCESS_DENIED, the account lacks Batch beta access.`,
|
|
|
70
83
|
.string()
|
|
71
84
|
.optional()
|
|
72
85
|
.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"),
|
|
86
|
+
response_type: z.enum(["markdown", "plaintext", "html", "pdf"]).optional().describe("Job-level response_type"),
|
|
77
87
|
zenrows_params: z
|
|
78
88
|
.record(z.union([z.string(), z.number(), z.boolean()]))
|
|
79
89
|
.optional()
|
|
80
90
|
.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"),
|
|
91
|
+
wait: z.boolean().optional().describe("If true, poll until the job reaches a terminal state before returning"),
|
|
85
92
|
wait_timeout_ms: z
|
|
86
93
|
.number()
|
|
87
94
|
.int()
|
|
@@ -91,9 +98,7 @@ If you get BATCH_ACCESS_DENIED, the account lacks Batch beta access.`,
|
|
|
91
98
|
.describe("Max wait time when wait=true (default 600000)"),
|
|
92
99
|
},
|
|
93
100
|
}, async (params) => {
|
|
94
|
-
const tasksIn = params.tasks && params.tasks.length > 0
|
|
95
|
-
? params.tasks
|
|
96
|
-
: (params.urls ?? []).map((url) => ({ url }));
|
|
101
|
+
const tasksIn = params.tasks && params.tasks.length > 0 ? params.tasks : (params.urls ?? []).map((url) => ({ url }));
|
|
97
102
|
if (!tasksIn.length) {
|
|
98
103
|
return err({
|
|
99
104
|
code: "INVALID_USAGE",
|
|
@@ -122,9 +127,7 @@ If you get BATCH_ACCESS_DENIED, the account lacks Batch beta access.`,
|
|
|
122
127
|
task.zenrows_params = normalizeParams(t.zenrows_params);
|
|
123
128
|
return task;
|
|
124
129
|
}),
|
|
125
|
-
...(Object.keys(jobParams).length
|
|
126
|
-
? { zenrows_params: normalizeParams(jobParams) }
|
|
127
|
-
: {}),
|
|
130
|
+
...(Object.keys(jobParams).length ? { zenrows_params: normalizeParams(jobParams) } : {}),
|
|
128
131
|
};
|
|
129
132
|
try {
|
|
130
133
|
const job = await createJob(body, call);
|
|
@@ -177,10 +180,7 @@ Each row may include task_id, external_id, status, and a short-lived result_url
|
|
|
177
180
|
Download result_url soon — presigned links expire.`,
|
|
178
181
|
inputSchema: {
|
|
179
182
|
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)"),
|
|
183
|
+
status: z.enum(["successful", "failed", "all"]).optional().describe("Filter results by status (default: all)"),
|
|
184
184
|
},
|
|
185
185
|
}, async ({ job_id, status }) => {
|
|
186
186
|
try {
|
package/dist/tools/browser.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { appendClaimHint } from "../auth/claim-hint.js";
|
|
2
3
|
import { browserFetch, browserError } from "./browser-fetch.js";
|
|
3
4
|
function ok() {
|
|
4
5
|
return { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] };
|
|
5
6
|
}
|
|
6
7
|
function err(msg) {
|
|
7
|
-
return {
|
|
8
|
+
return {
|
|
9
|
+
content: [{ type: "text", text: appendClaimHint(msg, { body: msg, message: msg }) }],
|
|
10
|
+
isError: true,
|
|
11
|
+
};
|
|
8
12
|
}
|
|
9
13
|
function json(data) {
|
|
10
14
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
@@ -14,7 +18,11 @@ export function registerBrowserTools(server, apiKey, browserUrl, getClientName)
|
|
|
14
18
|
const tfetch = (toolName) => (method, path, body) => browserFetch(method, path, apiKey, browserUrl, body, getClientName(), toolName);
|
|
15
19
|
// ─── Session ─────────────────────────────────────────────────────────────
|
|
16
20
|
server.registerTool("browser_navigate", {
|
|
17
|
-
annotations: {
|
|
21
|
+
annotations: {
|
|
22
|
+
title: "Open Browser & Navigate",
|
|
23
|
+
readOnlyHint: false,
|
|
24
|
+
destructiveHint: false,
|
|
25
|
+
},
|
|
18
26
|
description: `Open a Zenrows Browser Sessions session and navigate to a URL.
|
|
19
27
|
|
|
20
28
|
This is the entry point for all browser automation. It creates a new session backed by
|
|
@@ -32,7 +40,10 @@ When to use options:
|
|
|
32
40
|
.string()
|
|
33
41
|
.optional()
|
|
34
42
|
.describe("ISO 3166-1 alpha-2 country code for geo-targeted proxy (e.g. 'US', 'GB', 'DE')"),
|
|
35
|
-
proxy_region: z
|
|
43
|
+
proxy_region: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("World region code for geo-targeted proxy (eu=Europe, na=North America, ap=Asia Pacific, sa=South America, af=Africa, me=Middle East)"),
|
|
36
47
|
},
|
|
37
48
|
}, async (params) => {
|
|
38
49
|
const fetch = tfetch("browser_navigate");
|
|
@@ -51,7 +62,9 @@ When to use options:
|
|
|
51
62
|
const session = result.data;
|
|
52
63
|
let navResult;
|
|
53
64
|
try {
|
|
54
|
-
navResult = await fetch("POST", `/browser/sessions/${session.session_id}/navigate`, {
|
|
65
|
+
navResult = await fetch("POST", `/browser/sessions/${session.session_id}/navigate`, {
|
|
66
|
+
url: params.url,
|
|
67
|
+
});
|
|
55
68
|
}
|
|
56
69
|
catch (e) {
|
|
57
70
|
// Best-effort cleanup — session was created but navigation failed, free the slot.
|
|
@@ -63,7 +76,12 @@ When to use options:
|
|
|
63
76
|
return err(`Navigation failed: ${browserError(navResult)}`);
|
|
64
77
|
}
|
|
65
78
|
const nav = navResult.data;
|
|
66
|
-
return json({
|
|
79
|
+
return json({
|
|
80
|
+
session_id: session.session_id,
|
|
81
|
+
url: nav.url,
|
|
82
|
+
title: nav.title,
|
|
83
|
+
expires_at: session.expires_at,
|
|
84
|
+
});
|
|
67
85
|
});
|
|
68
86
|
server.registerTool("browser_close", {
|
|
69
87
|
annotations: { title: "Close Browser Session", readOnlyHint: false, destructiveHint: false },
|
|
@@ -181,7 +199,11 @@ When to use options:
|
|
|
181
199
|
}, async ({ session_id, selector, text, clear_first }) => {
|
|
182
200
|
const fetch = tfetch("browser_type");
|
|
183
201
|
try {
|
|
184
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/type`, {
|
|
202
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/type`, {
|
|
203
|
+
selector,
|
|
204
|
+
text,
|
|
205
|
+
clear_first,
|
|
206
|
+
});
|
|
185
207
|
if (!result.ok)
|
|
186
208
|
return err(`Type failed: ${browserError(result)}`);
|
|
187
209
|
return ok();
|
|
@@ -201,7 +223,10 @@ When to use options:
|
|
|
201
223
|
}, async ({ session_id, selector, value }) => {
|
|
202
224
|
const fetch = tfetch("browser_fill");
|
|
203
225
|
try {
|
|
204
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/fill`, {
|
|
226
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/fill`, {
|
|
227
|
+
selector,
|
|
228
|
+
value,
|
|
229
|
+
});
|
|
205
230
|
if (!result.ok)
|
|
206
231
|
return err(`Fill failed: ${browserError(result)}`);
|
|
207
232
|
return ok();
|
|
@@ -221,7 +246,10 @@ When to use options:
|
|
|
221
246
|
}, async ({ session_id, selector, value }) => {
|
|
222
247
|
const fetch = tfetch("browser_select_option");
|
|
223
248
|
try {
|
|
224
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/select`, {
|
|
249
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/select`, {
|
|
250
|
+
selector,
|
|
251
|
+
value,
|
|
252
|
+
});
|
|
225
253
|
if (!result.ok)
|
|
226
254
|
return err(`Select failed: ${browserError(result)}`);
|
|
227
255
|
return ok();
|
|
@@ -317,7 +345,10 @@ When to use options:
|
|
|
317
345
|
}, async ({ session_id, direction, distance }) => {
|
|
318
346
|
const fetch = tfetch("browser_scroll");
|
|
319
347
|
try {
|
|
320
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/scroll`, {
|
|
348
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/scroll`, {
|
|
349
|
+
direction,
|
|
350
|
+
distance,
|
|
351
|
+
});
|
|
321
352
|
if (!result.ok)
|
|
322
353
|
return err(`Scroll failed: ${browserError(result)}`);
|
|
323
354
|
return ok();
|
|
@@ -416,7 +447,9 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
416
447
|
}, async ({ session_id, selector }) => {
|
|
417
448
|
const fetch = tfetch("browser_get_text");
|
|
418
449
|
try {
|
|
419
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/get_text`, {
|
|
450
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/get_text`, {
|
|
451
|
+
selector,
|
|
452
|
+
});
|
|
420
453
|
if (!result.ok)
|
|
421
454
|
return err(`Failed to get text: ${browserError(result)}`);
|
|
422
455
|
const data = result.data;
|
|
@@ -437,7 +470,10 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
437
470
|
}, async ({ session_id, selector, attribute }) => {
|
|
438
471
|
const fetch = tfetch("browser_get_attribute");
|
|
439
472
|
try {
|
|
440
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/get_attribute`, {
|
|
473
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/get_attribute`, {
|
|
474
|
+
selector,
|
|
475
|
+
attribute,
|
|
476
|
+
});
|
|
441
477
|
if (!result.ok)
|
|
442
478
|
return err(`Failed to get attribute: ${browserError(result)}`);
|
|
443
479
|
return json(result.data);
|
|
@@ -456,7 +492,9 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
456
492
|
}, async ({ session_id, selector }) => {
|
|
457
493
|
const fetch = tfetch("browser_get_html");
|
|
458
494
|
try {
|
|
459
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/get_html`, {
|
|
495
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/get_html`, {
|
|
496
|
+
selector,
|
|
497
|
+
});
|
|
460
498
|
if (!result.ok)
|
|
461
499
|
return err(`Failed to get HTML: ${browserError(result)}`);
|
|
462
500
|
const data = result.data;
|
|
@@ -467,7 +505,11 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
467
505
|
}
|
|
468
506
|
});
|
|
469
507
|
server.registerTool("browser_query_selector_all", {
|
|
470
|
-
annotations: {
|
|
508
|
+
annotations: {
|
|
509
|
+
title: "Query All Matching Elements",
|
|
510
|
+
readOnlyHint: true,
|
|
511
|
+
destructiveHint: false,
|
|
512
|
+
},
|
|
471
513
|
description: "Find all elements matching a CSS selector and return their text, HTML, and attributes.",
|
|
472
514
|
inputSchema: {
|
|
473
515
|
session_id: sessionId,
|
|
@@ -476,7 +518,9 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
476
518
|
}, async ({ session_id, selector }) => {
|
|
477
519
|
const fetch = tfetch("browser_query_selector_all");
|
|
478
520
|
try {
|
|
479
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/query_selector_all`, {
|
|
521
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/query_selector_all`, {
|
|
522
|
+
selector,
|
|
523
|
+
});
|
|
480
524
|
if (!result.ok)
|
|
481
525
|
return err(`Query failed: ${browserError(result)}`);
|
|
482
526
|
return json(result.data);
|
|
@@ -491,7 +535,10 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
491
535
|
description: "Take a screenshot of the current page or a specific element. Use for visual verification or when the accessibility tree is not sufficient.",
|
|
492
536
|
inputSchema: {
|
|
493
537
|
session_id: sessionId,
|
|
494
|
-
full_page: z
|
|
538
|
+
full_page: z
|
|
539
|
+
.boolean()
|
|
540
|
+
.optional()
|
|
541
|
+
.describe("Capture full page including content below the fold (default false)"),
|
|
495
542
|
selector: z
|
|
496
543
|
.string()
|
|
497
544
|
.optional()
|
|
@@ -500,7 +547,10 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
500
547
|
}, async ({ session_id, full_page, selector }) => {
|
|
501
548
|
const fetch = tfetch("browser_screenshot");
|
|
502
549
|
try {
|
|
503
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/screenshot`, {
|
|
550
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/screenshot`, {
|
|
551
|
+
full_page,
|
|
552
|
+
selector,
|
|
553
|
+
});
|
|
504
554
|
if (!result.ok)
|
|
505
555
|
return err(`Screenshot failed: ${browserError(result)}`);
|
|
506
556
|
const data = result.data;
|
|
@@ -530,7 +580,11 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
530
580
|
}, async ({ session_id, print_background, landscape, scale }) => {
|
|
531
581
|
const fetch = tfetch("browser_generate_pdf");
|
|
532
582
|
try {
|
|
533
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/generate_pdf`, {
|
|
583
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/generate_pdf`, {
|
|
584
|
+
print_background,
|
|
585
|
+
landscape,
|
|
586
|
+
scale,
|
|
587
|
+
});
|
|
534
588
|
if (!result.ok)
|
|
535
589
|
return err(`PDF generation failed: ${browserError(result)}`);
|
|
536
590
|
const data = result.data;
|
|
@@ -558,12 +612,18 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
558
612
|
inputSchema: {
|
|
559
613
|
session_id: sessionId,
|
|
560
614
|
selector: z.string().describe("CSS selector to wait for"),
|
|
561
|
-
visible: z
|
|
615
|
+
visible: z
|
|
616
|
+
.boolean()
|
|
617
|
+
.optional()
|
|
618
|
+
.describe("Also require the element to be visible, not just present in the DOM (default false)"),
|
|
562
619
|
},
|
|
563
620
|
}, async ({ session_id, selector, visible }) => {
|
|
564
621
|
const fetch = tfetch("browser_wait_for_selector");
|
|
565
622
|
try {
|
|
566
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/wait_for_selector`, {
|
|
623
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/wait_for_selector`, {
|
|
624
|
+
selector,
|
|
625
|
+
visible,
|
|
626
|
+
});
|
|
567
627
|
if (!result.ok)
|
|
568
628
|
return err(`Wait for selector failed: ${browserError(result)}`);
|
|
569
629
|
return ok();
|
|
@@ -577,7 +637,13 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
577
637
|
description: "Wait for the page to navigate to a new URL. IMPORTANT: call this BEFORE the action that triggers navigation (e.g. before browser_click on a submit button), not after — the navigation event may already have fired and this will hang until timeout. If the page stays on the same URL (AJAX/SPA), skip this tool entirely. Optional timeout_ms (default 30000ms).",
|
|
578
638
|
inputSchema: {
|
|
579
639
|
session_id: sessionId,
|
|
580
|
-
timeout_ms: z
|
|
640
|
+
timeout_ms: z
|
|
641
|
+
.number()
|
|
642
|
+
.int()
|
|
643
|
+
.min(1000)
|
|
644
|
+
.max(60000)
|
|
645
|
+
.optional()
|
|
646
|
+
.describe("How long to wait in milliseconds (default 30000, max 60000)"),
|
|
581
647
|
},
|
|
582
648
|
}, async ({ session_id, timeout_ms }) => {
|
|
583
649
|
const fetch = tfetch("browser_wait_for_navigation");
|
|
@@ -669,7 +735,9 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
669
735
|
try {
|
|
670
736
|
// Remap http_only → httpOnly to match Chrome DevTools Protocol / Rod's JSON field names.
|
|
671
737
|
const mapped = cookies.map(({ http_only, ...rest }) => ({ ...rest, httpOnly: http_only }));
|
|
672
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/cookies`, {
|
|
738
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/cookies`, {
|
|
739
|
+
cookies: mapped,
|
|
740
|
+
});
|
|
673
741
|
if (!result.ok)
|
|
674
742
|
return err(`Failed to set cookies: ${browserError(result)}`);
|
|
675
743
|
return ok();
|
|
@@ -707,7 +775,11 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
707
775
|
}, async ({ session_id, action, key, value }) => {
|
|
708
776
|
const fetch = tfetch("browser_local_storage");
|
|
709
777
|
try {
|
|
710
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/local_storage`, {
|
|
778
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/local_storage`, {
|
|
779
|
+
action,
|
|
780
|
+
key,
|
|
781
|
+
value,
|
|
782
|
+
});
|
|
711
783
|
if (!result.ok)
|
|
712
784
|
return err(`Local storage operation failed: ${browserError(result)}`);
|
|
713
785
|
return json(result.data);
|
|
@@ -746,7 +818,9 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
746
818
|
}, async ({ session_id, tab_id }) => {
|
|
747
819
|
const fetch = tfetch("browser_switch_tab");
|
|
748
820
|
try {
|
|
749
|
-
const result = await fetch("POST", `/browser/sessions/${session_id}/switch_tab`, {
|
|
821
|
+
const result = await fetch("POST", `/browser/sessions/${session_id}/switch_tab`, {
|
|
822
|
+
tab_id,
|
|
823
|
+
});
|
|
750
824
|
if (!result.ok)
|
|
751
825
|
return err(`Failed to switch tab: ${browserError(result)}`);
|
|
752
826
|
return ok();
|
|
@@ -763,14 +837,23 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
763
837
|
z.object({ type: z.literal("reload") }),
|
|
764
838
|
z.object({ type: z.literal("click"), selector: z.string() }),
|
|
765
839
|
z.object({ type: z.literal("hover"), selector: z.string() }),
|
|
766
|
-
z.object({
|
|
840
|
+
z.object({
|
|
841
|
+
type: z.literal("type"),
|
|
842
|
+
selector: z.string(),
|
|
843
|
+
text: z.string(),
|
|
844
|
+
clear_first: z.boolean().optional(),
|
|
845
|
+
}),
|
|
767
846
|
z.object({ type: z.literal("fill"), selector: z.string(), value: z.string() }),
|
|
768
847
|
z.object({ type: z.literal("select"), selector: z.string(), value: z.string() }),
|
|
769
848
|
z.object({ type: z.literal("check"), selector: z.string() }),
|
|
770
849
|
z.object({ type: z.literal("uncheck"), selector: z.string() }),
|
|
771
850
|
z.object({ type: z.literal("focus"), selector: z.string() }),
|
|
772
851
|
z.object({ type: z.literal("press_key"), key: z.string() }),
|
|
773
|
-
z.object({
|
|
852
|
+
z.object({
|
|
853
|
+
type: z.literal("scroll"),
|
|
854
|
+
direction: z.enum(["up", "down", "left", "right"]),
|
|
855
|
+
distance: z.number().optional(),
|
|
856
|
+
}),
|
|
774
857
|
z.object({ type: z.literal("drag"), source_selector: z.string(), target_selector: z.string() }),
|
|
775
858
|
z.object({ type: z.literal("get_accessibility_tree") }),
|
|
776
859
|
z.object({ type: z.literal("get_url") }),
|
|
@@ -779,8 +862,16 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
779
862
|
z.object({ type: z.literal("get_attribute"), selector: z.string(), attribute: z.string() }),
|
|
780
863
|
z.object({ type: z.literal("get_html"), selector: z.string().optional() }),
|
|
781
864
|
z.object({ type: z.literal("query_selector_all"), selector: z.string() }),
|
|
782
|
-
z.object({
|
|
783
|
-
|
|
865
|
+
z.object({
|
|
866
|
+
type: z.literal("screenshot"),
|
|
867
|
+
full_page: z.boolean().optional(),
|
|
868
|
+
selector: z.string().optional(),
|
|
869
|
+
}),
|
|
870
|
+
z.object({
|
|
871
|
+
type: z.literal("wait_for_selector"),
|
|
872
|
+
selector: z.string(),
|
|
873
|
+
visible: z.boolean().optional(),
|
|
874
|
+
}),
|
|
784
875
|
z.object({ type: z.literal("wait_for_navigation") }),
|
|
785
876
|
z.object({ type: z.literal("wait"), ms: z.number().int().min(0).max(30000) }),
|
|
786
877
|
z.object({ type: z.literal("evaluate"), script: z.string() }),
|
|
@@ -813,49 +904,69 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
813
904
|
return r.data;
|
|
814
905
|
}
|
|
815
906
|
case "click": {
|
|
816
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/click`, {
|
|
907
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/click`, {
|
|
908
|
+
selector: action.selector,
|
|
909
|
+
});
|
|
817
910
|
if (!r.ok)
|
|
818
911
|
throw new Error(browserError(r));
|
|
819
912
|
return { ok: true };
|
|
820
913
|
}
|
|
821
914
|
case "hover": {
|
|
822
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/hover`, {
|
|
915
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/hover`, {
|
|
916
|
+
selector: action.selector,
|
|
917
|
+
});
|
|
823
918
|
if (!r.ok)
|
|
824
919
|
throw new Error(browserError(r));
|
|
825
920
|
return { ok: true };
|
|
826
921
|
}
|
|
827
922
|
case "type": {
|
|
828
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/type`, {
|
|
923
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/type`, {
|
|
924
|
+
selector: action.selector,
|
|
925
|
+
text: action.text,
|
|
926
|
+
clear_first: action.clear_first,
|
|
927
|
+
});
|
|
829
928
|
if (!r.ok)
|
|
830
929
|
throw new Error(browserError(r));
|
|
831
930
|
return { ok: true };
|
|
832
931
|
}
|
|
833
932
|
case "fill": {
|
|
834
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/fill`, {
|
|
933
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/fill`, {
|
|
934
|
+
selector: action.selector,
|
|
935
|
+
value: action.value,
|
|
936
|
+
});
|
|
835
937
|
if (!r.ok)
|
|
836
938
|
throw new Error(browserError(r));
|
|
837
939
|
return { ok: true };
|
|
838
940
|
}
|
|
839
941
|
case "select": {
|
|
840
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/select`, {
|
|
942
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/select`, {
|
|
943
|
+
selector: action.selector,
|
|
944
|
+
value: action.value,
|
|
945
|
+
});
|
|
841
946
|
if (!r.ok)
|
|
842
947
|
throw new Error(browserError(r));
|
|
843
948
|
return { ok: true };
|
|
844
949
|
}
|
|
845
950
|
case "check": {
|
|
846
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/check`, {
|
|
951
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/check`, {
|
|
952
|
+
selector: action.selector,
|
|
953
|
+
});
|
|
847
954
|
if (!r.ok)
|
|
848
955
|
throw new Error(browserError(r));
|
|
849
956
|
return { ok: true };
|
|
850
957
|
}
|
|
851
958
|
case "uncheck": {
|
|
852
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/uncheck`, {
|
|
959
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/uncheck`, {
|
|
960
|
+
selector: action.selector,
|
|
961
|
+
});
|
|
853
962
|
if (!r.ok)
|
|
854
963
|
throw new Error(browserError(r));
|
|
855
964
|
return { ok: true };
|
|
856
965
|
}
|
|
857
966
|
case "focus": {
|
|
858
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/focus`, {
|
|
967
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/focus`, {
|
|
968
|
+
selector: action.selector,
|
|
969
|
+
});
|
|
859
970
|
if (!r.ok)
|
|
860
971
|
throw new Error(browserError(r));
|
|
861
972
|
return { ok: true };
|
|
@@ -867,13 +978,19 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
867
978
|
return { ok: true };
|
|
868
979
|
}
|
|
869
980
|
case "scroll": {
|
|
870
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/scroll`, {
|
|
981
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/scroll`, {
|
|
982
|
+
direction: action.direction,
|
|
983
|
+
distance: action.distance,
|
|
984
|
+
});
|
|
871
985
|
if (!r.ok)
|
|
872
986
|
throw new Error(browserError(r));
|
|
873
987
|
return { ok: true };
|
|
874
988
|
}
|
|
875
989
|
case "drag": {
|
|
876
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/drag`, {
|
|
990
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/drag`, {
|
|
991
|
+
source_selector: action.source_selector,
|
|
992
|
+
target_selector: action.target_selector,
|
|
993
|
+
});
|
|
877
994
|
if (!r.ok)
|
|
878
995
|
throw new Error(browserError(r));
|
|
879
996
|
return { ok: true };
|
|
@@ -897,31 +1014,43 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
897
1014
|
return r.data;
|
|
898
1015
|
}
|
|
899
1016
|
case "get_text": {
|
|
900
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/get_text`, {
|
|
1017
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/get_text`, {
|
|
1018
|
+
selector: action.selector,
|
|
1019
|
+
});
|
|
901
1020
|
if (!r.ok)
|
|
902
1021
|
throw new Error(browserError(r));
|
|
903
1022
|
return r.data.text;
|
|
904
1023
|
}
|
|
905
1024
|
case "get_attribute": {
|
|
906
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/get_attribute`, {
|
|
1025
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/get_attribute`, {
|
|
1026
|
+
selector: action.selector,
|
|
1027
|
+
attribute: action.attribute,
|
|
1028
|
+
});
|
|
907
1029
|
if (!r.ok)
|
|
908
1030
|
throw new Error(browserError(r));
|
|
909
1031
|
return r.data;
|
|
910
1032
|
}
|
|
911
1033
|
case "get_html": {
|
|
912
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/get_html`, {
|
|
1034
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/get_html`, {
|
|
1035
|
+
selector: action.selector,
|
|
1036
|
+
});
|
|
913
1037
|
if (!r.ok)
|
|
914
1038
|
throw new Error(browserError(r));
|
|
915
1039
|
return r.data.html;
|
|
916
1040
|
}
|
|
917
1041
|
case "query_selector_all": {
|
|
918
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/query_selector_all`, {
|
|
1042
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/query_selector_all`, {
|
|
1043
|
+
selector: action.selector,
|
|
1044
|
+
});
|
|
919
1045
|
if (!r.ok)
|
|
920
1046
|
throw new Error(browserError(r));
|
|
921
1047
|
return r.data;
|
|
922
1048
|
}
|
|
923
1049
|
case "screenshot": {
|
|
924
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/screenshot`, {
|
|
1050
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/screenshot`, {
|
|
1051
|
+
full_page: action.full_page,
|
|
1052
|
+
selector: action.selector,
|
|
1053
|
+
});
|
|
925
1054
|
if (!r.ok)
|
|
926
1055
|
throw new Error(browserError(r));
|
|
927
1056
|
// Return as data URI string so it fits in the JSON result array.
|
|
@@ -930,7 +1059,10 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
930
1059
|
return { mime_type: d.mime_type, data: d.data };
|
|
931
1060
|
}
|
|
932
1061
|
case "wait_for_selector": {
|
|
933
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/wait_for_selector`, {
|
|
1062
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/wait_for_selector`, {
|
|
1063
|
+
selector: action.selector,
|
|
1064
|
+
visible: action.visible,
|
|
1065
|
+
});
|
|
934
1066
|
if (!r.ok)
|
|
935
1067
|
throw new Error(browserError(r));
|
|
936
1068
|
return { ok: true };
|
|
@@ -948,7 +1080,9 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
948
1080
|
return { ok: true };
|
|
949
1081
|
}
|
|
950
1082
|
case "evaluate": {
|
|
951
|
-
const r = await fetch("POST", `/browser/sessions/${sid}/evaluate`, {
|
|
1083
|
+
const r = await fetch("POST", `/browser/sessions/${sid}/evaluate`, {
|
|
1084
|
+
script: action.script,
|
|
1085
|
+
});
|
|
952
1086
|
if (!r.ok)
|
|
953
1087
|
throw new Error(browserError(r));
|
|
954
1088
|
return r.data;
|
|
@@ -956,7 +1090,11 @@ labels, and states — everything needed to drive browser interactions.`,
|
|
|
956
1090
|
}
|
|
957
1091
|
}
|
|
958
1092
|
server.registerTool("browser_batch", {
|
|
959
|
-
annotations: {
|
|
1093
|
+
annotations: {
|
|
1094
|
+
title: "Run Batch of Browser Actions",
|
|
1095
|
+
readOnlyHint: false,
|
|
1096
|
+
destructiveHint: false,
|
|
1097
|
+
},
|
|
960
1098
|
description: `Execute a sequence of browser actions in a single call against an existing session.
|
|
961
1099
|
|
|
962
1100
|
Use this when you already know the full sequence of steps — it reduces round trips and
|
|
@@ -969,7 +1107,11 @@ Note: screenshots in batch results are returned as base64 strings inside the JSO
|
|
|
969
1107
|
For proper image rendering, use browser_screenshot directly.`,
|
|
970
1108
|
inputSchema: {
|
|
971
1109
|
session_id: sessionId,
|
|
972
|
-
actions: z
|
|
1110
|
+
actions: z
|
|
1111
|
+
.array(batchAction)
|
|
1112
|
+
.min(1)
|
|
1113
|
+
.max(50)
|
|
1114
|
+
.describe("Ordered list of actions to perform. Each action has a 'type' field plus type-specific parameters."),
|
|
973
1115
|
stop_on_error: z.boolean().optional().describe("Stop executing on the first failed action (default true)"),
|
|
974
1116
|
},
|
|
975
1117
|
}, async ({ session_id, actions, stop_on_error = true }) => {
|
|
@@ -986,7 +1128,12 @@ For proper image rendering, use browser_screenshot directly.`,
|
|
|
986
1128
|
results.push({ step: i, type: action.type, error: msg });
|
|
987
1129
|
if (stop_on_error) {
|
|
988
1130
|
return {
|
|
989
|
-
content: [
|
|
1131
|
+
content: [
|
|
1132
|
+
{
|
|
1133
|
+
type: "text",
|
|
1134
|
+
text: JSON.stringify({ completed: i, total: actions.length, results }),
|
|
1135
|
+
},
|
|
1136
|
+
],
|
|
990
1137
|
isError: true,
|
|
991
1138
|
};
|
|
992
1139
|
}
|
package/dist/tools/extract.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { appendClaimHint } from "../auth/claim-hint.js";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
const pkg = require("../../package.json");
|
|
5
6
|
const ZENROWS_API_URL = "https://api.zenrows.com/v1/";
|
|
6
7
|
function err(text) {
|
|
7
|
-
return {
|
|
8
|
+
return {
|
|
9
|
+
content: [{ type: "text", text: appendClaimHint(text, { body: text, message: text }) }],
|
|
10
|
+
isError: true,
|
|
11
|
+
};
|
|
8
12
|
}
|
|
9
13
|
function json(data) {
|
|
10
14
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
@@ -187,10 +191,7 @@ For full-page markdown/HTML/screenshots, use scrape instead.`,
|
|
|
187
191
|
.string()
|
|
188
192
|
.optional()
|
|
189
193
|
.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
|
+
js_render: z.boolean().optional().describe("Enable headless JS rendering (SPAs / dynamic content)"),
|
|
194
195
|
premium_proxy: z
|
|
195
196
|
.boolean()
|
|
196
197
|
.optional()
|
|
@@ -199,14 +200,8 @@ For full-page markdown/HTML/screenshots, use scrape instead.`,
|
|
|
199
200
|
.string()
|
|
200
201
|
.optional()
|
|
201
202
|
.describe("ISO 3166-1 alpha-2 country code. Requires premium_proxy or mode_auto."),
|
|
202
|
-
mode_auto: z
|
|
203
|
-
|
|
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."),
|
|
203
|
+
mode_auto: z.boolean().optional().describe("Enable Adaptive Stealth Mode (mode=auto) for tougher sites"),
|
|
204
|
+
wait_for: z.string().optional().describe("CSS selector to wait for before extracting. Requires js_render."),
|
|
210
205
|
wait: z
|
|
211
206
|
.number()
|
|
212
207
|
.int()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenrows/mcp",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Zenrows MCP server — Fetch, Extract, Batch, and Browser Sessions for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc && chmod +x dist/index.js",
|
|
14
|
+
"check:server-json": "node scripts/sync-server-json-version.mjs --check",
|
|
14
15
|
"clean": "rm -rf dist",
|
|
15
16
|
"dev": "node --env-file=.env --import tsx src/index.ts",
|
|
16
17
|
"dev:http": "node --env-file=.env --import tsx src/http.ts",
|
|
@@ -20,8 +21,9 @@
|
|
|
20
21
|
"lint": "eslint src/**/*.ts",
|
|
21
22
|
"lint:fix": "eslint src/**/*.ts --fix",
|
|
22
23
|
"prepare": "npm run build",
|
|
23
|
-
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm test",
|
|
24
|
+
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm test && npm run check:server-json",
|
|
24
25
|
"publish-beta": "npm publish --tag beta",
|
|
26
|
+
"sync:server-json": "node scripts/sync-server-json-version.mjs",
|
|
25
27
|
"test": "node --import tsx --test tests/*.test.ts",
|
|
26
28
|
"typecheck": "tsc --noEmit"
|
|
27
29
|
},
|