crawlforge-mcp-server 5.6.10 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -60,7 +60,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
60
60
 
61
61
  ## Project Overview
62
62
 
63
- CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 29 web scraping, crawling, and content processing tools (6 inline + 23 advanced).
63
+ CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 30 web scraping, crawling, and content processing tools (6 inline + 24 advanced).
64
64
 
65
65
  **Current Version:** 5.6.6
66
66
 
@@ -162,18 +162,19 @@ Tools are organized in subdirectories by category:
162
162
  - `crawl/` - crawlDeep, mapSite
163
163
  - `extract/` - analyzeContent, extractContent, extractStructured, extractWithLlm, listOllamaModels, processDocument, summarizeContent
164
164
  - `research/` - deepResearch
165
+ - `result/` - readResult (`read_result`: search/slice/lines/json_path over a stored over-threshold result by `result_handle`, v5.8.0)
165
166
  - `search/` - searchWeb (proxied through CrawlForge.dev API), serpRank (real Google organic rank via DataForSEO; adapter in `search/adapters/dataforseoSearch.js`), redditSearch (Reddit posts/comments/threads via Arctic Shift + PullPush community archives — reddit.com blocks scrapers)
166
167
  - `templates/` - ScrapeTemplateTool (10 pre-built site templates, v4.0.0)
167
168
  - `tracking/` - trackChanges
168
169
  - `llmstxt/` - generateLLMsTxt
169
170
 
170
- ### Available MCP Tools (29 total)
171
+ ### Available MCP Tools (30 total)
171
172
 
172
173
  **Basic Tools (server.js inline, 6):**
173
174
  fetch_url, extract_text, extract_links, extract_metadata, extract_embedded_state, scrape_structured
174
175
 
175
- **Advanced Tools (23):**
176
- search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, get_batch_results, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
176
+ **Advanced Tools (24):**
177
+ search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, get_batch_results, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent, read_result
177
178
 
178
179
  **serp_rank (DataForSEO):**
179
180
  - `serp_rank` — reports where a target domain ranks in Google's REAL organic results for a keyword (the SERP position Google Custom Search / `search_web` cannot give). Backed by the DataForSEO Google Organic SERP API (Live Advanced, `POST /v3/serp/google/organic/live/advanced`, HTTP Basic auth). Credentials via `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD`, billed to the user's own DataForSEO account (~US$0.002 per 10 results of `depth`, so $0.004 at the default `depth:20` and $0.02 at `depth:100`), separate from CrawlForge credits. When unconfigured it returns `{ configured:false }` and charges **0** credits; when configured, **Cost: 5**. Never fabricates a rank.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <b>29 web scraping, crawling, deep-research &amp; autonomous-extraction tools for Claude, Cursor &amp; any MCP client.</b><br>
6
+ <b>30 web scraping, crawling, deep-research &amp; autonomous-extraction tools for Claude, Cursor &amp; any MCP client.</b><br>
7
7
  Clean Markdown &amp; structured JSON from any site. Get started with <b>1,000 free credits</b> — no credit card required.
8
8
  </p>
9
9
 
@@ -35,7 +35,7 @@
35
35
 
36
36
  ## 🎯 Why CrawlForge?
37
37
 
38
- - **29 MCP-native tools** — scraping, crawling, search, real Google SERP rank tracking, deep research, an autonomous `agent`, a unified multi-format `scrape`, document processing, stealth browsing, and more, callable directly from your AI assistant.
38
+ - **30 MCP-native tools** — scraping, crawling, search, real Google SERP rank tracking, deep research, an autonomous `agent`, a unified multi-format `scrape`, document processing, stealth browsing, and more, callable directly from your AI assistant.
39
39
  - **Generous free tier** — 1,000 credits to start instantly, no credit card. The grant is one-time rather than monthly, and the credits never expire.
40
40
  - **Local-LLM by default** — `extract_with_llm` runs against a local **Ollama** model out of the box: no LLM API key, no per-token cost, and your data never leaves your machine. Cloud (OpenAI/Anthropic) is opt-in.
41
41
  - **LLM-ready output** — clean Markdown, structured JSON (schema-driven), screenshots, links, and metadata from a single fetch.
@@ -47,7 +47,7 @@
47
47
 
48
48
  | | **CrawlForge MCP** | Firecrawl | Raw scraping API |
49
49
  |---|:---:|:---:|:---:|
50
- | Native MCP server | ✅ 29 tools | ✅ | ❌ |
50
+ | Native MCP server | ✅ 30 tools | ✅ | ❌ |
51
51
  | Free tier | ✅ 1,000 credits, rollover | Limited | Varies |
52
52
  | Self-hosted / local LLM extraction (Ollama) | ✅ default, $0/token | ❌ | ❌ |
