crawlforge-mcp-server 4.10.0 → 5.0.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.
Files changed (58) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +298 -212
  5. package/src/core/ActionExecutor.js +117 -33
  6. package/src/core/AgentOrchestrator.js +8 -2
  7. package/src/core/AuthManager.js +51 -17
  8. package/src/core/ChangeTracker.js +26 -10
  9. package/src/core/JobManager.js +9 -1
  10. package/src/core/LocalizationManager.js +19 -6
  11. package/src/core/ResearchOrchestrator.js +173 -35
  12. package/src/core/SnapshotManager.js +162 -165
  13. package/src/core/StealthBrowserManager.js +25 -3
  14. package/src/core/WebhookDispatcher.js +19 -14
  15. package/src/core/analysis/ContentAnalyzer.js +52 -7
  16. package/src/core/crawlers/BFSCrawler.js +27 -3
  17. package/src/core/processing/BrowserProcessor.js +19 -1
  18. package/src/core/processing/PDFProcessor.js +129 -65
  19. package/src/core/queue/QueueManager.js +3 -2
  20. package/src/schemas/toolOutputSchemas.js +269 -0
  21. package/src/server/auth/oauth.js +37 -7
  22. package/src/server/specHygiene.js +192 -0
  23. package/src/server/taskSupport.js +233 -0
  24. package/src/server/toolFilter.js +98 -0
  25. package/src/server/transports/streamableHttp.js +148 -11
  26. package/src/server/withAuth.js +11 -4
  27. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  28. package/src/tools/advanced/batchScrape/index.js +128 -27
  29. package/src/tools/advanced/batchScrape/worker.js +55 -5
  30. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  31. package/src/tools/basic/_fetch.js +125 -70
  32. package/src/tools/basic/extractLinks.js +14 -12
  33. package/src/tools/basic/scrapeStructured.js +21 -4
  34. package/src/tools/crawl/crawlDeep.js +110 -48
  35. package/src/tools/crawl/mapSite.js +25 -6
  36. package/src/tools/extract/_fetchAndParse.js +98 -1
  37. package/src/tools/extract/extractContent.js +7 -4
  38. package/src/tools/extract/extractStructured.js +125 -84
  39. package/src/tools/extract/extractWithLlm.js +10 -2
  40. package/src/tools/extract/processDocument.js +54 -6
  41. package/src/tools/extract/summarizeContent.js +7 -1
  42. package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
  43. package/src/tools/research/deepResearch.js +51 -31
  44. package/src/tools/scrape/_brandingExtractor.js +49 -11
  45. package/src/tools/scrape/unifiedScrape.js +27 -17
  46. package/src/tools/search/providers/searxng.js +5 -1
  47. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  48. package/src/tools/search/ranking/ResultRanker.js +17 -2
  49. package/src/tools/search/searchWeb.js +31 -14
  50. package/src/tools/templates/TemplateRegistry.js +7 -1
  51. package/src/tools/tracking/trackChanges/index.js +87 -26
  52. package/src/tools/tracking/trackChanges/schema.js +2 -2
  53. package/src/utils/CircuitBreaker.js +11 -9
  54. package/src/utils/contentUtils.js +66 -53
  55. package/src/utils/secretMask.js +1 -1
  56. package/src/utils/sitemapParser.js +11 -9
  57. package/src/utils/ssrfGuard.js +212 -40
  58. package/src/utils/urlNormalizer.js +2 -2
package/server.js CHANGED
@@ -51,6 +51,12 @@ import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
51
51
  import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
52
52
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
53
53
  import { ElicitationHelper } from "./src/core/ElicitationHelper.js";
54
+ // Phase 6: MCP-spec adoption — structured output, tool filtering, async tasks, spec hygiene
55
+ import { OUTPUT_SCHEMAS } from "./src/schemas/toolOutputSchemas.js";
56
+ import { dualOutput } from "./src/server/registerTool.js";
57
+ import { createToolFilter } from "./src/server/toolFilter.js";
58
+ import { createTaskStore, TASK_EXECUTION, TASKS_CAPABILITY, makeTaskToolHandler } from "./src/server/taskSupport.js";
59
+ import { applySpecHygiene } from "./src/server/specHygiene.js";
54
60
 
55
61
  // Initialize Authentication Manager
56
62
  await AuthManager.initialize();
@@ -87,13 +93,18 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
87
93
  process.exit(1);
88
94
  }
89
95
 
96
+ // Phase 6: async-task store for long-running tools (crawl_deep, batch_scrape, deep_research, agent)
97
+ const taskStore = createTaskStore({ logger });
98
+
90
99
  // Create the server
