@nstdata-ai/mcp-server 0.0.0-stage → 1.0.2

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,65 @@
1
+ import { z } from 'zod';
2
+ import { isIP } from 'node:net';
3
+ import { isUtf8 } from 'node:buffer';
4
+ import { validateHeaderName, validateHeaderValue } from 'node:http';
5
+ import { guard, ToolFailure } from '../shared/errors.js';
6
+ import { createProxyTransport } from '@nstdata-ai/proxy-checker/transport';
7
+ const reservedHeaders = new Set(['host', 'connection', 'proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'transfer-encoding', 'content-length', 'upgrade', 'te', 'trailer', 'keep-alive']);
8
+ const targetHeaders = z.record(z.string().min(1).max(256), z.string().max(8192)).refine(headers => {
9
+ if (Object.keys(headers).length > 100)
10
+ return false;
11
+ try {
12
+ for (const [key, value] of Object.entries(headers)) {
13
+ if (reservedHeaders.has(key.toLowerCase()))
14
+ return false;
15
+ validateHeaderName(key);
16
+ validateHeaderValue(key, value);
17
+ }
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }, 'Invalid target headers or reserved connection/proxy header');
24
+ export function registerProxyTools(server, config) {
25
+ const transport = createProxyTransport({ proxyURL: config.proxyURL, timeoutMs: config.httpTimeoutMs,
26
+ maxResponseBytes: config.maxResponseBytes, caFile: config.proxyCAFile });
27
+ server.registerTool('nstdata_proxy_fetch', {
28
+ description: 'Read an HTTP(S) page through the configured proxy. Does not follow redirects or retry; may consume billable traffic.',
29
+ inputSchema: z.strictObject({
30
+ url: z.url().max(8192).refine(value => { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password; }).describe('HTTP(S) target accessed through the configured proxy; no embedded credentials.'),
31
+ method: z.enum(['GET', 'HEAD']).default('GET').describe('GET returns content; HEAD requests response headers without a body.'),
32
+ format: z.enum(['text', 'base64']).default('text').describe('text requires UTF-8; base64 preserves binary bytes.'),
33
+ headers: targetHeaders.optional().describe('Optional target request headers. Send Authorization or Cookie only to trusted targets; proxy and connection headers are reserved.'),
34
+ }),
35
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
36
+ }, async (args, ctx) => guard(ctx, config, async (options) => {
37
+ const response = await transport.request(args, { signal: options.signal });
38
+ if (args.format === 'text' && !isUtf8(response.body))
39
+ throw new ToolFailure('INVALID_RESPONSE', 'The response is not valid UTF-8. Use base64 to read binary content.');
40
+ const output = { statusCode: response.statusCode, encoding: args.format === 'text' ? 'utf-8' : 'base64',
41
+ body: response.body.toString(args.format === 'text' ? 'utf8' : 'base64'),
42
+ headers: { 'content-type': response.headers['content-type'], location: response.headers.location, 'retry-after': response.headers['retry-after'] } };
43
+ return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output,
44
+ isError: response.statusCode < 200 || response.statusCode >= 300 };
45
+ }));
46
+ server.registerTool('nstdata_proxy_check', {
47
+ description: 'Check the proxy once against NSTDATA_PROXY_CHECK_URL, or https://example.com/ by default. Returns observedIp only when the target echoes a valid IP. Startup does not run this check.',
48
+ inputSchema: z.strictObject({}),
49
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
50
+ }, async (_args, ctx) => guard(ctx, config, async (options) => {
51
+ const started = performance.now();
52
+ const response = await transport.request({ url: config.proxyCheckURL, method: 'GET' }, { signal: options.signal });
53
+ const text = response.body.toString('utf8').trim();
54
+ let address = text;
55
+ try {
56
+ const json = JSON.parse(text);
57
+ address = json?.ip ?? (json?.code === 200 && json?.err === false ? json.data?.ip : undefined);
58
+ }
59
+ catch { /* Plain IP responses are also supported. */ }
60
+ const output = { statusCode: response.statusCode, elapsedMs: performance.now() - started,
61
+ observedIp: typeof address === 'string' && isIP(address) ? address : undefined };
62
+ return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output,
63
+ isError: response.statusCode < 200 || response.statusCode >= 300 };
64
+ }));
65
+ }
@@ -0,0 +1,4 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { type Config } from './config.js';
3
+ export declare function createServer(config: Config): McpServer;
4
+ export { loadConfig, type Config } from './config.js';
package/dist/server.js ADDED
@@ -0,0 +1,19 @@
1
+ import { createRequire } from 'node:module';
2
+ import { McpServer } from '@modelcontextprotocol/server';
3
+ import { NstDataClient } from '@nstdata-ai/crawl';
4
+ import { validateConfig } from './config.js';
5
+ import { registerCrawlTools } from './modules/crawl.js';
6
+ import { registerProxyTools } from './modules/proxy.js';
7
+ const { version } = createRequire(import.meta.url)('../package.json');
8
+ export function createServer(config) {
9
+ config = validateConfig(config);
10
+ const server = new McpServer({ name: 'nstdata', version });
11
+ if (config.modules.includes('crawl')) {
12
+ const client = new NstDataClient(config.apiToken, { baseURL: config.baseURL });
13
+ registerCrawlTools(server, client, config);
14
+ }
15
+ if (config.modules.includes('proxy'))
16
+ registerProxyTools(server, config);
17
+ return server;
18
+ }
19
+ export { loadConfig } from './config.js';
@@ -0,0 +1,10 @@
1
+ import { type RequestOptions } from '@nstdata-ai/crawl';
2
+ import { type CallToolResult, type ServerContext } from '@modelcontextprotocol/server';
3
+ import { type Config } from '../config.js';
4
+ export declare class ToolFailure extends Error {
5
+ readonly code: string;
6
+ readonly details: Record<string, unknown>;
7
+ constructor(code: string, message: string, details?: Record<string, unknown>);
8
+ }
9
+ export declare function toolError(code: string, message: string, details?: Record<string, unknown>): CallToolResult;
10
+ export declare function guard(ctx: ServerContext, config: Config, operation: (options: RequestOptions) => Promise<CallToolResult>, operationKind?: 'request' | 'task'): Promise<CallToolResult>;
@@ -0,0 +1,58 @@
1
+ import { NstDataApiError } from '@nstdata-ai/crawl';
2
+ import { ProxyTransportError } from '@nstdata-ai/proxy-checker/transport';
3
+ import { boundResult } from './output.js';
4
+ export class ToolFailure extends Error {
5
+ code;
6
+ details;
7
+ constructor(code, message, details = {}) {
8
+ super(message);
9
+ this.code = code;
10
+ this.details = details;
11
+ this.name = 'ToolFailure';
12
+ }
13
+ }
14
+ export function toolError(code, message, details = {}) {
15
+ const output = { error: { code, message, ...details } };
16
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
17
+ }
18
+ export async function guard(ctx, config, operation, operationKind = 'request') {
19
+ const unknownSubmission = operationKind === 'task'
20
+ ? ' The submission outcome is unknown; a remote task may still exist. Review it before submitting again.' : '';
21
+ const controller = new AbortController();
22
+ const abort = () => controller.abort(new DOMException('Local request cancelled', 'AbortError'));
23
+ if (ctx.mcpReq.signal.aborted)
24
+ abort();
25
+ else
26
+ ctx.mcpReq.signal.addEventListener('abort', abort, { once: true });
27
+ const timer = setTimeout(() => controller.abort(new DOMException('Tool deadline exceeded', 'TimeoutError')), config.httpTimeoutMs);
28
+ try {
29
+ return boundResult(await operation({ signal: controller.signal, timeoutMs: config.httpTimeoutMs, maxResponseBytes: config.maxResponseBytes }), config);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof ToolFailure)
33
+ return toolError(error.code, error.message, error.details);
34
+ if (error instanceof NstDataApiError && error.message === 'Response exceeds configured maxResponseBytes') {
35
+ return toolError('RESPONSE_LIMIT', 'The response exceeds the configured byte limit. No automatic retry was performed.' + unknownSubmission);
36
+ }
37
+ if (controller.signal.aborted || (error instanceof Error && error.name === 'TimeoutError') ||
38
+ (error instanceof ProxyTransportError && ['TIMEOUT', 'CANCELLED'].includes(error.code))) {
39
+ const message = 'Local request stopped. No automatic retry was performed.'
40
+ + (operationKind === 'task' ? ' Any submitted remote task may still run; check its status before submitting again.' : '');
41
+ return toolError(error instanceof ProxyTransportError ? error.code : controller.signal.reason?.name === 'AbortError' ? 'CANCELLED' : 'TIMEOUT', message);
42
+ }
43
+ if (error instanceof ProxyTransportError)
44
+ return toolError(error.code, error.message, error.details);
45
+ const statusCode = error instanceof NstDataApiError ? error.statusCode : undefined;
46
+ const apiCode = error instanceof NstDataApiError ? error.code : undefined;
47
+ const classification = statusCode !== undefined && statusCode >= 400 ? statusCode : apiCode;
48
+ const code = classification === 401 || classification === 403 ? 'AUTHORIZATION_FAILED'
49
+ : classification === 429 ? 'RATE_LIMITED' : 'UPSTREAM_ERROR';
50
+ const message = 'The request failed. No automatic retry was performed.'
51
+ + (code === 'UPSTREAM_ERROR' && !(classification !== undefined && classification >= 400 && classification < 500) ? unknownSubmission : '');
52
+ return toolError(code, message, { statusCode, apiCode });
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ ctx.mcpReq.signal.removeEventListener('abort', abort);
57
+ }
58
+ }
@@ -0,0 +1,4 @@
1
+ import { type CallToolResult } from '@modelcontextprotocol/server';
2
+ import { type Config } from '../config.js';
3
+ export declare function binaryContent(bytes: Buffer | undefined): Record<string, string> | undefined;
4
+ export declare function boundResult(result: CallToolResult, config: Config): CallToolResult;
@@ -0,0 +1,104 @@
1
+ // Upstream JSON cannot mark itself as a locally generated byte envelope.
2
+ const inlineBinary = Symbol('inlineBinary');
3
+ export function binaryContent(bytes) {
4
+ return bytes === undefined ? undefined : { [inlineBinary]: true, encoding: 'base64', base64: bytes.toString('base64') };
5
+ }
6
+ export function boundResult(result, config) {
7
+ const secrets = [config.apiToken];
8
+ if (config.proxyURL) {
9
+ const proxy = new URL(config.proxyURL);
10
+ const username = decodeURIComponent(proxy.username), password = decodeURIComponent(proxy.password);
11
+ secrets.push(config.proxyURL, proxy.username, proxy.password, username, password);
12
+ if (username || password)
13
+ secrets.push(Buffer.from(`${username}:${password}`).toString('base64'));
14
+ }
15
+ const knownSecrets = secrets.filter((secret) => !!secret).sort((a, b) => b.length - a.length);
16
+ const redact = (value) => knownSecrets.reduce((text, secret) => text.replaceAll(secret, '<REDACTED>'), value);
17
+ const original = (result.structuredContent ?? {});
18
+ const literalFields = new Set(['encoding', 'format', 'unit', 'status']);
19
+ const literals = new Set(['utf-8', 'base64', 'codePoints', 'bytes', 'markdown', 'html', 'pdf', 'screenshot', 'rawData', 'processing', 'completed', 'failed', 'expired', 'scraping']);
20
+ const opaqueFields = new Set(['encoding', 'format', 'unit', 'status', 'id', 'taskId', 'crawlId', 'ref', 'markdownRef', 'htmlRef', 'pdfRef', 'screenshotRef', 'rawDataRef', 'nextCursor', 'prevCursor', 'errorCode']);
21
+ let sensitive = false;
22
+ const text = JSON.stringify(original, function (key, value) {
23
+ if (!(this[inlineBinary] === true && (key === 'encoding' || key === 'base64')) && knownSecrets.some(secret => key.includes(secret)))
24
+ sensitive = true;
25
+ if (typeof value !== 'string')
26
+ return value;
27
+ if (this === original && literalFields.has(key) && literals.has(value))
28
+ return value;
29
+ if (this[inlineBinary] === true && key === 'encoding')
30
+ return value;
31
+ if ((this === original || this[inlineBinary] === true) && this.encoding === 'base64' && (key === 'body' || key === 'base64')) {
32
+ const bytes = Buffer.from(value, 'base64');
33
+ if (knownSecrets.some(secret => bytes.includes(Buffer.from(secret))))
34
+ sensitive = true;
35
+ return value;
36
+ }
37
+ const filtered = redact(value);
38
+ if (opaqueFields.has(key) && filtered !== value) {
39
+ sensitive = true;
40
+ return value;
41
+ }
42
+ return filtered;
43
+ });
44
+ if (sensitive) {
45
+ const output = { error: { code: 'SENSITIVE_OUTPUT', message: 'A result containing configured credentials was withheld to preserve its structure and bytes.' } };
46
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
47
+ }
48
+ const data = JSON.parse(text);
49
+ if (text.length <= config.maxOutputChars) {
50
+ return { ...result, content: [{ type: 'text', text }], structuredContent: data };
51
+ }
52
+ const recovery = {};
53
+ const pageList = Array.isArray(data.data);
54
+ const error = { code: 'OUTPUT_LIMIT', message: pageList
55
+ ? 'Page output exceeds the local limit. Retry with the same input cursor and a smaller limit. If recovery.page has the required references, read them before using recovery.continuation, which skips that page. Do not automatically repeat a task submission.'
56
+ : 'Result exceeds the local output limit. Reduce source content or use a supported smaller page/chunk/reference. Do not automatically repeat a task submission.', recovery };
57
+ const add = (key, value) => {
58
+ if (value === undefined)
59
+ return false;
60
+ const candidate = { ...recovery, [key]: value };
61
+ if (JSON.stringify({ error: { ...error, recovery: candidate } }).length > config.maxOutputChars)
62
+ return false;
63
+ Object.assign(recovery, candidate);
64
+ return true;
65
+ };
66
+ const scalar = (value) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
67
+ for (const key of ['statusCode', 'id', 'taskId', 'crawlId', 'ref', 'format', 'offset']) {
68
+ if (scalar(data[key]))
69
+ add(key, data[key]);
70
+ }
71
+ if (Array.isArray(data.data)) {
72
+ if (data.data.length === 1 && data.data[0] && typeof data.data[0] === 'object') {
73
+ const page = data.data[0];
74
+ const summary = {};
75
+ for (const key of ['id', 'taskId', 'url', 'status', 'success', 'errorCode']) {
76
+ if (scalar(page[key]) && add('page', { ...summary, [key]: page[key] }))
77
+ summary[key] = page[key];
78
+ }
79
+ const content = page.data;
80
+ const references = {};
81
+ for (const key of ['markdownRef', 'htmlRef', 'pdfRef', 'screenshotRef', 'rawDataRef']) {
82
+ if (typeof content?.[key] === 'string' && content[key] && add('page', { ...summary, data: { ...references, [key]: content[key] } }))
83
+ references[key] = content[key];
84
+ }
85
+ if (Object.keys(references).length) {
86
+ if (typeof content?.truncated === 'boolean')
87
+ add('page', { ...summary, data: { ...references, truncated: content.truncated } });
88
+ const continuation = Object.fromEntries(['nextCursor', 'prevCursor']
89
+ .filter(key => typeof data[key] === 'string' && data[key]).map(key => [key, data[key]]));
90
+ if (Object.keys(continuation).length)
91
+ add('continuation', continuation);
92
+ }
93
+ }
94
+ }
95
+ else if (data.data && typeof data.data === 'object') {
96
+ const page = data.data;
97
+ for (const key of ['markdownRef', 'htmlRef', 'pdfRef', 'screenshotRef', 'rawDataRef', 'truncated']) {
98
+ if (scalar(page[key]))
99
+ add(key, page[key]);
100
+ }
101
+ }
102
+ const output = { error };
103
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
104
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/stdio.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
3
+ import { ProxyTransportError } from '@nstdata-ai/proxy-checker/transport';
4
+ import { createRequire } from 'node:module';
5
+ import { createServer, loadConfig } from './server.js';
6
+ import { DEFAULT_PROXY_CHECK_URL, EnvironmentConfigError } from './config.js';
7
+ const args = process.argv.slice(2);
8
+ if (args.length === 1 && args[0] === '--help') {
9
+ console.log(`Usage: nstdata-mcp-server [--help | --version]
10
+
11
+ With no arguments, start a local stdio MCP server.
12
+ Set environment variables in your MCP client configuration:
13
+ NSTDATA_MODULES crawl (default), proxy, or crawl,proxy
14
+ NSTDATA_API_TOKEN Required when Crawl is enabled
15
+ NSTDATA_PROXY_URL Required when Proxy is enabled; HTTP(S) gateway URL
16
+ NSTDATA_PROXY_CHECK_URL Optional check target; default ${DEFAULT_PROXY_CHECK_URL}
17
+
18
+ Startup does not submit tasks or check the proxy. Calls may consume credits or traffic.
19
+ Configuration reference: https://github.com/nstdata-ai/nstdata-mcp-server#readme`);
20
+ }
21
+ else if (args.length === 1 && args[0] === '--version') {
22
+ const { version } = createRequire(import.meta.url)('../package.json');
23
+ console.log(version);
24
+ }
25
+ else if (args.length) {
26
+ console.error('Unsupported arguments. Use --help for usage.');
27
+ process.exitCode = 1;
28
+ }
29
+ else
30
+ try {
31
+ const config = loadConfig();
32
+ // Validate module resources before the lazy stdio negotiation receives input.
33
+ let initialServer;
34
+ try {
35
+ initialServer = createServer(config);
36
+ }
37
+ catch (error) {
38
+ if (config.proxyCAFile && error instanceof ProxyTransportError && error.code === 'INVALID_CONFIG') {
39
+ throw new EnvironmentConfigError(['proxyCAFile']);
40
+ }
41
+ throw error;
42
+ }
43
+ const handle = serveStdio(() => {
44
+ const server = initialServer ?? createServer(config);
45
+ initialServer = undefined;
46
+ return server;
47
+ }, {
48
+ onerror: () => { console.error('MCP protocol error.'); },
49
+ });
50
+ const shutdown = () => { void handle.close().catch(() => { process.exitCode = 1; }); };
51
+ process.once('SIGINT', shutdown);
52
+ process.once('SIGTERM', shutdown);
53
+ }
54
+ catch (error) {
55
+ console.error(error instanceof EnvironmentConfigError
56
+ ? error.message
57
+ : 'Unable to start Nstdata MCP. Check the documented environment configuration.');
58
+ process.exitCode = 1;
59
+ }
package/docs/mcp.md ADDED
@@ -0,0 +1,190 @@
1
+ # MCP client setup and Crawl reference
2
+
3
+ [中文](mcp_zh.md) · [Quick start](../README.md)
4
+
5
+ ## Client setup
6
+
7
+ Install the packages and use the stdio template in [README.md](../README.md).
8
+
9
+ - **Cursor:** project configuration is `.cursor/mcp.json`; user configuration is `~/.cursor/mcp.json`. A local entry may use `envFile` with an absolute path to a protected credential file. See the [Cursor MCP guide](https://cursor.com/docs/mcp).
10
+ - **Claude Desktop:** follow the [local MCP setup guide](https://modelcontextprotocol.io/docs/develop/connect-local-servers) to merge the entry into the client configuration.
11
+
12
+ The standard configuration uses `npx` to start the installed package. Preserve other server entries and keep credentials outside version control. Restart the MCP server after changing environment configuration.
13
+
14
+ Crawl-only exposes six tools, Proxy-only two, and both modules eight. Verify tool discovery before submitting a task. Startup failures should be checked in client diagnostics/stderr: confirm the supported Node version, `npx` availability and required environment variables. If a graphical client cannot find `npx`, use its absolute executable path without changing the package argument.
15
+
16
+ ## Configuration
17
+
18
+ | Variable | Default | Constraint |
19
+ |---|---|---|
20
+ | `NSTDATA_MODULES` | `crawl` | `crawl`, `proxy`, or both, comma-separated |
21
+ | `NSTDATA_API_TOKEN` | — | Required when Crawl is enabled |
22
+ | `NSTDATA_API_BASE_URL` | `https://api.nstdata.io` | Trusted HTTP(S) URL; no embedded credentials, query or fragment |
23
+ | `NSTDATA_HTTP_TIMEOUT_MS` | `120000` | Integer 1–600000; local tool deadline, including body reads |
24
+ | `NSTDATA_MAX_RESPONSE_BYTES` | `8388608` | Integer 256–67108864; each API/storage download |
25
+ | `NSTDATA_MAX_OUTPUT_CHARS` | `16000` | Integer 1024–64000; each serialized JSON representation, in UTF-16 code units |
26
+ | `NSTDATA_MAX_CRAWL_PAGES` | `10` | Integer 1–1000; default and maximum accepted `maxPages` |
27
+
28
+ Proxy-specific variables and behavior are defined in [proxy.md](proxy.md). Disabled modules do not require their credentials.
29
+
30
+ ## Tool reference
31
+
32
+ | Tool | Required input | Operation and result |
33
+ |---|---|---|
34
+ | `nstdata_crawl_scrape` | `url` | One sync/async single-page submission; returns status/content or an ID |
35
+ | `nstdata_crawl_scrape_result` | `taskId` | One result query; retains status and available content/references |
36
+ | `nstdata_crawl_start` | `url` | One bounded site submission; returns its actual ID |
37
+ | `nstdata_crawl_status` | `crawlId` | One aggregate progress query; does not poll |
38
+ | `nstdata_crawl_pages` | `crawlId` | One page of results with actual cursors and per-page status |
39
+ | `nstdata_crawl_read_result` | `ref`, `format` | One bounded reference download and local chunk selection |
40
+
41
+ Submission tools create potentially billable tasks. Reads may also consume service resources. Formats are `markdown` (default), `html`, `links`, `pdf`, `screenshot` and `rawData`; request 1–6 entries. Scrape `mode` defaults to `sync`; site page/depth controls do not apply to single-page scraping. Proxy tools are listed in [proxy.md](proxy.md#tools).
42
+
43
+ ## Workflows
44
+
45
+ ### Single page
46
+
47
+ ```json
48
+ {
49
+ "name": "nstdata_crawl_scrape",
50
+ "arguments": {
51
+ "url": "https://example.com/",
52
+ "formats": [
53
+ "markdown"
54
+ ],
55
+ "mode": "sync",
56
+ "options": {
57
+ "onlyMainContent": true
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ For asynchronous work, use `mode: "async"`. Save the returned `id` and pass it as `taskId` to `nstdata_crawl_scrape_result`. Each call performs one query; callers must bound polling. Consume completed synchronous results directly.
64
+
65
+ Sync and result-query outputs retain `id` when supplied by the service. Query outputs also retain the requested `taskId`. A synchronous result ID alone does not guarantee that its content can be queried again.
66
+
67
+ ### Site crawl
68
+
69
+ Call `nstdata_crawl_start` with an explicit `maxPages`. Save its `id` and pass it as `crawlId` to `nstdata_crawl_status` and `nstdata_crawl_pages`.
70
+
71
+ For example, collect the Quotes homepage and its second page:
72
+
73
+ ```json
74
+ {
75
+ "name": "nstdata_crawl_start",
76
+ "arguments": {
77
+ "url": "https://quotes.toscrape.com/",
78
+ "formats": [
79
+ "markdown"
80
+ ],
81
+ "maxPages": 2,
82
+ "maxDepth": 1,
83
+ "includeUrls": [
84
+ "/page/2/"
85
+ ],
86
+ "options": {
87
+ "onlyMainContent": false
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ Use paths to select discovered links; submit the seed separately as `url`. Adding `/` to the filter can admit other paths. In this example, `onlyMainContent: true` returned only the seed, while `false` collected both pages. Full-page output can include navigation. Filter semantics are service-specific, and `maxPages` is an upper bound: verify the returned URLs, not just task completion or page count.
94
+
95
+ Page inputs accept `limit` (1–100, default 10) and one of `afterCursor`/`beforeCursor`. Copy `nextCursor` or `prevCursor` unchanged. An empty page with a cursor is not the end; stop on repeated cursors or the caller's page/call limit. Aggregate completion does not mean every page succeeded. If a page list exceeds the output limit, repeat that read from the same input cursor with a smaller `limit` (down to 1), then continue using the successful response's cursor. Advancing past a withheld page would skip unread results.
96
+
97
+ If one row still exceeds the cap, `error.recovery.page` retains its identity, status and available storage references when they fit the output budget. It is a summary, not the full row. Read the required references before using `error.recovery.continuation.nextCursor` or `prevCursor` to move past that row. Those cursors are omitted for multi-row errors or when no reference fits. If the summary cannot satisfy the request, stop and report the limit; do not skip the page or submit another crawl.
98
+
99
+ ### Read a reference
100
+
101
+ Pass the value of `data.markdownRef` as **`ref`**, with `format: "markdown"`:
102
+
103
+ ```json
104
+ {
105
+ "name": "nstdata_crawl_read_result",
106
+ "arguments": {
107
+ "ref": "RETURNED_MARKDOWN_REF",
108
+ "format": "markdown",
109
+ "offset": 0,
110
+ "length": 2000
111
+ }
112
+ }
113
+ ```
114
+
115
+ Use the same mapping for htmlRef→html, pdfRef→pdf, screenshotRef→screenshot and rawDataRef→rawData. References are opaque values, not public URLs. Only read references from an authorized task.
116
+
117
+ `length` is 1–4000, default 2000. Text uses Unicode code-point offsets and UTF-8; invalid UTF-8 fails with `INVALID_RESPONSE` before slicing. Binary uses byte offsets and base64 output. Continue at `nextOffset` until absent. Decode binary chunks separately before joining bytes. Every call downloads the full reference within the response cap, then slices locally; this is not HTTP Range or persistent caching.
118
+
119
+ ## Options
120
+
121
+ Common top-level inputs are `url`, `formats` and, for scrape, `mode`. Site traversal controls are top-level `maxPages`, `maxDepth` (0–100), `includeUrls`, `excludeUrls`, `ignoreQuery`. Site `maxPages` defaults to the configured local upper bound (10 initially). Server-side request options are nested under `options` and forwarded without that wrapper:
122
+
123
+ - `cookies`: up to 100 objects; `name`/`value` required; optional domain/httpOnly/path/secure.
124
+ - `proxy.country`: a two-letter country code; no raw proxy credentials.
125
+ - `headers`: up to 100 string entries; names up to 256 characters, values up to 8192.
126
+ - `excludeTags`, `includeTags`, `removeSelectors`: up to 100 strings; `targetSelector` is one string. Each string/selector is at most 8192 characters. `includeTags` can remove surrounding structure; use only when desired.
127
+ - `mobile`, `onlyMainContent`: booleans.
128
+ - `timeout`: integer milliseconds, 1000–60000; the upstream timeout is distinct from the local tool deadline.
129
+ - `jsRender`: optional `waitFor` (integer milliseconds, 1000–30000), `waitForSelector`, up to 100 `actions`.
130
+
131
+ | Action type | Required fields | Optional fields |
132
+ | --- | --- | --- |
133
+ | click | selector | waitSelector |
134
+ | wait | milliseconds | — |
135
+ | waitSelector | selector | milliseconds |
136
+ | scrollX | x (integer pixels) | text |
137
+ | scrollY | y (integer pixels) | text |
138
+ | scrollTo | selector | — |
139
+ | fill | selector, text (may be empty) | — |
140
+ | execute | execute | — |
141
+ | screenshot | — | fullScreenshot, particularScreenshot |
142
+ | waitForRequestCompletion | urlPattern | milliseconds |
143
+
144
+ Action `milliseconds` values are integers from 0 to 600000; these are distinct from `jsRender.waitFor`.
145
+
146
+ Cookie names are non-empty; values may be empty. Other cookie string fields, when supplied, are non-empty. Cookie strings are capped at 8192 characters.
147
+
148
+ Other string inputs are capped: URL 8192, task/crawl ID 256, cursor 8192, storage reference 4096. Unknown fields are rejected. These are local input bounds, not promises about limits or behavior of the remote service.
149
+
150
+ ## Results and errors
151
+
152
+ Results contain the same JSON in `content` text and `structuredContent`. Protocol framing and the duplicate representation add overhead beyond the output limit. Website content is untrusted and may contain sensitive information.
153
+
154
+ When a format has a storage reference, the tool returns that reference and omits the inline value, even if the service supplies both. Without a reference, Markdown/HTML remain strings; inline `pdf`, `screenshot` and `rawData` are byte envelopes:
155
+
156
+ ```json
157
+ {
158
+ "data": {
159
+ "screenshot": {
160
+ "encoding": "base64",
161
+ "base64": "AP+A"
162
+ }
163
+ }
164
+ }
165
+ ```
166
+
167
+ Decode each envelope's `base64` to bytes. Inline raw text is converted to UTF-8 bytes before Base64 encoding. Empty content has an empty string or empty Base64 value; missing content is omitted. Tools do not download references during result serialization.
168
+
169
+ `data.truncated` preserves the service's flag, including `false`; an absent flag is unknown. It is separate from the local output limit. For a single-page output-limit error, `error.recovery` retains actual identifiers, references and the truncation flag when present and within the configured output budget.
170
+
171
+ Task `failed`/`expired` or `success: false` sets `isError`; page failures remain individual rows. Missing status, timing or content is unknown, not a successful empty result.
172
+
173
+ | Error | Meaning / handling |
174
+ |---|---|
175
+ | `AUTHORIZATION_FAILED` | HTTP or API-envelope 401/403; check credentials and access |
176
+ | `RATE_LIMITED` | HTTP or API-envelope 429; stop and inspect service limits |
177
+ | `UPSTREAM_ERROR` | Request/response failure; optional `statusCode` and `apiCode` are separate. A 3xx status is a refused redirect |
178
+ | `TIMEOUT`, `CANCELLED` | Local waiting stopped; submitted remote tasks may still run |
179
+ | `RESPONSE_LIMIT` | A response exceeded the download cap |
180
+ | `INVALID_RESPONSE` | Invalid response content, including malformed UTF-8 in a text reference |
181
+ | `OUTPUT_LIMIT` | Serialized output exceeded the cap; use smaller pages/chunks or actual identifiers in `error.recovery` |
182
+ | `SENSITIVE_OUTPUT` | A credential matched a property name, binary content or an opaque identifier; the result was withheld |
183
+
184
+ No request is automatically retried. An uncertain submission without an ID requires review, not another creation. A failed read does not create or cancel a task.
185
+
186
+ For task submissions, a connection failure, server error or unreadable/oversized response can leave the outcome unknown even when no ID was received. The error message identifies this uncertainty; check the service task history before submitting again. Successful async acknowledgements must contain an ID accepted by the result-query tools; a missing or unusable ID is an unknown outcome. Explicit task failures and synchronous content without a diagnostic ID remain distinct. Authentication and rate-limit rejections remain distinct from unknown outcomes.
187
+
188
+ `OUTPUT_LIMIT` is not a download continuation API. Inline-only content may have no reference; an ID alone does not make it chunkable. Configured credentials are filtered from display text and checked in property names and decoded binary bytes, including nested inline envelopes. Matching names, opaque identifiers and binary payloads are withheld rather than altered; short credentials can also match ordinary field names. Other website secrets are not automatically detected. Raw HTTP bodies and transport messages are omitted from request errors.
189
+
190
+ API/storage redirects are refused, including same-origin redirects. Configure a verified final API origin rather than following Location. For Proxy failures and limits, see [proxy.md](proxy.md).