53
53
  | Autonomous agent (no URLs needed) | ✅ `agent` | ✅ | ❌ |
@@ -178,7 +178,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
178
178
  | `scrape_template` | 1 | Structured data from well-known sites (Amazon, GitHub, LinkedIn, YouTube, Reddit, Hacker News, npm, and more) without writing selectors |
179
179
  | `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
180
180
  | `get_batch_results` | 1 | Retrieve paginated results for a `batch_scrape` job by `batchId` |
181
- | `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings |
181
+ | `read_result` | 1 | Search, slice, read lines or a JSON path from a result a tool returned with `truncated: true` and a `result_handle` (kept 1 hour on your own machine) — never fetch the page again |
182
+ | `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema, plus `{type:"highlights",query}` and `{type:"question",question}` for only the matching sentences, table rows and code blocks, verbatim with offsets into the markdown: +1 credit once per call, `mode:"model"` +3) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings |
182
183
  | `scrape_structured` | 2 | Extract structured data with CSS selectors |
183
184
  | `extract_embedded_state` | 2 | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads, Nuxt, Apollo, Redux, `<script type="application/json">` — with a `path` to scope the result. No LLM in the extraction path |
184
185
  | `extract_content` | 2 | Enhanced content extraction |
@@ -201,6 +202,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
201
202
  | `agent` | 8 | **Autonomous research/extraction from a natural-language prompt — no URLs required.** Plans, gathers, and shapes an answer under hard safety stops (max steps/URLs/wall-clock enforced by the orchestrator, never the LLM) |
202
203
  | `deep_research` | 10 | Multi-stage research with source verification |
203
204
 
205
+ Ten tools (`scrape`, `fetch_url`, `extract_content`, `crawl_deep`, `batch_scrape`, `stealth_mode`, `scrape_with_actions`, `process_document`, `deep_research`, `extract_embedded_state`) accept `max_inline_chars` (default 40,000; env `CRAWLFORGE_MAX_INLINE_CHARS`): a result over it comes back as a `preview` plus a `result_handle` for `read_result`, with the full result kept for 1 hour under `~/.crawlforge/results/` on your own machine — nothing is uploaded.
206
+
204
207
  For the full canonical capabilities reference (all tools, CLI commands, stealth engines, research workflow), see [SKILL.md](docs/SKILL.md).
205
208
 
206
209
  <p align="right"><a href="#table-of-contents">↑ Back to top</a></p>
@@ -217,7 +220,7 @@ For the full canonical capabilities reference (all tools, CLI commands, stealth
217
220
  | **Business** ($399) | 250,000 / month | Large scale operations |
218
221
 
219
222
  **All plans include:**
220
- - Access to all 29 tools
223
+ - Access to all 30 tools
221
224
  - Credits never expire; paid-plan credits roll over month to month
222
225
  - API access and webhook notifications
223
226
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.10",
3
+ "version": "5.8.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
- "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
5
+ "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
7
7
  "bin": {
8
8
  "crawlforge": "src/cli/index.js",
@@ -116,7 +116,7 @@
116
116
  "cheerio": "^1.1.2",
117
117
  "commander": "^14.0.3",
118
118
  "compromise": "^14.14.4",
119
- "crawlforge-extractors": "^1.6.5",
119
+ "crawlforge-extractors": "^1.8.0",
120
120
  "diff": "^9.0.0",
121
121
  "dotenv": "^17.2.1",
122
122
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -26,7 +26,7 @@ import { DeepResearchTool } from "./src/tools/research/deepResearch.js";
26
26
  import { TrackChangesTool } from "./src/tools/tracking/trackChanges/index.js";
27
27
  import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
28
28
  import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
29
- import { UnifiedScrapeTool } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
29
+ import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
30
30
  import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
31
31
  import { StealthBrowserManager } from "./src/core/StealthBrowserManager.js";
32
32
  import { LocalizationManager } from "./src/core/LocalizationManager.js";
@@ -55,6 +55,8 @@ import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
55
55
  import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
56
56
  import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
57
57
  import { extractEmbeddedStateHandler } from "./src/tools/extract/extractEmbeddedState.js";
58
+ import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/readResult.js"; // Phase 2
59
+ import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
58
60
  // D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
59
61
  import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
60
62
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
@@ -107,8 +109,8 @@ const taskStore = createTaskStore({ logger });
107
109
  // Create the server
108
110
  const server = new McpServer({
109
111
  name: "crawlforge",
110
- version: "5.6.10",
111
- description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
112
+ version: "5.8.0",
113
+ description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
112
114
  homepage: "https://www.crawlforge.dev",
113
115
  icon: "https://www.crawlforge.dev/icon.png",
114
116
  icons: [{ src: "https://www.crawlforge.dev/icon.png", mimeType: "image/png", sizes: ["any"] }],
@@ -125,6 +127,7 @@ const server = new McpServer({
125
127
  "- Exact values from a Next.js/Nuxt/Redux payload -> extract_embedded_state (2) with a path.",
126
128
  "- Known CSS selectors -> scrape_structured (2); fields you can describe but not select -> extract_structured (3).",
127
129
  "- A report from several sources -> ONE deep_research call (10 + ~1 per 5 sources); it replaces a search_web + scrape fan-out that costs 5 per search and 2 per page. Open question with no URLs -> agent (8).",
130
+ "- A result that came back truncated: true with a result_handle -> read_result (1): search, slice, lines or json_path over the stored result; never fetch the page again.",
128
131
  "Rules: never fetch a URL whose content is already in this conversation - reuse it. One call per page: scrape with several formats replaces fetch_url + extract_* pairs. Error results end with \"Next step:\" naming the tool to try; follow it instead of retrying the same call. Use the client's built-in web search/fetch only when CrawlForge is unavailable or out of credits."
129
132
  ].join("\n"),
130
133
  taskStore
@@ -142,7 +145,7 @@ server.registerPrompt("getting-started", {
142
145
  role: "user",
143
146
  content: {
144
147
  type: "text",
145
- text: "You have access to CrawlForge MCP: 29 metered web tools (credits per call, shown in brackets). Pick one tool per step from this ladder, and reuse content already in the conversation instead of fetching it again.\n" +
148
+ text: "You have access to CrawlForge MCP: 30 metered web tools (credits per call, shown in brackets). Pick one tool per step from this ladder, and reuse content already in the conversation instead of fetching it again.\n" +
146
149
  "\n" +
147
150
  "Read a page\n" +
148
151
  "- scrape (2): one URL, every format you need in one call - markdown, links, metadata, html, screenshot, json. Default for any page whose URL you have.\n" +
@@ -176,6 +179,9 @@ server.registerPrompt("getting-started", {
176
179
  "- deep_research (10): multi-source report. agent (8): open question, no URLs.\n" +
177
180
  "- track_changes (3): create_baseline, then compare. generate_llms_txt (5): write a site's llms.txt.\n" +
178
181
  "\n" +
182
+ "Large results\n" +
183
+ "- read_result (1): a result that came back truncated: true with a result_handle - search, slice, lines or json_path over the stored result; never fetch the page again.\n" +
184
+ "\n" +
179
185
  "Error results end with \"Next step:\" naming the tool to try - follow it rather than retrying the same call.\n" +
180
186
  "\n" +
181
187
  "Get your API key at https://www.crawlforge.dev/signup (1,000 free credits)"
@@ -256,6 +262,7 @@ agentTool.setMcpServer(server); // D4 D2: SamplingClient + Elicitation
256
262
  trackChangesTool.setMcpServer(server); // v4.8: SamplingClient for scheduled-monitor goal judging
257
263
  extractWithLlmTool.setMcpServer(server); // SamplingClient fallback
258
264
  summarizeContentTool.setMcpServer(server); // SamplingClient fallback
265
+ unifiedScrapeTool.setMcpServer(server); // SamplingClient for the highlights/question mode:"model" step
259
266
  AuthManager.setElicitation(elicitation);
260
267
 
261
268
  // ─── D1.1 Resource Templates (MCP Resources) ─────────────────────────────────
@@ -354,7 +361,8 @@ registerToolIfEnabled("fetch_url", {
354
361
  url: z.string().url().describe("The URL to fetch content from"),
355
362
  headers: z.record(z.string()).optional().describe("Custom HTTP headers to include in the request"),
356
363
  timeout: z.number().min(1000).max(30000).optional().default(10000).describe("Request timeout in milliseconds (1000-30000)"),
357
- ...COMPLIANCE_PARAMS
364
+ ...COMPLIANCE_PARAMS,
365
+ ...MAX_INLINE_CHARS_PARAM
358
366
  }
359
367
  }, withAuth("fetch_url", fetchUrlHandler));
360
368
 
@@ -401,7 +409,8 @@ registerToolIfEnabled("extract_embedded_state", {
401
409
  inputSchema: {
402
410
  url: z.string().url().describe("The URL to read embedded state from"),
403
411
  path: z.string().optional().describe("Return only this subtree instead of the whole payload. Dotted keys and array indexes, e.g. \"next_data.props.pageProps\" or \"next_f[0].f\" — not JSONPath (no wildcards, filters or recursion). State payloads are routinely over a megabyte; scope them."),
404
- ...COMPLIANCE_PARAMS
412
+ ...COMPLIANCE_PARAMS,
413
+ ...MAX_INLINE_CHARS_PARAM
405
414
  }
406
415
  }, withAuth("extract_embedded_state", extractEmbeddedStateHandler));
407
416
 
@@ -574,7 +583,8 @@ if (toolFilter.isEnabled("crawl_deep")) {
574
583
  headers: z.record(z.string()).optional(),
575
584
  body: z.string().optional()
576
585
  }).optional()
577
- }).optional().describe("Shared cookie-jar/session for login-then-crawl workflows")
586
+ }).optional().describe("Shared cookie-jar/session for login-then-crawl workflows"),
587
+ ...MAX_INLINE_CHARS_PARAM
578
588
  },
579
589
  outputSchema: OUTPUT_SCHEMAS.crawl_deep,
580
590
  execution: TASK_EXECUTION
@@ -636,7 +646,8 @@ registerToolIfEnabled("extract_content", {
636
646
  inputSchema: {
637
647
  url: z.string().url().describe("The URL to extract content from"),
638
648
  options: z.object({}).passthrough().optional().describe("Additional extraction options"),
639
- ...COMPLIANCE_PARAMS
649
+ ...COMPLIANCE_PARAMS,
650
+ ...MAX_INLINE_CHARS_PARAM
640
651
  }
641
652
  }, withAuth("extract_content", async (params) => {
642
653
  try {
@@ -660,7 +671,8 @@ registerToolIfEnabled("process_document", {
660
671
  // C3: passthrough so granular options (maxPages, pageRange:{start,end},
661
672
  // extractText, outputFormat, etc.) reach the tool instead of being stripped.
662
673
  options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)"),
663
- ...COMPLIANCE_PARAMS
674
+ ...COMPLIANCE_PARAMS,
675
+ ...MAX_INLINE_CHARS_PARAM
664
676
  }
665
677
  }, withAuth("process_document", async (params) => {
666
678
  try {
@@ -825,7 +837,8 @@ if (toolFilter.isEnabled("batch_scrape")) {
825
837
  maxRetries: z.number().min(0).max(5).default(1),
826
838
  tags: z.array(z.string()).default([])
827
839
  }).optional().describe("Job management options for async processing"),
828
- ...COMPLIANCE_PARAMS
840
+ ...COMPLIANCE_PARAMS,
841
+ ...MAX_INLINE_CHARS_PARAM
829
842
  },
830
843
  execution: TASK_EXECUTION
831
844
  }, makeTaskToolHandler({
@@ -864,6 +877,14 @@ registerToolIfEnabled("get_batch_results", {
864
877
  }
865
878
  }));
866
879
 
880
+ // Tool: read_result — Phase 2: read a stored result by the handle a truncated result returned
881
+ registerToolIfEnabled("read_result", {
882
+ description: "Use this to read a result that came back with truncated: true and a result_handle - the tool kept the whole result for 1 hour and returned a preview. operation:\"search\" finds a literal query with offsets and context, \"slice\" returns characters from an offset, \"lines\" pages by line, \"json_path\" reads one subtree of a JSON result (crawl_deep pages, batch results, a fetch_url JSON body). Not a fetching tool: never call the original tool again while the handle is valid, and not for a result that arrived whole. Cost: 1 credit. Example: read_result({handle: \"res_…\", operation: \"search\", query: \"pricing\"})",
883
+ annotations: { title: "Read Result", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
884
+ inputSchema: READ_RESULT_INPUT_SHAPE,
885
+ outputSchema: OUTPUT_SCHEMAS.read_result
886
+ }, withAuth("read_result", readResultHandler));
887
+
867
888
  // Tool: scrape_with_actions
868
889
  registerToolIfEnabled("scrape_with_actions", {
869
890
  description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Set browserOptions.stealth:true to run the chain in the stealth browser. robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"click\",selector:\"#login\"},{type:\"type\",selector:\"#email\",text:\"user@a.com\"}]})",
@@ -946,7 +967,8 @@ registerToolIfEnabled("scrape_with_actions", {
946
967
  continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
947
968
  maxRetries: z.number().min(0).max(3).default(1).describe("Maximum retry attempts on failure"),
948
969
  screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
949
- respect_robots: COMPLIANCE_PARAMS.respect_robots
970
+ respect_robots: COMPLIANCE_PARAMS.respect_robots,
971
+ ...MAX_INLINE_CHARS_PARAM
950
972
  }
951
973
  }, withAuth("scrape_with_actions", async (params) => {
952
974
  try {
@@ -1023,7 +1045,8 @@ if (toolFilter.isEnabled("deep_research")) {
1023
1045
  url: z.string().url(),
1024
1046
  events: z.array(z.enum(['started', 'progress', 'completed', 'failed'])).optional().default(['completed']),
1025
1047
  headers: z.record(z.string()).optional()
1026
- }).optional().describe("Webhook for progress and completion notifications")
1048
+ }).optional().describe("Webhook for progress and completion notifications"),
1049
+ ...MAX_INLINE_CHARS_PARAM
1027
1050
  },
1028
1051
  execution: TASK_EXECUTION
1029
1052
  }, makeTaskToolHandler({
@@ -1043,33 +1066,16 @@ if (toolFilter.isEnabled("deep_research")) {
1043
1066
 
1044
1067
  // Tool: scrape (D4 D1 — unified multi-format single-fetch)
1045
1068
  registerToolIfEnabled("scrape", {
1046
- description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Not for raw API/JSON bodies (fetch_url), a blocked site (stealth_mode), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape). Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
1069
+ description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Not for raw API/JSON bodies (fetch_url), a blocked site (stealth_mode), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape). Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
1047
1070
  annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1048
1071
  // Claude Code tool search loads only names + instructions at session start; this flag
1049
1072
  // ships the full definition too, so the first call needs no ToolSearch round-trip.
1050
1073
  _meta: { "anthropic/alwaysLoad": true },
1074
+ // The tool module owns the schema (0.3); this is the same shape it validates with.
1051
1075
  inputSchema: {
1052
- url: z.string().url().describe("The URL to scrape"),
1053
- formats: z.array(z.union([
1054
- z.enum(["markdown", "html", "rawHtml", "text", "links", "metadata", "screenshot", "branding"]),
1055
- z.object({
1056
- type: z.literal("json"),
1057
- schema: z.record(z.any()).optional().describe("JSON schema for extraction"),
1058
- prompt: z.string().optional().describe("Extraction instruction for the LLM")
1059
- })
1060
- ])).min(1).optional().default(["markdown"]).describe("Formats to return (default: [\"markdown\"])"),
1061
- onlyMainContent: z.boolean().optional().default(true).describe("Strip boilerplate via Readability (default: true)"),
1062
- timeoutMs: z.number().min(1000).max(60000).optional().default(15000).describe("Fetch timeout in ms"),
1063
- brandingOptions: z.object({
1064
- fetchLinkedCss: z.boolean().optional().default(true).describe("Fetch linked stylesheets for richer color/font extraction"),
1065
- maxStylesheets: z.number().min(0).max(20).optional().default(10).describe("Max linked stylesheets to fetch")
1066
- }).optional().describe("Options for the \"branding\" format"),
1067
- screenshotOptions: z.object({
1068
- fullPage: z.boolean().optional().default(false).describe("Capture the full scrollable page"),
1069
- format: z.enum(["png", "jpeg"]).optional().default("png"),
1070
- quality: z.number().min(0).max(100).optional().describe("JPEG quality (jpeg only)")
1071
- }).optional().describe("Options for the \"screenshot\" format"),
1072
- ...COMPLIANCE_PARAMS
1076
+ ...SCRAPE_INPUT_SHAPE,
1077
+ ...COMPLIANCE_PARAMS,
1078
+ ...MAX_INLINE_CHARS_PARAM
1073
1079
  },
1074
1080
  outputSchema: OUTPUT_SCHEMAS.scrape
1075
1081
  }, withAuth("scrape", async (params) => {
@@ -1089,6 +1095,11 @@ registerToolIfEnabled("scrape", {
1089
1095
  return shot;
1090
1096
  });
1091
1097
  }
1098
+ // A blocked verdict (0.1) is an error result: withAuth bills it at the
1099
+ // error rate and appends the hint naming stealth_mode, and the SDK skips
1100
+ // outputSchema validation on isError. The JSON body still carries the
1101
+ // verdict (success:false, error, blocked) the way stealth_mode's does.
1102
+ if (result.success === false) return { ...dualOutput(result), isError: true };
1092
1103
  return dualOutput(result);
1093
1104
  } catch (error) {
1094
1105
  return { content: [{ type: "text", text: `Scrape failed: ${error.message}` }], isError: true };
@@ -1381,7 +1392,8 @@ registerToolIfEnabled("stealth_mode", {
1381
1392
  formats: z.array(z.enum(["markdown", "html", "text", "links", "metadata", "screenshot"])).optional().default(["markdown"]).describe("Formats to return from operation:\"scrape\" (default: [\"markdown\"]). \"screenshot\" returns a crawlforge://screenshot/{id} resource URI."),
1382
1393
  wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
1383
1394
  verbose: z.boolean().optional().default(false).describe("Return the full generated fingerprint from create_context instead of a summary"),
1384
- respect_robots: COMPLIANCE_PARAMS.respect_robots
1395
+ respect_robots: COMPLIANCE_PARAMS.respect_robots,
1396
+ ...MAX_INLINE_CHARS_PARAM
1385
1397
  }
1386
1398
  }, withAuth("stealth_mode", async ({ operation, stealthConfig, contextId, urlToTest, url, formats, wait_for, verbose, engine, respect_robots }) => {
1387
1399
  try {
@@ -1730,10 +1742,10 @@ async function runServer() {
1730
1742
  console.error("Search enabled: true (via CrawlForge proxy)");
1731
1743
 
1732
1744
  const allTools = [
1733
- "fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured",
1745
+ "fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured", "extract_embedded_state",
1734
1746
  "search_web", "serp_rank", "reddit_search", "crawl_deep", "map_site",
1735
1747
  "extract_content", "process_document", "summarize_content", "analyze_content",
1736
- "batch_scrape", "get_batch_results", "scrape_with_actions",
1748
+ "batch_scrape", "get_batch_results", "read_result", "scrape_with_actions",
1737
1749
  "deep_research", "track_changes", "generate_llms_txt",
1738
1750
  "stealth_mode", "localization", "extract_structured", "extract_with_llm",
1739
1751
  "list_ollama_models", "scrape_template", // D3.3
@@ -5,12 +5,18 @@
5
5
 
6
6
  // Using native fetch (Node.js 18+)
7
7
  import fs from 'fs/promises';
8
+ import { readFileSync } from 'fs';
8
9
  import path from 'path';
9
10
  import { randomUUID } from 'crypto';
10
11
  import { isCreatorModeVerified } from './creatorMode.js';
11
12
  import { resolveApiEndpoint } from './endpointGuard.js';
12
13
  import { logger } from '../utils/Logger.js';
13
14
  import { maskSecrets } from '../utils/secretMask.js';
15
+ import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
16
+ // Stamped on every usage report so support can tell which client version
17
+ // made a call; it read '3.0.3' from 3.0.3 to 5.6.10 (the website stores it
18
+ // per record from its Phase 1.3 on).
19
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
14
20
  // D1.4: Elicitation for low-credit warnings (lazy import to avoid circular dep)
15
21
  let _ElicitationHelper = null;
16
22
  function getElicitationHelper() {
@@ -368,7 +374,7 @@ class AuthManager {
368
374
  timestamp: new Date().toISOString(),
369
375
  requestId,
370
376
  idempotencyKey,
371
- version: '3.0.3'
377
+ version: PACKAGE_VERSION
372
378
  };
373
379
 
374
380
  try {
@@ -493,7 +499,7 @@ class AuthManager {
493
499
  timestamp: entry.timestamp,
494
500
  requestId: entry.requestId,
495
501
  idempotencyKey,
496
- version: '3.0.3'
502
+ version: PACKAGE_VERSION
497
503
  }),
498
504
  signal: AbortSignal.timeout(5000)
499
505
  });
@@ -554,6 +560,7 @@ class AuthManager {
554
560
  scrape_template: 1,
555
561
  list_ollama_models: 1,
556
562
  get_batch_results: 1, // retrieval of an already-paid batch job
563
+ read_result: 1, // reads a stored result an earlier call already paid for
557
564
 
558
565
  // 2 credits
559
566
  scrape_structured: 2,
@@ -618,6 +625,14 @@ class AuthManager {
618
625
  return costs.search_web;
619
626
  }
620
627
 
628
+ // The query-scoped formats (highlights, question) add 1 once per call,
629
+ // and mode:"model" adds 3 once; the base price in the table is what the
630
+ // backend sees. The rule lives with the format schemas.
631
+ if (tool === 'scrape') {
632
+ const { query, model } = scrapeFormatSurcharge(params?.formats);
633
+ return costs.scrape + query + model;
634
+ }
635
+
621
636
  return costs[tool] ?? 1;
622
637
  }
623
638
 
@@ -674,7 +689,7 @@ class AuthManager {
674
689
  break;
675
690
  case 'scrape': {
676
691
  projected = base;
677
- note = 'Fixed cost per invocation. json format may incur external LLM cost (billed by your provider).';
692
+ note = 'Base 2; a highlights or question format adds 1 once per call, mode:"model" adds 3 once (charged at the extractive price when no LLM route exists). json format may incur external LLM cost (billed by your provider).';
678
693
  break;
679
694
  }
680
695
  case 'agent': {
@@ -0,0 +1,230 @@
1
+ /**
2
+ * ResultStore — where a tool result lives once it is too large to return
3
+ * inline (Phase 2, result handles).
4
+ *
5
+ * Local and per-process: the store is on the machine the server runs on,
6
+ * which is where the result already was. Nothing leaves it. Entries are
7
+ * written under ~/.crawlforge/results/ (same root as SnapshotManager's
8
+ * snapshots), indexed in memory, evicted least-recently-used once the total
9
+ * passes maxBytes, and dropped after ttlMs whatever happens. A result that
10
+ * cannot be written to disk stays in memory instead — put never fails for a
11
+ * disk error.
12
+ *
13
+ * Handles are `res_<uuid>` (or a caller-supplied key such as a batch id).
14
+ * RESULT_HANDLE_PATTERN is the path-traversal guard: every method rejects a
15
+ * handle that does not match before touching the filesystem.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { randomUUID } from 'node:crypto';
22
+
23
+ export const RESULT_HANDLE_PATTERN = /^(res|batch)_[A-Za-z0-9_-]{1,80}$/;
24
+ const KEY_PATTERN = /^[A-Za-z0-9_-]{4,80}$/;
25
+
26
+ export class ResultStore {
27
+ /**
28
+ * @param {object} [options]
29
+ * @param {string} [options.baseDir] — directory for the result files (default ~/.crawlforge/results)
30
+ * @param {number} [options.maxBytes] — LRU budget in serialised bytes (default 200 MB)
31
+ * @param {number} [options.ttlMs] — how long an entry is readable (default 1 hour)
32
+ * @param {number} [options.sweepIntervalMs] — expiry sweep period (default 5 minutes)
33
+ * @param {object} [options.logger] — optional logger with warn()
34
+ */
35
+ constructor({
36
+ baseDir,
37
+ maxBytes = 200 * 1024 * 1024,
38
+ ttlMs = 60 * 60 * 1000,
39
+ sweepIntervalMs = 5 * 60 * 1000,
40
+ logger = null
41
+ } = {}) {
42
+ this.baseDir = baseDir || path.join(os.homedir(), '.crawlforge', 'results');
43
+ this.maxBytes = maxBytes;
44
+ this.ttlMs = ttlMs;
45
+ this.logger = logger;
46
+ // Insertion order is LRU order: get() re-inserts the entry it read.
47
+ this.index = new Map();
48
+ this.totalBytes = 0;
49
+
50
+ this._cleanupStaleFiles();
51
+
52
+ // .unref() so this timer never blocks process exit on its own — matches
53
+ // SnapshotManager's cleanupTimer.
54
+ this._sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs);
55
+ if (typeof this._sweepTimer.unref === 'function') this._sweepTimer.unref();
56
+ }
57
+
58
+ /**
59
+ * Store a payload. Never throws for a disk error.
60
+ * @param {string} toolName
61
+ * @param {unknown} payload — any JSON-serialisable value
62
+ * @param {{ key?: string, meta?: object }} [options] — key: caller-supplied handle
63
+ * @returns {string} handle
64
+ */
65
+ put(toolName, payload, { key, meta = {} } = {}) {
66
+ let handle;
67
+ if (key !== undefined) {
68
+ if (typeof key !== 'string' || !KEY_PATTERN.test(key)) {
69
+ throw new Error(`ResultStore: invalid key "${key}"`);
70
+ }
71
+ if (!RESULT_HANDLE_PATTERN.test(key)) {
72
+ throw new Error(`ResultStore: key "${key}" must start with res_ or batch_ to be readable as a handle`);
73
+ }
74
+ handle = key;
75
+ } else {
76
+ handle = `res_${randomUUID()}`;
77
+ }
78
+
79
+ const json = JSON.stringify(payload);
80
+ const bytes = Buffer.byteLength(json);
81
+ const createdAt = Date.now();
82
+ const entry = { handle, toolName, bytes, createdAt, expiresAt: createdAt + this.ttlMs, meta };
83
+
84
+ // Re-put under the same key replaces the old entry (and its bytes).
85
+ if (this.index.has(handle)) this.delete(handle);
86
+
87
+ try {
88
+ fs.mkdirSync(this.baseDir, { recursive: true });
89
+ fs.writeFileSync(this._file(handle), json);
90
+ } catch (error) {
91
+ entry.inMemory = true;
92
+ entry.json = json;
93
+ this.logger?.warn?.(`ResultStore: could not write ${handle} to ${this.baseDir}, keeping it in memory: ${error.message}`);
94
+ }
95
+
96
+ this.index.set(handle, entry);
97
+ this.totalBytes += bytes;
98
+ this._evict(handle);
99
+ return handle;
100
+ }
101
+
102
+ /**
103
+ * @param {string} handle
104
+ * @returns {{ handle: string, toolName: string, payload: unknown, bytes: number, createdAt: number, expiresAt: number, meta: object } | null}
105
+ */
106
+ get(handle) {
107
+ const entry = this._live(handle);
108
+ if (!entry) return null;
109
+
110
+ let payload;
111
+ try {
112
+ const json = entry.inMemory ? entry.json : fs.readFileSync(this._file(handle), 'utf8');
113
+ payload = JSON.parse(json);
114
+ } catch {
115
+ // A missing or corrupt file is treated as gone, never thrown.
116
+ this.delete(handle);
117
+ return null;
118
+ }
119
+
120
+ // Refresh recency.
121
+ this.index.delete(handle);
122
+ this.index.set(handle, entry);
123
+
124
+ const { toolName, bytes, createdAt, expiresAt, meta } = entry;
125
+ return { handle, toolName, payload, bytes, createdAt, expiresAt, meta };
126
+ }
127
+
128
+ /** Live and unexpired, without a disk read. */
129
+ has(handle) {
130
+ return this._live(handle) !== null;
131
+ }
132
+
133
+ delete(handle) {
134
+ if (!RESULT_HANDLE_PATTERN.test(handle)) return false;
135
+ const entry = this.index.get(handle);
136
+ if (!entry) return false;
137
+ this.index.delete(handle);
138
+ this.totalBytes -= entry.bytes;
139
+ if (!entry.inMemory) {
140
+ try { fs.unlinkSync(this._file(handle)); } catch { /* already gone */ }
141
+ }
142
+ return true;
143
+ }
144
+
145
+ /** Index entries without payloads. */
146
+ list() {
147
+ return [...this.index.values()].map(({ handle, toolName, bytes, createdAt, expiresAt, meta, inMemory }) => (
148
+ { handle, toolName, bytes, createdAt, expiresAt, meta, inMemory: inMemory === true }
149
+ ));
150
+ }
151
+
152
+ /** Remove expired entries and their files. */
153
+ sweep() {
154
+ const now = Date.now();
155
+ for (const [handle, entry] of this.index) {
156
+ if (entry.expiresAt <= now) this.delete(handle);
157
+ }
158
+ }
159
+
160
+ close() {
161
+ if (this._sweepTimer) {
162
+ clearInterval(this._sweepTimer);
163
+ this._sweepTimer = null;
164
+ }
165
+ }
166
+
167
+ // ── Private ────────────────────────────────────────────────────────────────
168
+
169
+ _file(handle) {
170
+ return path.join(this.baseDir, `${handle}.json`);
171
+ }
172
+
173
+ /** The index entry when the handle is valid, known and unexpired; else null. */
174
+ _live(handle) {
175
+ if (typeof handle !== 'string' || !RESULT_HANDLE_PATTERN.test(handle)) return null;
176
+ const entry = this.index.get(handle);
177
+ if (!entry) return null;
178
+ if (entry.expiresAt <= Date.now()) {
179
+ this.delete(handle);
180
+ return null;
181
+ }
182
+ return entry;
183
+ }
184
+
185
+ /** Evict least-recently-used entries until the budget holds; never `keep`. */
186
+ _evict(keep) {
187
+ for (const handle of this.index.keys()) {
188
+ if (this.totalBytes <= this.maxBytes) return;
189
+ if (handle === keep) return;
190
+ this.delete(handle);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Best-effort removal of result files older than the TTL. Other server
196
+ * processes may share the directory, so a file younger than the TTL is
197
+ * never touched here even though this process did not write it.
198
+ */
199
+ _cleanupStaleFiles() {
200
+ try {
201
+ const cutoff = Date.now() - this.ttlMs;
202
+ for (const name of fs.readdirSync(this.baseDir)) {
203
+ if (!name.endsWith('.json') || !RESULT_HANDLE_PATTERN.test(name.slice(0, -5))) continue;
204
+ const file = path.join(this.baseDir, name);
205
+ try {
206
+ if (fs.statSync(file).mtimeMs < cutoff) fs.unlinkSync(file);
207
+ } catch { /* raced with another process */ }
208
+ }
209
+ } catch { /* no directory yet, or unreadable — nothing to clean */ }
210
+ }
211
+ }
212
+
213
+ // ── Process singleton ────────────────────────────────────────────────────────
214
+
215
+ let singleton = null;
216
+
217
+ /** The process-wide store; CRAWLFORGE_RESULTS_DIR overrides its directory. */
218
+ export function getResultStore() {
219
+ if (!singleton) {
220
+ singleton = new ResultStore({ baseDir: process.env.CRAWLFORGE_RESULTS_DIR || undefined });
221
+ }
222
+ return singleton;
223
+ }
224
+
225
+ /** Test seam: replace (or with null, reset) the singleton. */
226
+ export function setResultStoreForTests(store) {
227
+ singleton = store;
228
+ }
229
+
230
+ export default ResultStore;