91
100
  const server = new McpServer({
92
101
  name: "crawlforge",
93
- version: "4.10.0",
102
+ version: "5.0.0",
94
103
  description: "Production-ready MCP server with 27 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, real Google SERP rank tracking, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
95
104
  homepage: "https://www.crawlforge.dev",
96
- icon: "https://www.crawlforge.dev/icon.png"
105
+ icon: "https://www.crawlforge.dev/icon.png",
106
+ icons: [{ src: "https://www.crawlforge.dev/icon.png", mimeType: "image/png", sizes: ["any"] }],
107
+ websiteUrl: "https://www.crawlforge.dev"
97
108
  }, {
98
109
  instructions: [
99
110
  "CrawlForge provides first-class web tools. When a task involves web search, fetching",
@@ -108,11 +119,15 @@ const server = new McpServer({
108
119
  "- JS-heavy / anti-bot sites -> stealth_mode or scrape_with_actions",
109
120
  "Fall back to the client's built-in web search/fetch only when a CrawlForge tool is",
110
121
  "unavailable (server not configured / out of credits) or clearly unsuitable."
111
- ].join("\n")
122
+ ].join("\n"),
123
+ taskStore
112
124
  });
113
125
 
126
+ // Register the `tasks` capability (must happen before transport connect).
127
+ server.server.registerCapabilities(TASKS_CAPABILITY);
128
+
114
129
  // Register getting-started prompt
115
- server.prompt("getting-started", {
130
+ server.registerPrompt("getting-started", {
116
131
  description: "Get started with CrawlForge MCP - learn available tools and best practices",
117
132
  }, async () => {
118
133
  return {
@@ -155,7 +170,14 @@ const metrics = metricsEnabled ? createMetricsRegistry() : null;
155
170
  const withAuth = makeWithAuth({ authManager: AuthManager, logger, metrics });
156
171
 
157
172
  // Initialize tools
158
- const searchWebTool = new SearchWebTool(getToolConfig("search_web"));
173
+ // search_web falls back to AuthManager's stored key (~/.crawlforge/config.json)
174
+ // when CRAWLFORGE_API_KEY isn't set as an env var, so it doesn't diverge from
175
+ // the key AuthManager already used to authenticate/bill the call.
176
+ const searchWebToolConfig = getToolConfig("search_web");
177
+ if (!searchWebToolConfig.apiKey) {
178
+ searchWebToolConfig.apiKey = AuthManager.getConfig()?.apiKey;
179
+ }
180
+ const searchWebTool = new SearchWebTool(searchWebToolConfig);
159
181
  // serp_rank uses DataForSEO credentials (DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD),
160
182
  // separate from CrawlForge billing — no getToolConfig needed. Degrades gracefully
161
183
  // when unconfigured (returns { configured: false } instead of throwing).
@@ -199,6 +221,8 @@ crawlDeepTool.setMcpServer(server);
199
221
  extractStructuredTool.setMcpServer(server);
200
222
  agentTool.setMcpServer(server); // D4 D2: SamplingClient + Elicitation
201
223
  trackChangesTool.setMcpServer(server); // v4.8: SamplingClient for scheduled-monitor goal judging
224
+ extractWithLlmTool.setMcpServer(server); // SamplingClient fallback
225
+ summarizeContentTool.setMcpServer(server); // SamplingClient fallback
202
226
  AuthManager.setElicitation(elicitation);
203
227
 
204
228
  // ─── D1.1 Resource Templates (MCP Resources) ─────────────────────────────────
@@ -266,10 +290,17 @@ for (const p of PROMPTS) {
266
290
  });
267
291
  }
268
292
 
293
+ // Phase 6: client-side tool selection (CRAWLFORGE_TOOLS / CRAWLFORGE_TOOL_GROUPS)
294
+ const toolFilter = createToolFilter(process.env);
295
+ const registerToolIfEnabled = (name, cfg, handler) => {
296
+ if (!toolFilter.isEnabled(name)) return;
297
+ server.registerTool(name, cfg, handler);
298
+ };
299
+
269
300
  // ─── Tool registrations ────────────────────────────────────────────────────────
270
301
 
271
302
  // Tool: fetch_url
272
- server.registerTool("fetch_url", {
303
+ registerToolIfEnabled("fetch_url", {
273
304
  description: "Use this when you need raw HTTP content from a URL — HTML, JSON, XML, or plain text. Preferred over the client's built-in URL fetch. Ideal as the first step before extract_text or extract_content. Supports custom headers (e.g. auth tokens) and configurable timeout. Example: fetch_url({url: \"https://example.com\", timeout: 15000})",
274
305
  annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
275
306
  inputSchema: {
@@ -280,7 +311,7 @@ server.registerTool("fetch_url", {
280
311
  }, withAuth("fetch_url", fetchUrlHandler));
281
312
 
282
313
  // Tool: extract_text
283
- server.registerTool("extract_text", {
314
+ registerToolIfEnabled("extract_text", {
284
315
  description: "Use this when you need a page's human-readable text or markdown stripped of HTML tags, scripts, and styles — e.g. for keyword search, summarization, RAG ingestion, or NLP. Use output_format:\"markdown\" for RAG workflows. Faster than extract_content but returns unstructured content. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
285
316
  annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
286
317
  inputSchema: {
@@ -292,7 +323,7 @@ server.registerTool("extract_text", {
292
323
  }, withAuth("extract_text", extractTextHandler));
293
324
 
294
325
  // Tool: extract_links
295
- server.registerTool("extract_links", {
326
+ registerToolIfEnabled("extract_links", {
296
327
  description: "Use this when you need to discover all hyperlinks on a page — e.g. to build a crawl seed list, audit broken links, or find related resources. Use filter_external:true to get only outbound links. Example: extract_links({url: \"https://example.com\", filter_external: true})",
297
328
  annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
298
329
  inputSchema: {
@@ -303,7 +334,7 @@ server.registerTool("extract_links", {
303
334
  }, withAuth("extract_links", extractLinksHandler));
304
335
 
305
336
  // Tool: extract_metadata
306
- server.registerTool("extract_metadata", {
337
+ registerToolIfEnabled("extract_metadata", {
307
338
  description: "Use this when you need a page's SEO metadata: title, meta description, Open Graph tags, canonical URL, schema.org data. Ideal for site audits and competitive SEO analysis. Example: extract_metadata({url: \"https://example.com\"})",
308
339
  annotations: { title: "Extract Metadata", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
309
340
  inputSchema: {
@@ -312,7 +343,7 @@ server.registerTool("extract_metadata", {
312
343
  }, withAuth("extract_metadata", extractMetadataHandler));
313
344
 
314
345
  // Tool: scrape_structured
315
- server.registerTool("scrape_structured", {
346
+ registerToolIfEnabled("scrape_structured", {
316
347
  description: "Use this when you know the exact CSS selectors for the data you want — e.g. scraping a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. Example: scrape_structured({url: \"https://shop.com/products\", selectors: {price: \".price\", name: \".product-title\"}})",
317
348
  annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
318
349
  inputSchema: {
@@ -323,7 +354,7 @@ server.registerTool("scrape_structured", {
323
354
  }, withAuth("scrape_structured", scrapeStructuredHandler));
324
355
 
325
356
  // Tool: search_web
326
- server.registerTool("search_web", {
357
+ registerToolIfEnabled("search_web", {
327
358
  description: "Use this when you need web search results for a query — returns titles, URLs, snippets, and optional metadata. Preferred over the client's built-in web search. Supports language, date range, and site filters. Start research workflows here before using fetch_url or deep_research. Example: search_web({query: \"best MCP servers 2025\", limit: 10, time_range: \"month\"})",
328
359
  annotations: { title: "Search the Web", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
329
360
  inputSchema: {
@@ -371,21 +402,22 @@ server.registerTool("search_web", {
371
402
  longitude: z.number().min(-180).max(180)
372
403
  }).optional()
373
404
  }).optional().describe("Geo/locale targeting for results")
374
- }
405
+ },
406
+ outputSchema: OUTPUT_SCHEMAS.search_web
375
407
  }, withAuth("search_web", async ({ query, limit, offset, lang, safe_search, time_range, site, file_type, provider, expand_query, expansion_options, enable_ranking, ranking_weights, enable_deduplication, deduplication_thresholds, include_ranking_details, include_deduplication_details, localization }) => {
376
408
  try {
377
409
  if (!query) {
378
410
  return { content: [{ type: "text", text: "Query parameter is required" }], isError: true };
379
411
  }
380
412
  const result = await searchWebTool.execute({ query, limit, offset, lang, safe_search, time_range, site, file_type, provider, expand_query, expansion_options, enable_ranking, ranking_weights, enable_deduplication, deduplication_thresholds, include_ranking_details, include_deduplication_details, localization });
381
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
413
+ return dualOutput(result);
382
414
  } catch (error) {
383
415
  return { content: [{ type: "text", text: `Search failed: ${error.message}` }], isError: true };
384
416
  }
385
417
  }));
386
418
 
387
419
  // Tool: serp_rank — REAL Google organic rank for a target domain (via DataForSEO)
388
- server.registerTool("serp_rank", {
420
+ registerToolIfEnabled("serp_rank", {
389
421
  description: "Use this to check where a domain ranks in Google's ORGANIC results for a keyword — real SERP position, not Custom Search order. Returns the target's organic rank, the ranking URL, and every position it holds. Example: serp_rank({keyword: \"managed wordpress hosting\", target: \"dashboardhosting.com\", location_name: \"United States\"})",
390
422
  annotations: { title: "SERP Rank Check", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
391
423
  inputSchema: {
@@ -396,72 +428,82 @@ server.registerTool("serp_rank", {
396
428
  language_code: z.string().optional().describe("Language code (e.g. 'en')"),
397
429
  device: z.enum(["desktop", "mobile"]).optional().describe("Device to emulate"),
398
430
  depth: z.number().min(10).max(200).optional().describe("How many results to scan, 10-200 (100 = 1 page of cost)")
399
- }
431
+ },
432
+ outputSchema: OUTPUT_SCHEMAS.serp_rank
400
433
  }, withAuth("serp_rank", async ({ keyword, target, location_name, location_code, language_code, device, depth }) => {
401
434
  try {
402
435
  if (!keyword || !target) {
403
436
  return { content: [{ type: "text", text: "Both 'keyword' and 'target' are required" }], isError: true };
404
437
  }
405
438
  const result = await serpRankTool.execute({ keyword, target, location_name, location_code, language_code, device, depth });
406
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
439
+ return dualOutput(result);
407
440
  } catch (error) {
408
441
  return { content: [{ type: "text", text: `SERP rank check failed: ${error.message}` }], isError: true };
409
442
  }
410
443
  }));
411
444
 
412
- // Tool: crawl_deep
413
- server.registerTool("crawl_deep", {
414
- description: "Use this when you need to discover and optionally extract content from many pages within a site — e.g. building a knowledge base, indexing docs, or auditing all pages. Use map_site first to estimate scope, then crawl_deep for content. Example: crawl_deep({url: \"https://docs.example.com\", max_depth: 3, max_pages: 200, extract_content: true})",
415
- annotations: { title: "Deep Crawl", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
416
- inputSchema: {
417
- url: z.string().url().describe("Starting URL for the crawl"),
418
- max_depth: z.number().min(1).max(5).optional().describe("Maximum crawl depth from starting URL"),
419
- max_pages: z.number().min(1).max(1000).optional().describe("Maximum number of pages to crawl"),
420
- include_patterns: z.array(z.string()).optional().describe("URL patterns to include (regex)"),
421
- exclude_patterns: z.array(z.string()).optional().describe("URL patterns to exclude (regex)"),
422
- follow_external: z.boolean().optional().describe("Follow links to external domains"),
423
- respect_robots: z.boolean().optional().describe("Respect robots.txt directives"),
424
- extract_content: z.boolean().optional().describe("Extract page content during crawl"),
425
- content_max_length: z.number().min(1).max(100000).optional().describe("Maximum characters of page content to include per page (default 500); sets a truncated flag when trimmed"),
426
- concurrency: z.number().min(1).max(20).optional().describe("Number of concurrent requests"),
427
- enable_link_analysis: z.boolean().optional().describe("Compute PageRank/link-graph analysis over crawled pages"),
428
- link_analysis_options: z.object({
429
- dampingFactor: z.number().min(0).max(1).optional(),
430
- maxIterations: z.number().min(1).max(1000).optional(),
431
- enableCaching: z.boolean().optional()
432
- }).optional().describe("PageRank tuning options"),
433
- domain_filter: z.object({
434
- whitelist: z.array(z.any()).optional(),
435
- blacklist: z.array(z.any()).optional(),
436
- domain_rules: z.record(z.any()).optional()
437
- }).optional().describe("Per-domain allow/deny lists and crawl rules"),
438
- import_filter_config: z.string().optional().describe("JSON string of a previously exported domain-filter config"),
439
- session: z.object({
440
- enabled: z.boolean(),
441
- persistCookies: z.boolean().optional(),
442
- headers: z.record(z.string()).optional(),
443
- initialRequest: z.object({
444
- url: z.string().url(),
445
- method: z.string().optional(),
445
+ // Tool: crawl_deep (async task pattern — Phase 6; taskSupport:'optional' keeps sync callers working)
446
+ if (toolFilter.isEnabled("crawl_deep")) {
447
+ server.experimental.tasks.registerToolTask("crawl_deep", {
448
+ description: "Use this when you need to discover and optionally extract content from many pages within a site — e.g. building a knowledge base, indexing docs, or auditing all pages. Use map_site first to estimate scope, then crawl_deep for content. Example: crawl_deep({url: \"https://docs.example.com\", max_depth: 3, max_pages: 200, extract_content: true})",
449
+ annotations: { title: "Deep Crawl", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
450
+ inputSchema: {
451
+ url: z.string().url().describe("Starting URL for the crawl"),
452
+ max_depth: z.number().min(1).max(5).optional().describe("Maximum crawl depth from starting URL"),
453
+ max_pages: z.number().min(1).max(1000).optional().describe("Maximum number of pages to crawl"),
454
+ include_patterns: z.array(z.string()).optional().describe("URL patterns to include (regex)"),
455
+ exclude_patterns: z.array(z.string()).optional().describe("URL patterns to exclude (regex)"),
456
+ follow_external: z.boolean().optional().describe("Follow links to external domains"),
457
+ respect_robots: z.boolean().optional().describe("Respect robots.txt directives"),
458
+ extract_content: z.boolean().optional().describe("Extract page content during crawl"),
459
+ content_max_length: z.number().min(1).max(100000).optional().describe("Maximum characters of page content to include per page (default 500); sets a truncated flag when trimmed"),
460
+ concurrency: z.number().min(1).max(20).optional().describe("Number of concurrent requests"),
461
+ enable_link_analysis: z.boolean().optional().describe("Compute PageRank/link-graph analysis over crawled pages"),
462
+ link_analysis_options: z.object({
463
+ dampingFactor: z.number().min(0).max(1).optional(),
464
+ maxIterations: z.number().min(1).max(1000).optional(),
465
+ enableCaching: z.boolean().optional()
466
+ }).optional().describe("PageRank tuning options"),
467
+ domain_filter: z.object({
468
+ whitelist: z.array(z.any()).optional(),
469
+ blacklist: z.array(z.any()).optional(),
470
+ domain_rules: z.record(z.any()).optional()
471
+ }).optional().describe("Per-domain allow/deny lists and crawl rules"),
472
+ import_filter_config: z.string().optional().describe("JSON string of a previously exported domain-filter config"),
473
+ session: z.object({
474
+ enabled: z.boolean(),
475
+ persistCookies: z.boolean().optional(),
446
476
  headers: z.record(z.string()).optional(),
447
- body: z.string().optional()
448
- }).optional()
449
- }).optional().describe("Shared cookie-jar/session for login-then-crawl workflows")
450
- }
451
- }, withAuth("crawl_deep", async ({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session }) => {
452
- try {
453
- if (!url) {
454
- return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
455
- }
456
- const result = await crawlDeepTool.execute({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session });
457
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
458
- } catch (error) {
459
- return { content: [{ type: "text", text: `Crawl failed: ${error.message}` }], isError: true };
460
- }
461
- }));
477
+ initialRequest: z.object({
478
+ url: z.string().url(),
479
+ method: z.string().optional(),
480
+ headers: z.record(z.string()).optional(),
481
+ body: z.string().optional()
482
+ }).optional()
483
+ }).optional().describe("Shared cookie-jar/session for login-then-crawl workflows")
484
+ },
485
+ outputSchema: OUTPUT_SCHEMAS.crawl_deep,
486
+ execution: TASK_EXECUTION
487
+ }, makeTaskToolHandler({
488
+ name: "crawl_deep",
489
+ run: withAuth("crawl_deep", async ({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session }) => {
490
+ try {
491
+ if (!url) {
492
+ return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
493
+ }
494
+ const result = await crawlDeepTool.execute({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session });
495
+ return dualOutput(result);
496
+ } catch (error) {
497
+ return { content: [{ type: "text", text: `Crawl failed: ${error.message}` }], isError: true };
498
+ }
499
+ }),
500
+ taskStore,
501
+ logger
502
+ }));
503
+ }
462
504
 
463
505
  // Tool: map_site
464
- server.registerTool("map_site", {
506
+ registerToolIfEnabled("map_site", {
465
507
  description: "Use this when you need to know all URLs on a domain without fetching full page content — e.g. before a crawl_deep, for a site audit, or to find specific section URLs. Reads sitemap.xml when available. Example: map_site({url: \"https://example.com\", include_sitemap: true, max_urls: 500})",
466
508
  annotations: { title: "Map Website", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
467
509
  inputSchema: {
@@ -478,26 +520,27 @@ server.registerTool("map_site", {
478
520
  }).optional().describe("Per-domain allow/deny lists and URL include/exclude patterns"),
479
521
  import_filter_config: z.string().optional().describe("JSON string of a previously exported domain-filter config"),
480
522
  search: z.string().optional().describe("When set, rank discovered URLs by relevance to this string and emit ranked_urls:[{url,score}]")
481
- }
523
+ },
524
+ outputSchema: OUTPUT_SCHEMAS.map_site
482
525
  }, withAuth("map_site", async ({ url, include_sitemap, max_urls, group_by_path, include_metadata, domain_filter, import_filter_config, search }) => {
483
526
  try {
484
527
  if (!url) {
485
528
  return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
486
529
  }
487
530
  const result = await mapSiteTool.execute({ url, include_sitemap, max_urls, group_by_path, include_metadata, domain_filter, import_filter_config, search });
488
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
531
+ return dualOutput(result);
489
532
  } catch (error) {
490
533
  return { content: [{ type: "text", text: `Site mapping failed: ${error.message}` }], isError: true };
491
534
  }
492
535
  }));
493
536
 
494
537
  // Tool: extract_content
495
- server.registerTool("extract_content", {
538
+ registerToolIfEnabled("extract_content", {
496
539
  description: "Use this when you need a clean, readable version of a web article or page — removes ads, nav, footers, and boilerplate. Ideal for RAG ingestion, summarization, or LLM context. Prefer this over extract_text for article-style pages. Example: extract_content({url: \"https://blog.example.com/post-title\"})",
497
540
  annotations: { title: "Extract Content", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
498
541
  inputSchema: {
499
542
  url: z.string().url().describe("The URL to extract content from"),
500
- options: z.object({}).optional().describe("Additional extraction options")
543
+ options: z.object({}).passthrough().optional().describe("Additional extraction options")
501
544
  }
502
545
  }, withAuth("extract_content", async ({ url, options }) => {
503
546
  try {
@@ -512,7 +555,7 @@ server.registerTool("extract_content", {
512
555
  }));
513
556
 
514
557
  // Tool: process_document
515
- server.registerTool("process_document", {
558
+ registerToolIfEnabled("process_document", {
516
559
  description: "Use this when you need to extract text from a PDF URL or file — e.g. research papers, contracts, reports. Also handles HTML URLs. Returns structured sections, metadata, and word count. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
517
560
  annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
518
561
  inputSchema: {
@@ -520,7 +563,7 @@ server.registerTool("process_document", {
520
563
  sourceType: z.enum(['url', 'pdf_url', 'file', 'pdf_file']).optional().describe("Type of document source"),
521
564
  // C3: passthrough so granular options (maxPages, pageRange:{start,end},
522
565
  // extractText, outputFormat, etc.) reach the tool instead of being stripped.
523
- options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, password, outputFormat, ...)")
566
+ options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)")
524
567
  }
525
568
  }, withAuth("process_document", async ({ source, sourceType, options }) => {
526
569
  try {
@@ -535,12 +578,12 @@ server.registerTool("process_document", {
535
578
  }));
536
579
 
537
580
  // Tool: summarize_content
538
- server.registerTool("summarize_content", {
581
+ registerToolIfEnabled("summarize_content", {
539
582
  description: "Use this when you have text content (from extract_text or extract_content) and need a condensed version — e.g. for briefings, comparison tables, or LLM context reduction. Supports extractive (sentence selection) and abstractive (rewrite via Ollama/sampling) modes. Example: summarize_content({text: \"..long article..\", options: {summaryLength: \"short\", summaryType: \"abstractive\"}})",
540
583
  annotations: { title: "Summarize Content", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
541
584
  inputSchema: {
542
585
  text: z.string().describe("The text content to summarize"),
543
- options: z.object({}).optional().describe("Summarization options")
586
+ options: z.object({}).passthrough().optional().describe("Summarization options")
544
587
  }
545
588
  }, withAuth("summarize_content", async ({ text, options }) => {
546
589
  try {
@@ -555,12 +598,12 @@ server.registerTool("summarize_content", {
555
598
  }));
556
599
 
557
600
  // Tool: analyze_content
558
- server.registerTool("analyze_content", {
601
+ registerToolIfEnabled("analyze_content", {
559
602
  description: "Use this when you need NLP metrics for text — language detection, sentiment, topic extraction, entity recognition, readability score. Good for content auditing and classification. Example: analyze_content({text: \"..article text..\", options: {extractTopics: true, includeSentiment: true}})",
560
603
  annotations: { title: "Analyze Content", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
561
604
  inputSchema: {
562
605
  text: z.string().describe("The text content to analyze"),
563
- options: z.object({}).optional().describe("Analysis options")
606
+ options: z.object({}).passthrough().optional().describe("Analysis options")
564
607
  }
565
608
  }, withAuth("analyze_content", async ({ text, options }) => {
566
609
  try {
@@ -575,7 +618,7 @@ server.registerTool("analyze_content", {
575
618
  }));
576
619
 
577
620
  // Tool: extract_structured
578
- server.registerTool("extract_structured", {
621
+ registerToolIfEnabled("extract_structured", {
579
622
  description: "Use this when you need a specific data shape extracted from a page using a JSON schema — e.g. product details, job listings, event data. Uses LLM by default; falls back to CSS selectors when no LLM is configured. Example: extract_structured({url: \"https://jobs.example.com/post/123\", schema: {properties: {title: {type:\"string\"}, salary: {type:\"string\"}}, required:[\"title\"]}})",
580
623
  annotations: { title: "Extract Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
581
624
  inputSchema: {
@@ -592,18 +635,19 @@ server.registerTool("extract_structured", {
592
635
  }).optional().describe("LLM provider configuration for AI-powered extraction"),
593
636
  fallbackToSelectors: z.boolean().optional().default(true).describe("Fall back to CSS selector extraction if LLM is unavailable"),
594
637
  selectorHints: z.record(z.string()).optional().describe("CSS selector hints to guide extraction")
595
- }
638
+ },
639
+ outputSchema: OUTPUT_SCHEMAS.extract_structured
596
640
  }, withAuth("extract_structured", async ({ url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints }) => {
597
641
  try {
598
642
  const result = await extractStructuredTool.execute({ url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints });
599
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
643
+ return dualOutput(result);
600
644
  } catch (error) {
601
645
  return { content: [{ type: "text", text: `Structured extraction failed: ${error.message}` }], isError: true };
602
646
  }
603
647
  }));
604
648
 
605
649
  // Tool: extract_with_llm
606
- server.registerTool("extract_with_llm", {
650
+ registerToolIfEnabled("extract_with_llm", {
607
651
  description: "Extract structured data from a URL or text using a natural-language prompt. Defaults to a local Ollama model (http://localhost:11434, no API key required) — call list_ollama_models first to see what's installed and pass the name via the `model` parameter. Pass provider: \"openai\" or \"anthropic\" with the matching API key to use a cloud model instead.",
608
652
  annotations: { title: "Extract With LLM", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
609
653
  inputSchema: {
@@ -625,7 +669,7 @@ server.registerTool("extract_with_llm", {
625
669
  }));
626
670
 
627
671
  // Tool: list_ollama_models
628
- server.registerTool("list_ollama_models", {
672
+ registerToolIfEnabled("list_ollama_models", {
629
673
  description: "List the Ollama models installed locally on this machine. Use this to discover which `model` values you can pass to extract_with_llm. Requires Ollama running on http://localhost:11434 (or $OLLAMA_BASE_URL).",
630
674
  annotations: { title: "List Ollama Models", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
631
675
  inputSchema: {}
@@ -641,53 +685,61 @@ server.registerTool("list_ollama_models", {
641
685
  }
642
686
  }));
643
687
 
644
- // Tool: batch_scrape
645
- server.registerTool("batch_scrape", {
646
- description: "Use this when you need to scrape 2–50 URLs in parallel — e.g. batch-collecting product pages, news articles, or competitor pages. Use mode:\"async\" with a webhook for large batches; mode:\"sync\" for up to ~25 URLs when you need results immediately. Example: batch_scrape({urls: [\"https://a.com\",\"https://b.com\"], formats: [\"json\"], maxConcurrency: 5})",
647
- annotations: { title: "Batch Scrape", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
648
- inputSchema: {
649
- urls: z.array(z.union([
650
- z.string().url(),
651
- z.object({
688
+ // Tool: batch_scrape (async task pattern — Phase 6; taskSupport:'optional' keeps sync callers working)
689
+ if (toolFilter.isEnabled("batch_scrape")) {
690
+ server.experimental.tasks.registerToolTask("batch_scrape", {
691
+ description: "Use this when you need to scrape 2–50 URLs in parallel — e.g. batch-collecting product pages, news articles, or competitor pages. Use mode:\"async\" with a webhook for large batches; mode:\"sync\" for up to ~25 URLs when you need results immediately. Example: batch_scrape({urls: [\"https://a.com\",\"https://b.com\"], formats: [\"json\"], maxConcurrency: 5})",
692
+ annotations: { title: "Batch Scrape", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
693
+ inputSchema: {
694
+ urls: z.array(z.union([
695
+ z.string().url(),
696
+ z.object({
697
+ url: z.string().url(),
698
+ selectors: z.record(z.string()).optional(),
699
+ headers: z.record(z.string()).optional(),
700
+ timeout: z.number().min(1000).max(30000).optional(),
701
+ metadata: z.record(z.any()).optional()
702
+ })
703
+ ])).min(1).max(50).describe("Array of URLs or URL objects to scrape"),
704
+ formats: z.array(z.enum(['markdown', 'html', 'json', 'text'])).default(['json']).describe("Output formats for scraped content"),
705
+ mode: z.enum(['sync', 'async']).default('sync').describe("Processing mode: sync (wait) or async (background)"),
706
+ webhook: z.object({
652
707
  url: z.string().url(),
653
- selectors: z.record(z.string()).optional(),
708
+ events: z.array(z.string()).optional().default(['batch_completed', 'batch_failed']),
654
709
  headers: z.record(z.string()).optional(),
655
- timeout: z.number().min(1000).max(30000).optional(),
656
- metadata: z.record(z.any()).optional()
657
- })
658
- ])).min(1).max(50).describe("Array of URLs or URL objects to scrape"),
659
- formats: z.array(z.enum(['markdown', 'html', 'json', 'text'])).default(['json']).describe("Output formats for scraped content"),
660
- mode: z.enum(['sync', 'async']).default('sync').describe("Processing mode: sync (wait) or async (background)"),
661
- webhook: z.object({
662
- url: z.string().url(),
663
- events: z.array(z.string()).optional().default(['batch_completed', 'batch_failed']),
664
- headers: z.record(z.string()).optional(),
665
- signingSecret: z.string().optional()
666
- }).optional().describe("Webhook configuration for async job notifications"),
667
- extractionSchema: z.record(z.string()).optional().describe("Schema for structured data extraction from each URL"),
668
- maxConcurrency: z.number().min(1).max(20).default(10).describe("Maximum concurrent scraping requests"),
669
- delayBetweenRequests: z.number().min(0).max(10000).default(100).describe("Delay in milliseconds between requests"),
670
- includeMetadata: z.boolean().default(true).describe("Include page metadata in results"),
671
- includeFailed: z.boolean().default(true).describe("Include failed URLs in results"),
672
- pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
673
- jobOptions: z.object({
674
- priority: z.number().default(0),
675
- ttl: z.number().min(60000).default(24 * 60 * 60 * 1000),
676
- maxRetries: z.number().min(0).max(5).default(1),
677
- tags: z.array(z.string()).default([])
678
- }).optional().describe("Job management options for async processing")
679
- }
680
- }, withAuth("batch_scrape", async (params) => {
681
- try {
682
- const result = await batchScrapeTool.execute(params);
683
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
684
- } catch (error) {
685
- return { content: [{ type: "text", text: `Batch scrape failed: ${error.message}` }], isError: true };
686
- }
687
- }));
710
+ signingSecret: z.string().optional()
711
+ }).optional().describe("Webhook configuration for async job notifications"),
712
+ extractionSchema: z.record(z.string()).optional().describe("Schema for structured data extraction from each URL"),
713
+ maxConcurrency: z.number().min(1).max(20).default(10).describe("Maximum concurrent scraping requests"),
714
+ delayBetweenRequests: z.number().min(0).max(10000).default(100).describe("Delay in milliseconds between requests"),
715
+ includeMetadata: z.boolean().default(true).describe("Include page metadata in results"),
716
+ includeFailed: z.boolean().default(true).describe("Include failed URLs in results"),
717
+ pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
718
+ jobOptions: z.object({
719
+ priority: z.number().default(0),
720
+ ttl: z.number().min(60000).default(24 * 60 * 60 * 1000),
721
+ maxRetries: z.number().min(0).max(5).default(1),
722
+ tags: z.array(z.string()).default([])
723
+ }).optional().describe("Job management options for async processing")
724
+ },
725
+ execution: TASK_EXECUTION
726
+ }, makeTaskToolHandler({
727
+ name: "batch_scrape",
728
+ run: withAuth("batch_scrape", async (params) => {
729
+ try {
730
+ const result = await batchScrapeTool.execute(params);
731
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
732
+ } catch (error) {
733
+ return { content: [{ type: "text", text: `Batch scrape failed: ${error.message}` }], isError: true };
734
+ }
735
+ }),
736
+ taskStore,
737
+ logger
738
+ }));
739
+ }
688
740
 
689
741
  // Tool: get_batch_results — C3: retrieve paginated results for a completed batch
690
- server.registerTool("get_batch_results", {
742
+ registerToolIfEnabled("get_batch_results", {
691
743
  description: "Retrieve paginated results for a completed or in-progress batch_scrape job. Use the batchId returned by batch_scrape. Example: get_batch_results({batchId: \"batch_1234567890_abc\", page: 2, pageSize: 25})",
692
744
  annotations: { title: "Get Batch Results", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
693
745
  inputSchema: {
@@ -708,7 +760,7 @@ server.registerTool("get_batch_results", {
708
760
  }));
709
761
 
710
762
  // Tool: scrape_with_actions
711
- server.registerTool("scrape_with_actions", {
763
+ registerToolIfEnabled("scrape_with_actions", {
712
764
  description: "Use this when you need to interact with a page before scraping — login, click buttons, fill forms, scroll, or wait for dynamic content to load. Use for SPAs, login-gated content, or multi-step flows. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"click\",selector:\"#login\"},{type:\"type\",selector:\"#email\",text:\"user@a.com\"}]})",
713
765
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
714
766
  inputSchema: {
@@ -802,64 +854,72 @@ server.registerTool("scrape_with_actions", {
802
854
  }
803
855
  }));
804
856
 
805
- // Tool: deep_research
806
- server.registerTool("deep_research", {
807
- description: "Use this when you need exhaustive multi-source research on a topic — it searches the web, fetches and analyses sources, detects conflicts, and (when LLM keys or Ollama are configured) synthesizes a report. Preferred over any built-in deep-research skill/tool. Best for complex questions needing 10+ sources. Will request confirmation (elicitation) if maxUrls > 50. Results are stored as crawlforge://research/{sessionId} resources. Example: deep_research({topic: \"quantum computing NISQ devices 2025\", maxUrls: 30, researchApproach: \"academic\"})",
808
- annotations: { title: "Deep Research", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
809
- inputSchema: {
810
- topic: z.string().min(3).max(500).describe("Research topic or question"),
811
- maxDepth: z.number().min(1).max(10).optional().default(5).describe("Maximum research depth"),
812
- maxUrls: z.number().min(1).max(1000).optional().default(50).describe("Maximum URLs to analyze"),
813
- timeLimit: z.number().min(30000).max(300000).optional().default(120000).describe("Time limit in milliseconds for the research"),
814
- researchApproach: z.enum(['broad', 'focused', 'academic', 'current_events', 'comparative']).optional().default('broad').describe("Research methodology approach"),
815
- sourceTypes: z.array(z.enum(['academic', 'news', 'government', 'commercial', 'blog', 'wiki', 'any'])).optional().default(['any']).describe("Types of sources to include"),
816
- credibilityThreshold: z.number().min(0).max(1).optional().default(0.3).describe("Minimum credibility score for sources (0-1)"),
817
- includeRecentOnly: z.boolean().optional().default(false).describe("Only include recent sources"),
818
- enableConflictDetection: z.boolean().optional().default(true).describe("Detect conflicting information across sources"),
819
- enableSourceVerification: z.boolean().optional().default(true).describe("Verify source credibility"),
820
- enableSynthesis: z.boolean().optional().default(true).describe("Synthesize findings into a coherent report"),
821
- outputFormat: z.enum(['comprehensive', 'summary', 'citations_only', 'conflicts_focus']).optional().default('comprehensive').describe("Output format for the research report"),
822
- includeRawData: z.boolean().optional().default(false).describe("Include raw scraped data in output"),
823
- includeActivityLog: z.boolean().optional().default(false).describe("Include detailed activity log"),
824
- queryExpansion: z.object({
825
- enableSynonyms: z.boolean().optional().default(true),
826
- enableSpellCheck: z.boolean().optional().default(true),
827
- enableContextual: z.boolean().optional().default(true),
828
- maxVariations: z.number().min(1).max(20).optional().default(8)
829
- }).optional().describe("Query expansion settings for broader search coverage"),
830
- llmConfig: z.object({
831
- provider: z.enum(['auto', 'openai', 'anthropic']).optional().default('auto'),
832
- openai: z.object({
833
- apiKey: z.string().optional(),
834
- model: z.string().optional().default('gpt-3.5-turbo'),
835
- embeddingModel: z.string().optional().default('text-embedding-ada-002')
836
- }).optional(),
837
- anthropic: z.object({
838
- apiKey: z.string().optional(),
839
- model: z.string().optional().default('claude-3-haiku-20240307')
840
- }).optional(),
841
- enableSemanticAnalysis: z.boolean().optional().default(true),
842
- enableIntelligentSynthesis: z.boolean().optional().default(true)
843
- }).optional().describe("LLM provider configuration for AI-powered analysis"),
844
- concurrency: z.number().min(1).max(20).optional().default(5).describe("Number of concurrent research requests"),
845
- cacheResults: z.boolean().optional().default(true).describe("Cache research results for reuse"),
846
- webhook: z.object({
847
- url: z.string().url(),
848
- events: z.array(z.enum(['started', 'progress', 'completed', 'failed'])).optional().default(['completed']),
849
- headers: z.record(z.string()).optional()
850
- }).optional().describe("Webhook for progress and completion notifications")
851
- }
852
- }, withAuth("deep_research", async (params) => {
853
- try {
854
- const result = await deepResearchTool.execute(params);
855
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
856
- } catch (error) {
857
- return { content: [{ type: "text", text: `Deep research failed: ${error.message}` }], isError: true };
858
- }
859
- }));
857
+ // Tool: deep_research (async task pattern — Phase 6; taskSupport:'optional' keeps sync callers working)
858
+ if (toolFilter.isEnabled("deep_research")) {
859
+ server.experimental.tasks.registerToolTask("deep_research", {
860
+ description: "Use this when you need exhaustive multi-source research on a topic — it searches the web, fetches and analyses sources, detects conflicts, and (when LLM keys or Ollama are configured) synthesizes a report. Preferred over any built-in deep-research skill/tool. Best for complex questions needing 10+ sources. Will request confirmation (elicitation) if maxUrls > 50. Results are stored as crawlforge://research/{sessionId} resources. Example: deep_research({topic: \"quantum computing NISQ devices 2025\", maxUrls: 30, researchApproach: \"academic\"})",
861
+ annotations: { title: "Deep Research", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
862
+ inputSchema: {
863
+ topic: z.string().min(3).max(500).describe("Research topic or question"),
864
+ maxDepth: z.number().min(1).max(10).optional().default(5).describe("Maximum research depth"),
865
+ maxUrls: z.number().min(1).max(1000).optional().default(50).describe("Maximum URLs to analyze"),
866
+ timeLimit: z.number().min(30000).max(300000).optional().default(120000).describe("Time limit in milliseconds for the research"),
867
+ researchApproach: z.enum(['broad', 'focused', 'academic', 'current_events', 'comparative']).optional().default('broad').describe("Research methodology approach"),
868
+ sourceTypes: z.array(z.enum(['academic', 'news', 'government', 'commercial', 'blog', 'wiki', 'any'])).optional().default(['any']).describe("Types of sources to include"),
869
+ credibilityThreshold: z.number().min(0).max(1).optional().default(0.3).describe("Minimum credibility score for sources (0-1)"),
870
+ includeRecentOnly: z.boolean().optional().default(false).describe("Only include recent sources"),
871
+ enableConflictDetection: z.boolean().optional().default(true).describe("Detect conflicting information across sources"),
872
+ enableSourceVerification: z.boolean().optional().default(true).describe("Verify source credibility"),
873
+ enableSynthesis: z.boolean().optional().default(true).describe("Synthesize findings into a coherent report"),
874
+ outputFormat: z.enum(['comprehensive', 'summary', 'citations_only', 'conflicts_focus']).optional().default('comprehensive').describe("Output format for the research report"),
875
+ includeRawData: z.boolean().optional().default(false).describe("Include raw scraped data in output"),
876
+ includeActivityLog: z.boolean().optional().default(false).describe("Include detailed activity log"),
877
+ queryExpansion: z.object({
878
+ enableSynonyms: z.boolean().optional().default(true),
879
+ enableSpellCheck: z.boolean().optional().default(true),
880
+ enableContextual: z.boolean().optional().default(true),
881
+ maxVariations: z.number().min(1).max(20).optional().default(8)
882
+ }).optional().describe("Query expansion settings for broader search coverage"),
883
+ llmConfig: z.object({
884
+ provider: z.enum(['auto', 'openai', 'anthropic']).optional().default('auto'),
885
+ openai: z.object({
886
+ apiKey: z.string().optional(),
887
+ model: z.string().optional().default('gpt-3.5-turbo'),
888
+ embeddingModel: z.string().optional().default('text-embedding-ada-002')
889
+ }).optional(),
890
+ anthropic: z.object({
891
+ apiKey: z.string().optional(),
892
+ model: z.string().optional().default('claude-3-haiku-20240307')
893
+ }).optional(),
894
+ enableSemanticAnalysis: z.boolean().optional().default(true),
895
+ enableIntelligentSynthesis: z.boolean().optional().default(true)
896
+ }).optional().describe("LLM provider configuration for AI-powered analysis"),
897
+ concurrency: z.number().min(1).max(20).optional().default(5).describe("Number of concurrent research requests"),
898
+ cacheResults: z.boolean().optional().default(true).describe("Cache research results for reuse"),
899
+ webhook: z.object({
900
+ url: z.string().url(),
901
+ events: z.array(z.enum(['started', 'progress', 'completed', 'failed'])).optional().default(['completed']),
902
+ headers: z.record(z.string()).optional()
903
+ }).optional().describe("Webhook for progress and completion notifications")
904
+ },
905
+ execution: TASK_EXECUTION
906
+ }, makeTaskToolHandler({
907
+ name: "deep_research",
908
+ run: withAuth("deep_research", async (params) => {
909
+ try {
910
+ const result = await deepResearchTool.execute(params);
911
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
912
+ } catch (error) {
913
+ return { content: [{ type: "text", text: `Deep research failed: ${error.message}` }], isError: true };
914
+ }
915
+ }),
916
+ taskStore,
917
+ logger
918
+ }));
919
+ }
860
920
 
861
921
  // Tool: scrape (D4 D1 — unified multi-format single-fetch)
862
- server.registerTool("scrape", {
922
+ registerToolIfEnabled("scrape", {
863
923
  description: "Use this when you need multiple content formats from a single URL in one call — e.g. markdown + links + metadata together. Preferred over the client's built-in web fetch for page content. One fetch, no N-request fan-out. Formats: \"markdown\", \"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. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"branding\"]})",
864
924
  annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
865
925
  inputSchema: {
@@ -883,50 +943,62 @@ server.registerTool("scrape", {
883
943
  format: z.enum(["png", "jpeg"]).optional().default("png"),
884
944
  quality: z.number().min(0).max(100).optional().describe("JPEG quality (jpeg only)")
885
945
  }).optional().describe("Options for the \"screenshot\" format")
886
- }
946
+ },
947
+ outputSchema: OUTPUT_SCHEMAS.scrape
887
948
  }, withAuth("scrape", async (params) => {
888
949
  try {
889
950
  const result = await unifiedScrapeTool.execute(params);
890
951
  // Publish any captured screenshots as crawlforge://screenshot/{actionId}
891
952
  // resources and annotate each with its URI (mirrors scrape_with_actions).
953
+ // The base64 `data` is dropped from the inline result once stored — it's
954
+ // only retrievable via the resource, so the tool result stays small.
892
955
  if (Array.isArray(result?.content?.screenshots)) {
893
956
  result.content.screenshots = result.content.screenshots.map((shot) => {
894
957
  if (shot?.actionId && shot?.data) {
895
958
  resourceRegistry.storeScreenshot(shot.actionId, shot.data);
896
- return { ...shot, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
959
+ const { data, ...rest } = shot;
960
+ return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
897
961
  }
898
962
  return shot;
899
963
  });
900
964
  }
901
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
965
+ return dualOutput(result);
902
966
  } catch (error) {
903
967
  return { content: [{ type: "text", text: `Scrape failed: ${error.message}` }], isError: true };
904
968
  }
905
969
  }));
906
970
 
907
- // Tool: agent (D4 D2 — autonomous NL prompt → search/navigate/extract)
908
- server.registerTool("agent", {
909
- description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web — no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps≤10, maxUrls≤20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
910
- annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
911
- inputSchema: {
912
- prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
913
- urls: z.array(z.string().url()).max(20).optional().describe("Optional seed URLs to include (max 20)"),
914
- schema: z.record(z.any()).optional().describe("Optional JSON schema for structured output"),
915
- model: z.enum(["default", "pro"]).optional().default("default").describe("\"default\" = SamplingClient loop (no keys needed); \"pro\" = full ResearchOrchestrator"),
916
- maxSteps: z.number().min(1).max(10).optional().default(5).describe("Max fetch iterations (hard cap: 10)"),
917
- maxUrls: z.number().min(1).max(20).optional().default(10).describe("Max URLs to fetch (hard cap: 20)")
918
- }
919
- }, withAuth("agent", async (params) => {
920
- try {
921
- const result = await agentTool.execute(params);
922
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
923
- } catch (error) {
924
- return { content: [{ type: "text", text: `Agent failed: ${error.message}` }], isError: true };
925
- }
926
- }));
971
+ // Tool: agent (D4 D2 — autonomous NL prompt → search/navigate/extract; async task pattern — Phase 6)
972
+ if (toolFilter.isEnabled("agent")) {
973
+ server.experimental.tasks.registerToolTask("agent", {
974
+ description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web — no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps≤10, maxUrls≤20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
975
+ annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
976
+ inputSchema: {
977
+ prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
978
+ urls: z.array(z.string().url()).max(20).optional().describe("Optional seed URLs to include (max 20)"),
979
+ schema: z.record(z.any()).optional().describe("Optional JSON schema for structured output"),
980
+ model: z.enum(["default", "pro"]).optional().default("default").describe("\"default\" = SamplingClient loop (no keys needed); \"pro\" = full ResearchOrchestrator"),
981
+ maxSteps: z.number().min(1).max(10).optional().default(5).describe("Max fetch iterations (hard cap: 10)"),
982
+ maxUrls: z.number().min(1).max(20).optional().default(10).describe("Max URLs to fetch (hard cap: 20)")
983
+ },
984
+ execution: TASK_EXECUTION
985
+ }, makeTaskToolHandler({
986
+ name: "agent",
987
+ run: withAuth("agent", async (params) => {
988
+ try {
989
+ const result = await agentTool.execute(params);
990
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
991
+ } catch (error) {
992
+ return { content: [{ type: "text", text: `Agent failed: ${error.message}` }], isError: true };
993
+ }
994
+ }),
995
+ taskStore,
996
+ logger
997
+ }));
998
+ }
927
999
 
928
1000
  // Tool: track_changes
929
- server.registerTool("track_changes", {
1001
+ registerToolIfEnabled("track_changes", {
930
1002
  description: "Use this when you need to monitor a URL for content changes over time — e.g. competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff. Supports webhooks and scheduled monitoring. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
931
1003
  annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
932
1004
  inputSchema: {
@@ -1035,7 +1107,7 @@ server.registerTool("track_changes", {
1035
1107
  }));
1036
1108
 
1037
1109
  // Tool: generate_llms_txt
1038
- server.registerTool("generate_llms_txt", {
1110
+ registerToolIfEnabled("generate_llms_txt", {
1039
1111
  description: "Use this when you need to generate an llms.txt file for a website — the standard that tells AI models how to interact with a site's content. Useful for site owners preparing for AI discoverability, or for understanding a site's AI access policy. Example: generate_llms_txt({url: \"https://example.com\"})",
1040
1112
  annotations: { title: "Generate llms.txt", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1041
1113
  inputSchema: {
@@ -1045,7 +1117,8 @@ server.registerTool("generate_llms_txt", {
1045
1117
  maxPages: z.number().min(10).max(500).optional().default(100),
1046
1118
  detectAPIs: z.boolean().optional().default(true),
1047
1119
  analyzeContent: z.boolean().optional().default(true),
1048
- checkSecurity: z.boolean().optional().default(true),
1120
+ checkSecurity: z.boolean().optional().default(false),
1121
+ probeRateLimit: z.boolean().optional().default(false),
1049
1122
  respectRobots: z.boolean().optional().default(true)
1050
1123
  }).optional().describe("Website analysis options for depth, scope, and detection"),
1051
1124
  outputOptions: z.object({
@@ -1054,7 +1127,8 @@ server.registerTool("generate_llms_txt", {
1054
1127
  contactEmail: z.string().email().optional(),
1055
1128
  organizationName: z.string().optional(),
1056
1129
  customGuidelines: z.array(z.string()).optional(),
1057
- customRestrictions: z.array(z.string()).optional()
1130
+ customRestrictions: z.array(z.string()).optional(),
1131
+ robotsStyle: z.boolean().optional().default(false)
1058
1132
  }).optional().describe("Output customization and organization details"),
1059
1133
  complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe("Compliance level for generated guidelines"),
1060
1134
  format: z.enum(['both', 'llms-txt', 'llms-full-txt']).optional().default('both').describe("Output format: llms.txt, llms-full.txt, or both")
@@ -1069,7 +1143,7 @@ server.registerTool("generate_llms_txt", {
1069
1143
  }));
1070
1144
 
1071
1145
  // Tool: stealth_mode
1072
- server.registerTool("stealth_mode", {
1146
+ registerToolIfEnabled("stealth_mode", {
1073
1147
  description: "Use this when a site blocks normal scraping — Cloudflare, Datadome, or other bot-detection systems. Manages a Playwright browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing. Start with operation:\"create_context\" then use the contextId. Example: stealth_mode({operation:\"create_context\", stealthConfig:{level:\"advanced\", simulateHumanBehavior:true}})",
1074
1148
  annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1075
1149
  inputSchema: {
@@ -1177,7 +1251,7 @@ server.registerTool("stealth_mode", {
1177
1251
  }));
1178
1252
 
1179
1253
  // Tool: localization
1180
- server.registerTool("localization", {
1254
+ registerToolIfEnabled("localization", {
1181
1255
  description: "Use this when you need to scrape geo-restricted content or emulate a specific locale/timezone — e.g. seeing region-specific pricing, bypassing geo-blocks, or searching in another language. Use operation:\"configure_country\" to set country context. Example: localization({operation:\"configure_country\", countryCode:\"DE\", language:\"de\"})",
1182
1256
  annotations: { title: "Localization", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1183
1257
  inputSchema: {
@@ -1285,7 +1359,7 @@ server.registerTool("localization", {
1285
1359
 
1286
1360
 
1287
1361
  // Tool: scrape_template (D3.3 — pre-built site templates)
1288
- server.registerTool("scrape_template", {
1362
+ registerToolIfEnabled("scrape_template", {
1289
1363
  description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
1290
1364
  annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1291
1365
  inputSchema: {
@@ -1302,6 +1376,16 @@ server.registerTool("scrape_template", {
1302
1376
  }
1303
1377
  }));
1304
1378
 
1379
+ // All tools, prompts, and resources are registered above — apply spec hygiene
1380
+ // (tools/list sorting, JSON Schema 2020-12 stamping, icons injection,
1381
+ // SEP-2549 cacheable _meta) before any transport connects.
1382
+ applySpecHygiene(server);
1383
+
1384
+ // Phase 6: report tool-filter activity (stderr only — stdout is the JSON-RPC stream).
1385
+ if (process.env.CRAWLFORGE_TOOLS || process.env.CRAWLFORGE_TOOL_GROUPS) {
1386
+ console.error(`Tool filter active: ${JSON.stringify(toolFilter.summary())}`);
1387
+ }
1388
+
1305
1389
  // ─── Transport + startup ───────────────────────────────────────────────────────
1306
1390
 
1307
1391
  const useHttp = process.argv.includes('--http') || process.env.MCP_HTTP === 'true';
@@ -1363,7 +1447,8 @@ async function runServer() {
1363
1447
  "list_ollama_models", "scrape_template", // D3.3
1364
1448
  "scrape", "agent" // D4
1365
1449
  ];
1366
- console.error(`Tools available (27): ${allTools.join(", ")}`);
1450
+ const enabledTools = allTools.filter((name) => toolFilter.isEnabled(name));
1451
+ console.error(`Tools available (${enabledTools.length}/${allTools.length}): ${enabledTools.join(", ")}`);
1367
1452
 
1368
1453
  // Start memory monitoring in development
1369
1454
  if (config.server.nodeEnv === "development") {
@@ -1390,6 +1475,7 @@ async function gracefulShutdown(signal) {
1390
1475
  batchScrapeTool, scrapeWithActionsTool, deepResearchTool,
1391
1476
  trackChangesTool, generateLLMsTxtTool, stealthBrowserManager,
1392
1477
  localizationManager, extractStructuredTool,
1478
+ extractContentTool, processDocumentTool, // each owns a lazily-launched BrowserProcessor
1393
1479
  agentTool // D4 D2: may hold ResearchOrchestrator
1394
1480
  ].filter(tool => tool && (typeof tool.destroy === 'function' || typeof tool.cleanup === 'function'));
1395
1481