@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.
@@ -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: "ZenRows MCP — scrape and extract from protected sites via the Universal Scraper API (anti-bot bypass, JS rendering, proxies).",
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: "ZenRows",
97
+ name: "Zenrows",
75
98
  version: pkg.version,
76
- description: "ZenRows Universal Scraper API via MCP",
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
- "WWW-Authenticate": `Bearer realm="${AUTH_SERVER}", resource_metadata="${MCP_SERVER}/.well-known/oauth-protected-resource"`,
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
- "Link": `<${MCP_SERVER}/.well-known/oauth-protected-resource>; rel="oauth-protected-resource"`,
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, } from "./auth/ensure-key.js";
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: `Zenrows error ${response.status}: ${body}` }],
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
+ }
@@ -1,11 +1,23 @@
1
1
  import { createRequire } from "module";
2
2
  import { z } from "zod";
3
- import { BatchError, createJob, getJob, listResults, stopJob, waitForJob, } from "../batch-api.js";
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: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data) }],
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 {
@@ -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 { content: [{ type: "text", text: msg }], isError: true };
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: { title: "Open Browser & Navigate", readOnlyHint: false, destructiveHint: false },
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.string().optional().describe("World region code for geo-targeted proxy (eu=Europe, na=North America, ap=Asia Pacific, sa=South America, af=Africa, me=Middle East)"),
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`, { url: params.url });
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({ session_id: session.session_id, url: nav.url, title: nav.title, expires_at: session.expires_at });
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`, { selector, text, clear_first });
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`, { selector, value });
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`, { selector, value });
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`, { direction, distance });
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`, { selector });
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`, { selector, 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`, { selector });
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: { title: "Query All Matching Elements", readOnlyHint: true, destructiveHint: false },
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`, { selector });
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.boolean().optional().describe("Capture full page including content below the fold (default false)"),
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`, { full_page, selector });
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`, { print_background, landscape, scale });
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.boolean().optional().describe("Also require the element to be visible, not just present in the DOM (default false)"),
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`, { selector, visible });
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.number().int().min(1000).max(60000).optional().describe("How long to wait in milliseconds (default 30000, max 60000)"),
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`, { cookies: mapped });
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`, { action, key, value });
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`, { tab_id });
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({ type: z.literal("type"), selector: z.string(), text: z.string(), clear_first: z.boolean().optional() }),
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({ type: z.literal("scroll"), direction: z.enum(["up", "down", "left", "right"]), distance: z.number().optional() }),
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({ type: z.literal("screenshot"), full_page: z.boolean().optional(), selector: z.string().optional() }),
783
- z.object({ type: z.literal("wait_for_selector"), selector: z.string(), visible: z.boolean().optional() }),
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`, { selector: action.selector });
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`, { selector: action.selector });
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`, { selector: action.selector, text: action.text, clear_first: action.clear_first });
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`, { selector: action.selector, value: action.value });
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`, { selector: action.selector, value: action.value });
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`, { selector: action.selector });
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`, { selector: action.selector });
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`, { selector: action.selector });
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`, { direction: action.direction, distance: action.distance });
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`, { source_selector: action.source_selector, target_selector: action.target_selector });
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`, { selector: action.selector });
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`, { selector: action.selector, attribute: action.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`, { selector: action.selector });
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`, { selector: action.selector });
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`, { full_page: action.full_page, selector: action.selector });
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`, { selector: action.selector, visible: action.visible });
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`, { script: action.script });
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: { title: "Run Batch of Browser Actions", readOnlyHint: false, destructiveHint: false },
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.array(batchAction).min(1).max(50).describe("Ordered list of actions to perform. Each action has a 'type' field plus type-specific parameters."),
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: [{ type: "text", text: JSON.stringify({ completed: i, total: actions.length, results }) }],
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
  }
@@ -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 { content: [{ type: "text", text }], isError: true };
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
- .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."),
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.0",
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
  },