crawlforge-mcp-server 5.10.0 → 6.1.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 (34) hide show
  1. package/README.md +7 -1
  2. package/package.json +7 -5
  3. package/server.js +210 -251
  4. package/src/cli/commands/login.js +176 -0
  5. package/src/cli/index.js +2 -0
  6. package/src/core/ActionExecutor.js +1 -1
  7. package/src/core/AuthManager.js +19 -6
  8. package/src/core/ChangeTracker.js +1 -1
  9. package/src/core/ElicitationHelper.js +192 -62
  10. package/src/core/SamplingClient.js +8 -2
  11. package/src/core/analysis/ContentAnalyzer.js +1 -1
  12. package/src/core/processing/BrowserProcessor.js +1 -1
  13. package/src/core/processing/ContentProcessor.js +1 -1
  14. package/src/core/processing/PDFProcessor.js +2 -2
  15. package/src/server/registerTool.js +1 -1
  16. package/src/server/requestContext.js +50 -0
  17. package/src/server/specHygiene.js +17 -22
  18. package/src/server/transports/stdio.js +2 -3
  19. package/src/server/transports/streamableHttp.js +142 -67
  20. package/src/server/withAuth.js +47 -9
  21. package/src/tools/advanced/batchScrape/index.js +29 -19
  22. package/src/tools/agent/agent.js +9 -4
  23. package/src/tools/crawl/crawlDeep.js +14 -8
  24. package/src/tools/extract/analyzeContent.js +1 -1
  25. package/src/tools/extract/extractContent.js +1 -1
  26. package/src/tools/extract/extractStructured.js +62 -43
  27. package/src/tools/extract/processDocument.js +1 -1
  28. package/src/tools/extract/summarizeContent.js +1 -1
  29. package/src/tools/llmstxt/generateLLMsTxt.js +2 -2
  30. package/src/tools/research/deepResearch.js +11 -5
  31. package/src/tools/tracking/trackChanges/schema.js +4 -4
  32. package/src/utils/HumanBehaviorSimulator.js +7 -7
  33. package/src/server/taskSupport.js +0 -233
  34. package/src/server/transports/http.js +0 -22
package/server.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export { isCreatorModeVerified } from './src/core/creatorMode.js';
6
6
 
7
7
  // Import everything else
8
- import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { McpServer, ResourceTemplate, isInputRequiredResult } from "@modelcontextprotocol/server";
9
9
  import { z } from "zod";
10
10
  import { logger } from "./src/utils/Logger.js";
11
11
  import { SearchWebTool } from "./src/tools/search/searchWeb.js";
@@ -42,7 +42,6 @@ import AuthManager from "./src/core/AuthManager.js";
42
42
  import { makeWithAuth } from "./src/server/withAuth.js";
43
43
  // Transport helpers
44
44
  import { connectStdio } from "./src/server/transports/stdio.js";
45
- import { connectHttp } from "./src/server/transports/http.js";
46
45
  import { connectStreamableHttp } from "./src/server/transports/streamableHttp.js";
47
46
  // OAuth 2.1 (HTTP transport only — opt-in via CRAWLFORGE_OAUTH_ENABLED=true)
48
47
  import { createOAuthProvider } from "./src/server/auth/oauth.js";
@@ -64,11 +63,10 @@ import { markPreflightRefusal } from "./src/server/requestContext.js";
64
63
  import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
65
64
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
66
65
  import { ElicitationHelper } from "./src/core/ElicitationHelper.js";
67
- // Phase 6: MCP-spec adoption — structured output, tool filtering, async tasks, spec hygiene
66
+ // Phase 6: MCP-spec adoption — structured output, tool filtering, spec hygiene
68
67
  import { OUTPUT_SCHEMAS } from "./src/schemas/toolOutputSchemas.js";
69
68
  import { dualOutput } from "./src/server/registerTool.js";
70
69
  import { createToolFilter } from "./src/server/toolFilter.js";
71
- import { createTaskStore, TASK_EXECUTION, TASKS_CAPABILITY, makeTaskToolHandler } from "./src/server/taskSupport.js";
72
70
  import { applySpecHygiene } from "./src/server/specHygiene.js";
73
71
 
74
72
  // Initialize Authentication Manager
@@ -106,13 +104,10 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
106
104
  process.exit(1);
107
105
  }
108
106
 
109
- // Phase 6: async-task store for long-running tools (crawl_deep, batch_scrape, deep_research, agent)
110
- const taskStore = createTaskStore({ logger });
111
-
112
107
  // Create the server
113
108
  const server = new McpServer({
114
109
  name: "crawlforge",
115
- version: "5.10.0",
110
+ version: "6.1.0",
116
111
  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.",
117
112
  homepage: "https://www.crawlforge.dev",
118
113
  icon: "https://www.crawlforge.dev/icon.png",
@@ -132,13 +127,9 @@ const server = new McpServer({
132
127
  "- 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).",
133
128
  "- 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.",
134
129
  "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."
135
- ].join("\n"),
136
- taskStore
130
+ ].join("\n")
137
131
  });
138
132
 
139
- // Register the `tasks` capability (must happen before transport connect).
140
- server.server.registerCapabilities(TASKS_CAPABILITY);
141
-
142
133
  // Register getting-started prompt
143
134
  server.registerPrompt("getting-started", {
144
135
  description: "Get started with CrawlForge MCP - learn available tools and best practices",
@@ -289,7 +280,7 @@ AuthManager.setElicitation(elicitation);
289
280
  // The registry is populated at runtime as tools produce artifacts.
290
281
 
291
282
  // Research sessions: crawlforge://research/{sessionId}
292
- server.resource(
283
+ server.registerResource(
293
284
  "crawlforge-research",
294
285
  new ResourceTemplate("crawlforge://research/{sessionId}", {
295
286
  list: async () => ({
@@ -301,7 +292,7 @@ server.resource(
301
292
  );
302
293
 
303
294
  // Job results: crawlforge://job/{jobId}
304
- server.resource(
295
+ server.registerResource(
305
296
  "crawlforge-job",
306
297
  new ResourceTemplate("crawlforge://job/{jobId}", {
307
298
  list: async () => ({
@@ -313,7 +304,7 @@ server.resource(
313
304
  );
314
305
 
315
306
  // Crawl sitemaps: crawlforge://crawl/{sessionId}/sitemap
316
- server.resource(
307
+ server.registerResource(
317
308
  "crawlforge-crawl-sitemap",
318
309
  new ResourceTemplate("crawlforge://crawl/{sessionId}/sitemap", {
319
310
  list: async () => ({
@@ -325,7 +316,7 @@ server.resource(
325
316
  );
326
317
 
327
318
  // Screenshots: crawlforge://screenshot/{actionId}
328
- server.resource(
319
+ server.registerResource(
329
320
  "crawlforge-screenshot",
330
321
  new ResourceTemplate("crawlforge://screenshot/{actionId}", {
331
322
  list: async () => ({
@@ -344,7 +335,8 @@ for (const p of PROMPTS) {
344
335
  for (const arg of p.arguments) {
345
336
  argsShape[arg.name] = z.string().optional().describe(arg.description);
346
337
  }
347
- server.registerPrompt(p.name, { description: p.description, argsSchema: argsShape }, async (args) => {
338
+ // v2 deprecates raw shapes here; z.object() is the Standard Schema form.
339
+ server.registerPrompt(p.name, { description: p.description, argsSchema: z.object(argsShape) }, async (args) => {
348
340
  return getPromptMessages(p.name, args || {});
349
341
  });
350
342
  }
@@ -573,67 +565,62 @@ registerToolIfEnabled("reddit_search", {
573
565
  }
574
566
  }));
575
567
 
576
- // Tool: crawl_deep (async task pattern — Phase 6; taskSupport:'optional' keeps sync callers working)
577
- if (toolFilter.isEnabled("crawl_deep")) {
578
- server.experimental.tasks.registerToolTask("crawl_deep", {
579
- description: "Use this to fetch many pages of one site by following links - a knowledge base, a docs index, a full-site audit. Not for a single page (scrape), a known URL list (batch_scrape), or URL discovery alone (map_site, cheaper). Runs as an async task on clients that support them. Cost: 4 credits base, grows with page count. Example: crawl_deep({url: \"https://docs.example.com\", max_depth: 3, max_pages: 200, extract_content: true})",
580
- annotations: { title: "Deep Crawl", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
581
- inputSchema: {
582
- url: z.string().url().describe("Starting URL for the crawl"),
583
- max_depth: z.number().min(1).max(5).optional().describe("Maximum crawl depth from starting URL"),
584
- max_pages: z.number().min(1).max(1000).optional().describe("Maximum number of pages to crawl"),
585
- include_patterns: z.array(z.string()).optional().describe("URL patterns to include (regex)"),
586
- exclude_patterns: z.array(z.string()).optional().describe("URL patterns to exclude (regex)"),
587
- follow_external: z.boolean().optional().describe("Follow links to external domains"),
588
- respect_robots: z.boolean().optional().describe("Respect robots.txt directives"),
589
- extract_content: z.boolean().optional().describe("Extract page content during crawl"),
590
- 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"),
591
- concurrency: z.number().min(1).max(20).optional().describe("Number of concurrent requests"),
592
- enable_link_analysis: z.boolean().optional().describe("Compute PageRank/link-graph analysis over crawled pages"),
593
- link_analysis_options: z.object({
594
- dampingFactor: z.number().min(0).max(1).optional(),
595
- maxIterations: z.number().min(1).max(1000).optional(),
596
- enableCaching: z.boolean().optional()
597
- }).optional().describe("PageRank tuning options"),
598
- domain_filter: z.object({
599
- whitelist: z.array(z.any()).optional(),
600
- blacklist: z.array(z.any()).optional(),
601
- domain_rules: z.record(z.any()).optional()
602
- }).optional().describe("Per-domain allow/deny lists and crawl rules"),
603
- import_filter_config: z.string().optional().describe("JSON string of a previously exported domain-filter config"),
604
- session: z.object({
605
- enabled: z.boolean(),
606
- persistCookies: z.boolean().optional(),
568
+ // Tool: crawl_deep
569
+ registerToolIfEnabled("crawl_deep", {
570
+ description: "Use this to fetch many pages of one site by following links - a knowledge base, a docs index, a full-site audit. Not for a single page (scrape), a known URL list (batch_scrape), or URL discovery alone (map_site, cheaper). Cost: 4 credits base, grows with page count. Example: crawl_deep({url: \"https://docs.example.com\", max_depth: 3, max_pages: 200, extract_content: true})",
571
+ annotations: { title: "Deep Crawl", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
572
+ inputSchema: {
573
+ url: z.string().url().describe("Starting URL for the crawl"),
574
+ max_depth: z.number().min(1).max(5).optional().describe("Maximum crawl depth from starting URL"),
575
+ max_pages: z.number().min(1).max(1000).optional().describe("Maximum number of pages to crawl"),
576
+ include_patterns: z.array(z.string()).optional().describe("URL patterns to include (regex)"),
577
+ exclude_patterns: z.array(z.string()).optional().describe("URL patterns to exclude (regex)"),
578
+ follow_external: z.boolean().optional().describe("Follow links to external domains"),
579
+ respect_robots: z.boolean().optional().describe("Respect robots.txt directives"),
580
+ extract_content: z.boolean().optional().describe("Extract page content during crawl"),
581
+ 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"),
582
+ concurrency: z.number().min(1).max(20).optional().describe("Number of concurrent requests"),
583
+ enable_link_analysis: z.boolean().optional().describe("Compute PageRank/link-graph analysis over crawled pages"),
584
+ link_analysis_options: z.object({
585
+ dampingFactor: z.number().min(0).max(1).optional(),
586
+ maxIterations: z.number().min(1).max(1000).optional(),
587
+ enableCaching: z.boolean().optional()
588
+ }).optional().describe("PageRank tuning options"),
589
+ domain_filter: z.object({
590
+ whitelist: z.array(z.any()).optional(),
591
+ blacklist: z.array(z.any()).optional(),
592
+ domain_rules: z.record(z.any()).optional()
593
+ }).optional().describe("Per-domain allow/deny lists and crawl rules"),
594
+ import_filter_config: z.string().optional().describe("JSON string of a previously exported domain-filter config"),
595
+ session: z.object({
596
+ enabled: z.boolean(),
597
+ persistCookies: z.boolean().optional(),
598
+ headers: z.record(z.string()).optional(),
599
+ initialRequest: z.object({
600
+ url: z.string().url(),
601
+ method: z.string().optional(),
607
602
  headers: z.record(z.string()).optional(),
608
- initialRequest: z.object({
609
- url: z.string().url(),
610
- method: z.string().optional(),
611
- headers: z.record(z.string()).optional(),
612
- body: z.string().optional()
613
- }).optional()
614
- }).optional().describe("Shared cookie-jar/session for login-then-crawl workflows"),
615
- ...MAX_INLINE_CHARS_PARAM,
616
- ...REDACT_PII_PARAM
617
- },
618
- outputSchema: OUTPUT_SCHEMAS.crawl_deep,
619
- execution: TASK_EXECUTION
620
- }, makeTaskToolHandler({
621
- name: "crawl_deep",
622
- 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 }) => {
623
- try {
624
- if (!url) {
625
- return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
626
- }
627
- 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 });
628
- return dualOutput(result);
629
- } catch (error) {
630
- return { content: [{ type: "text", text: `Crawl failed: ${error.message}` }], isError: true };
603
+ body: z.string().optional()
604
+ }).optional()
605
+ }).optional().describe("Shared cookie-jar/session for login-then-crawl workflows"),
606
+ ...MAX_INLINE_CHARS_PARAM,
607
+ ...REDACT_PII_PARAM
608
+ },
609
+ outputSchema: OUTPUT_SCHEMAS.crawl_deep
610
+ }, 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 }, ctx) => {
611
+ try {
612
+ if (!url) {
613
+ return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
631
614
  }
632
- }),
633
- taskStore,
634
- logger
615
+ 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 }, ctx);
616
+ // A confirmation round trip is the SDK's result shape, not a tool payload:
617
+ // it must reach the transport unwrapped (Phase 4.4).
618
+ if (isInputRequiredResult(result)) return result;
619
+ return dualOutput(result);
620
+ } catch (error) {
621
+ return { content: [{ type: "text", text: `Crawl failed: ${error.message}` }], isError: true };
622
+ }
635
623
  }));
636
- }
637
624
 
638
625
  // Tool: map_site
639
626
  registerToolIfEnabled("map_site", {
@@ -779,12 +766,13 @@ registerToolIfEnabled("extract_structured", {
779
766
  ...VERIFY_NUMBERS_PARAM
780
767
  },
781
768
  outputSchema: OUTPUT_SCHEMAS.extract_structured
782
- }, withAuth("extract_structured", async (params) => {
769
+ }, withAuth("extract_structured", async (params, ctx) => {
783
770
  try {
784
771
  // Forward params whole. This wrapper used to destructure a fixed six, which
785
772
  // silently dropped respect_robots and user_agent — both declared here and
786
773
  // read by the tool, so the G5 override was accepted and ignored.
787
- const result = await extractStructuredTool.execute(params);
774
+ const result = await extractStructuredTool.execute(params, ctx);
775
+ if (isInputRequiredResult(result)) return result;
788
776
  return dualOutput(result);
789
777
  } catch (error) {
790
778
  return { content: [{ type: "text", text: `Structured extraction failed: ${error.message}` }], isError: true };
@@ -832,61 +820,54 @@ registerToolIfEnabled("list_ollama_models", {
832
820
  }
833
821
  }));
834
822
 
835
- // Tool: batch_scrape (async task pattern — Phase 6; taskSupport:'optional' keeps sync callers working)
836
- if (toolFilter.isEnabled("batch_scrape")) {
837
- server.experimental.tasks.registerToolTask("batch_scrape", {
838
- description: "Use this to scrape 2-50 URLs in one call - product pages, news articles, competitor pages. Never loop scrape over a URL list. mode:\"sync\" returns results directly for up to ~25 URLs; mode:\"async\" with a webhook for larger batches, then get_batch_results. Not for one URL (scrape) or for discovering URLs (map_site). Cost: 5 credits. Example: batch_scrape({urls: [\"https://a.com\",\"https://b.com\"], formats: [\"json\"], maxConcurrency: 5})",
839
- annotations: { title: "Batch Scrape", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
840
- inputSchema: {
841
- urls: z.array(z.union([
842
- z.string().url(),
843
- z.object({
844
- url: z.string().url(),
845
- selectors: z.record(z.string()).optional(),
846
- headers: z.record(z.string()).optional(),
847
- timeout: z.number().min(1000).max(30000).optional(),
848
- metadata: z.record(z.any()).optional()
849
- })
850
- ])).min(1).max(50).describe("Array of URLs or URL objects to scrape"),
851
- formats: z.array(z.enum(['markdown', 'html', 'json', 'text'])).default(['json']).describe("Output formats for scraped content"),
852
- mode: z.enum(['sync', 'async']).default('sync').describe("Processing mode: sync (wait) or async (background)"),
853
- webhook: z.object({
823
+ // Tool: batch_scrape
824
+ registerToolIfEnabled("batch_scrape", {
825
+ description: "Use this to scrape 2-50 URLs in one call - product pages, news articles, competitor pages. Never loop scrape over a URL list. mode:\"sync\" returns results directly for up to ~25 URLs; mode:\"async\" with a webhook for larger batches, then get_batch_results. Not for one URL (scrape) or for discovering URLs (map_site). Cost: 5 credits. Example: batch_scrape({urls: [\"https://a.com\",\"https://b.com\"], formats: [\"json\"], maxConcurrency: 5})",
826
+ annotations: { title: "Batch Scrape", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
827
+ inputSchema: {
828
+ urls: z.array(z.union([
829
+ z.string().url(),
830
+ z.object({
854
831
  url: z.string().url(),
855
- events: z.array(z.string()).optional().default(['batch_completed', 'batch_failed']),
832
+ selectors: z.record(z.string()).optional(),
856
833
  headers: z.record(z.string()).optional(),
857
- signingSecret: z.string().optional()
858
- }).optional().describe("Webhook configuration for async job notifications"),
859
- extractionSchema: z.record(z.string()).optional().describe("Schema for structured data extraction from each URL"),
860
- maxConcurrency: z.number().min(1).max(20).default(10).describe("Maximum concurrent scraping requests"),
861
- delayBetweenRequests: z.number().min(0).max(10000).default(100).describe("Delay in milliseconds between requests"),
862
- includeMetadata: z.boolean().default(true).describe("Include page metadata in results"),
863
- includeFailed: z.boolean().default(true).describe("Include failed URLs in results"),
864
- pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
865
- jobOptions: z.object({
866
- priority: z.number().default(0),
867
- ttl: z.number().min(60000).default(24 * 60 * 60 * 1000),
868
- maxRetries: z.number().min(0).max(5).default(1),
869
- tags: z.array(z.string()).default([])
870
- }).optional().describe("Job management options for async processing"),
871
- ...COMPLIANCE_PARAMS,
872
- ...MAX_INLINE_CHARS_PARAM,
873
- ...REDACT_PII_PARAM
874
- },
875
- execution: TASK_EXECUTION
876
- }, makeTaskToolHandler({
877
- name: "batch_scrape",
878
- run: withAuth("batch_scrape", async (params) => {
879
- try {
880
- const result = await batchScrapeTool.execute(params);
881
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
882
- } catch (error) {
883
- return { content: [{ type: "text", text: `Batch scrape failed: ${error.message}` }], isError: true };
884
- }
885
- }),
886
- taskStore,
887
- logger
834
+ timeout: z.number().min(1000).max(30000).optional(),
835
+ metadata: z.record(z.any()).optional()
836
+ })
837
+ ])).min(1).max(50).describe("Array of URLs or URL objects to scrape"),
838
+ formats: z.array(z.enum(['markdown', 'html', 'json', 'text'])).default(['json']).describe("Output formats for scraped content"),
839
+ mode: z.enum(['sync', 'async']).default('sync').describe("Processing mode: sync (wait) or async (background)"),
840
+ webhook: z.object({
841
+ url: z.string().url(),
842
+ events: z.array(z.string()).optional().default(['batch_completed', 'batch_failed']),
843
+ headers: z.record(z.string()).optional(),
844
+ signingSecret: z.string().optional()
845
+ }).optional().describe("Webhook configuration for async job notifications"),
846
+ extractionSchema: z.record(z.string()).optional().describe("Schema for structured data extraction from each URL"),
847
+ maxConcurrency: z.number().min(1).max(20).default(10).describe("Maximum concurrent scraping requests"),
848
+ delayBetweenRequests: z.number().min(0).max(10000).default(100).describe("Delay in milliseconds between requests"),
849
+ includeMetadata: z.boolean().default(true).describe("Include page metadata in results"),
850
+ includeFailed: z.boolean().default(true).describe("Include failed URLs in results"),
851
+ pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
852
+ jobOptions: z.object({
853
+ priority: z.number().default(0),
854
+ ttl: z.number().min(60000).default(24 * 60 * 60 * 1000),
855
+ maxRetries: z.number().min(0).max(5).default(1),
856
+ tags: z.array(z.string()).default([])
857
+ }).optional().describe("Job management options for async processing"),
858
+ ...COMPLIANCE_PARAMS,
859
+ ...MAX_INLINE_CHARS_PARAM,
860
+ ...REDACT_PII_PARAM
861
+ }
862
+ }, withAuth("batch_scrape", async (params, ctx) => {
863
+ try {
864
+ const result = await batchScrapeTool.execute(params, ctx);
865
+ if (isInputRequiredResult(result)) return result;
866
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
867
+ } catch (error) {
868
+ return { content: [{ type: "text", text: `Batch scrape failed: ${error.message}` }], isError: true };
869
+ }
888
870
  }));
889
- }
890
871
 
891
872
  // Tool: get_batch_results — C3: retrieve paginated results for a completed batch
892
873
  registerToolIfEnabled("get_batch_results", {
@@ -1025,77 +1006,70 @@ registerToolIfEnabled("scrape_with_actions", {
1025
1006
  }
1026
1007
  }));
1027
1008
 
1028
- // Tool: deep_research (async task pattern — Phase 6; taskSupport:'optional' keeps sync callers working)
1029
- if (toolFilter.isEnabled("deep_research")) {
1030
- server.experimental.tasks.registerToolTask("deep_research", {
1031
- description: "Use this for 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. Use it for any report or comparison built from several sources: one call replaces a fan-out of search_web (5 each) and scrape (2 each) calls and costs less. Not for a question one search answers (search_web) or a single page (scrape). Will request confirmation (elicitation) if maxUrls > 50. Results are stored as crawlforge://research/{sessionId} resources. Cost: 10 credits base, grows with maxUrls. Example: deep_research({topic: \"quantum computing NISQ devices 2025\", maxUrls: 30, researchApproach: \"academic\"})",
1032
- annotations: { title: "Deep Research", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1033
- // Loaded at session start like scrape/search_web: when only those two carried full schemas,
1034
- // sonnet fanned a multi-source report into search_web x5 + scrape x7 instead of one call here.
1035
- _meta: { "anthropic/alwaysLoad": true },
1036
- inputSchema: {
1037
- topic: z.string().min(3).max(500).describe("Research topic or question"),
1038
- maxDepth: z.number().min(1).max(10).optional().default(5).describe("Maximum research depth"),
1039
- maxUrls: z.number().min(1).max(1000).optional().default(50).describe("Maximum URLs to analyze"),
1040
- timeLimit: z.number().min(30000).max(300000).optional().default(120000).describe("Time limit in milliseconds for the research"),
1041
- researchApproach: z.enum(['broad', 'focused', 'academic', 'current_events', 'comparative']).optional().default('broad').describe("Research methodology approach"),
1042
- sourceTypes: z.array(z.enum(['academic', 'news', 'government', 'commercial', 'blog', 'wiki', 'any'])).optional().default(['any']).describe("Types of sources to include"),
1043
- credibilityThreshold: z.number().min(0).max(1).optional().default(0.3).describe("Minimum credibility score for sources (0-1)"),
1044
- includeRecentOnly: z.boolean().optional().default(false).describe("Only include recent sources"),
1045
- enableConflictDetection: z.boolean().optional().default(true).describe("Detect conflicting information across sources"),
1046
- enableSourceVerification: z.boolean().optional().default(true).describe("Verify source credibility"),
1047
- enableSynthesis: z.boolean().optional().default(true).describe("Synthesize findings into a coherent report"),
1048
- outputFormat: z.enum(['comprehensive', 'summary', 'citations_only', 'conflicts_focus']).optional().default('comprehensive').describe("Output format for the research report"),
1049
- includeRawData: z.boolean().optional().default(false).describe("Include raw scraped data in output"),
1050
- includeActivityLog: z.boolean().optional().default(false).describe("Include detailed activity log"),
1051
- queryExpansion: z.object({
1052
- enableSynonyms: z.boolean().optional().default(true),
1053
- enableSpellCheck: z.boolean().optional().default(true),
1054
- enableContextual: z.boolean().optional().default(true),
1055
- maxVariations: z.number().min(1).max(20).optional().default(8)
1056
- }).optional().describe("Query expansion settings for broader search coverage"),
1057
- llmConfig: z.object({
1058
- provider: z.enum(['auto', 'openai', 'anthropic', 'ollama']).optional().default('auto'),
1059
- openai: z.object({
1060
- apiKey: z.string().optional(),
1061
- model: z.string().optional().default('gpt-3.5-turbo'),
1062
- embeddingModel: z.string().optional().default('text-embedding-ada-002')
1063
- }).optional(),
1064
- anthropic: z.object({
1065
- apiKey: z.string().optional(),
1066
- model: z.string().optional().default('claude-3-haiku-20240307')
1067
- }).optional(),
1068
- ollama: z.object({
1069
- model: z.string().optional(),
1070
- embeddingModel: z.string().optional()
1071
- }).optional(),
1072
- enableSemanticAnalysis: z.boolean().optional().default(true),
1073
- enableIntelligentSynthesis: z.boolean().optional().default(true)
1074
- }).optional().describe("LLM provider configuration for AI-powered analysis. provider 'auto' (default) uses a configured cloud key if there is one, else the local Ollama (http://localhost:11434, no key); 'ollama' forces the local model; 'openai'/'anthropic' need the matching API key"),
1075
- concurrency: z.number().min(1).max(20).optional().default(5).describe("Number of concurrent research requests"),
1076
- cacheResults: z.boolean().optional().default(true).describe("Cache research results for reuse"),
1077
- webhook: z.object({
1078
- url: z.string().url(),
1079
- events: z.array(z.enum(['started', 'progress', 'completed', 'failed'])).optional().default(['completed']),
1080
- headers: z.record(z.string()).optional()
1081
- }).optional().describe("Webhook for progress and completion notifications"),
1082
- ...MAX_INLINE_CHARS_PARAM
1083
- },
1084
- execution: TASK_EXECUTION
1085
- }, makeTaskToolHandler({
1086
- name: "deep_research",
1087
- run: withAuth("deep_research", async (params) => {
1088
- try {
1089
- const result = await deepResearchTool.execute(params);
1090
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1091
- } catch (error) {
1092
- return { content: [{ type: "text", text: `Deep research failed: ${error.message}` }], isError: true };
1093
- }
1094
- }),
1095
- taskStore,
1096
- logger
1009
+ // Tool: deep_research
1010
+ registerToolIfEnabled("deep_research", {
1011
+ description: "Use this for 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. Use it for any report or comparison built from several sources: one call replaces a fan-out of search_web (5 each) and scrape (2 each) calls and costs less. Not for a question one search answers (search_web) or a single page (scrape). Will request confirmation (elicitation) if maxUrls > 50. Results are stored as crawlforge://research/{sessionId} resources. Cost: 10 credits base, grows with maxUrls. Example: deep_research({topic: \"quantum computing NISQ devices 2025\", maxUrls: 30, researchApproach: \"academic\"})",
1012
+ annotations: { title: "Deep Research", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1013
+ // Loaded at session start like scrape/search_web: when only those two carried full schemas,
1014
+ // sonnet fanned a multi-source report into search_web x5 + scrape x7 instead of one call here.
1015
+ _meta: { "anthropic/alwaysLoad": true },
1016
+ inputSchema: {
1017
+ topic: z.string().min(3).max(500).describe("Research topic or question"),
1018
+ maxDepth: z.number().min(1).max(10).optional().default(5).describe("Maximum research depth"),
1019
+ maxUrls: z.number().min(1).max(1000).optional().default(50).describe("Maximum URLs to analyze"),
1020
+ timeLimit: z.number().min(30000).max(300000).optional().default(120000).describe("Time limit in milliseconds for the research"),
1021
+ researchApproach: z.enum(['broad', 'focused', 'academic', 'current_events', 'comparative']).optional().default('broad').describe("Research methodology approach"),
1022
+ sourceTypes: z.array(z.enum(['academic', 'news', 'government', 'commercial', 'blog', 'wiki', 'any'])).optional().default(['any']).describe("Types of sources to include"),
1023
+ credibilityThreshold: z.number().min(0).max(1).optional().default(0.3).describe("Minimum credibility score for sources (0-1)"),
1024
+ includeRecentOnly: z.boolean().optional().default(false).describe("Only include recent sources"),
1025
+ enableConflictDetection: z.boolean().optional().default(true).describe("Detect conflicting information across sources"),
1026
+ enableSourceVerification: z.boolean().optional().default(true).describe("Verify source credibility"),
1027
+ enableSynthesis: z.boolean().optional().default(true).describe("Synthesize findings into a coherent report"),
1028
+ outputFormat: z.enum(['comprehensive', 'summary', 'citations_only', 'conflicts_focus']).optional().default('comprehensive').describe("Output format for the research report"),
1029
+ includeRawData: z.boolean().optional().default(false).describe("Include raw scraped data in output"),
1030
+ includeActivityLog: z.boolean().optional().default(false).describe("Include detailed activity log"),
1031
+ queryExpansion: z.object({
1032
+ enableSynonyms: z.boolean().optional().default(true),
1033
+ enableSpellCheck: z.boolean().optional().default(true),
1034
+ enableContextual: z.boolean().optional().default(true),
1035
+ maxVariations: z.number().min(1).max(20).optional().default(8)
1036
+ }).optional().describe("Query expansion settings for broader search coverage"),
1037
+ llmConfig: z.object({
1038
+ provider: z.enum(['auto', 'openai', 'anthropic', 'ollama']).optional().default('auto'),
1039
+ openai: z.object({
1040
+ apiKey: z.string().optional(),
1041
+ model: z.string().optional().default('gpt-3.5-turbo'),
1042
+ embeddingModel: z.string().optional().default('text-embedding-ada-002')
1043
+ }).optional(),
1044
+ anthropic: z.object({
1045
+ apiKey: z.string().optional(),
1046
+ model: z.string().optional().default('claude-3-haiku-20240307')
1047
+ }).optional(),
1048
+ ollama: z.object({
1049
+ model: z.string().optional(),
1050
+ embeddingModel: z.string().optional()
1051
+ }).optional(),
1052
+ enableSemanticAnalysis: z.boolean().optional().default(true),
1053
+ enableIntelligentSynthesis: z.boolean().optional().default(true)
1054
+ }).optional().describe("LLM provider configuration for AI-powered analysis. provider 'auto' (default) uses a configured cloud key if there is one, else the local Ollama (http://localhost:11434, no key); 'ollama' forces the local model; 'openai'/'anthropic' need the matching API key"),
1055
+ concurrency: z.number().min(1).max(20).optional().default(5).describe("Number of concurrent research requests"),
1056
+ cacheResults: z.boolean().optional().default(true).describe("Cache research results for reuse"),
1057
+ webhook: z.object({
1058
+ url: z.string().url(),
1059
+ events: z.array(z.enum(['started', 'progress', 'completed', 'failed'])).optional().default(['completed']),
1060
+ headers: z.record(z.string()).optional()
1061
+ }).optional().describe("Webhook for progress and completion notifications"),
1062
+ ...MAX_INLINE_CHARS_PARAM
1063
+ }
1064
+ }, withAuth("deep_research", async (params, ctx) => {
1065
+ try {
1066
+ const result = await deepResearchTool.execute(params, ctx);
1067
+ if (isInputRequiredResult(result)) return result;
1068
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1069
+ } catch (error) {
1070
+ return { content: [{ type: "text", text: `Deep research failed: ${error.message}` }], isError: true };
1071
+ }
1097
1072
  }));
1098
- }
1099
1073
 
1100
1074
  // Tool: scrape (D4 D1 — unified multi-format single-fetch)
1101
1075
  registerToolIfEnabled("scrape", {
@@ -1140,34 +1114,27 @@ registerToolIfEnabled("scrape", {
1140
1114
  }
1141
1115
  }));
1142
1116
 
1143
- // Tool: agent (D4 D2 — autonomous NL prompt → search/navigate/extract; async task pattern — Phase 6)
1144
- if (toolFilter.isEnabled("agent")) {
1145
- server.experimental.tasks.registerToolTask("agent", {
1146
- 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. Not for a URL you already have (scrape) or a question one search answers (search_web). Cost: 8 credits, scales with maxUrls. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
1147
- annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1148
- inputSchema: {
1149
- prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
1150
- urls: z.array(z.string().url()).max(20).optional().describe("Optional seed URLs to include (max 20)"),
1151
- schema: z.record(z.any()).optional().describe("Optional JSON schema for structured output"),
1152
- model: z.enum(["default", "pro"]).optional().default("default").describe("\"default\" = SamplingClient loop (no keys needed); \"pro\" = full ResearchOrchestrator"),
1153
- maxSteps: z.number().min(1).max(10).optional().default(5).describe("Max fetch iterations (hard cap: 10)"),
1154
- maxUrls: z.number().min(1).max(20).optional().default(10).describe("Max URLs to fetch (hard cap: 20)")
1155
- },
1156
- execution: TASK_EXECUTION
1157
- }, makeTaskToolHandler({
1158
- name: "agent",
1159
- run: withAuth("agent", async (params) => {
1160
- try {
1161
- const result = await agentTool.execute(params);
1162
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1163
- } catch (error) {
1164
- return { content: [{ type: "text", text: `Agent failed: ${error.message}` }], isError: true };
1165
- }
1166
- }),
1167
- taskStore,
1168
- logger
1117
+ // Tool: agent (D4 D2 — autonomous NL prompt → search/navigate/extract)
1118
+ registerToolIfEnabled("agent", {
1119
+ 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. Not for a URL you already have (scrape) or a question one search answers (search_web). Cost: 8 credits, scales with maxUrls. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
1120
+ annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1121
+ inputSchema: {
1122
+ prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
1123
+ urls: z.array(z.string().url()).max(20).optional().describe("Optional seed URLs to include (max 20)"),
1124
+ schema: z.record(z.any()).optional().describe("Optional JSON schema for structured output"),
1125
+ model: z.enum(["default", "pro"]).optional().default("default").describe("\"default\" = SamplingClient loop (no keys needed); \"pro\" = full ResearchOrchestrator"),
1126
+ maxSteps: z.number().min(1).max(10).optional().default(5).describe("Max fetch iterations (hard cap: 10)"),
1127
+ maxUrls: z.number().min(1).max(20).optional().default(10).describe("Max URLs to fetch (hard cap: 20)")
1128
+ }
1129
+ }, withAuth("agent", async (params, ctx) => {
1130
+ try {
1131
+ const result = await agentTool.execute(params, ctx);
1132
+ if (isInputRequiredResult(result)) return result;
1133
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1134
+ } catch (error) {
1135
+ return { content: [{ type: "text", text: `Agent failed: ${error.message}` }], isError: true };
1136
+ }
1169
1137
  }));
1170
- }
1171
1138
 
1172
1139
  // Tool: track_changes
1173
1140
  registerToolIfEnabled("track_changes", {
@@ -1728,7 +1695,6 @@ if (process.env.CRAWLFORGE_TOOLS || process.env.CRAWLFORGE_TOOL_GROUPS) {
1728
1695
  // ─── Transport + startup ───────────────────────────────────────────────────────
1729
1696
 
1730
1697
  const useHttp = process.argv.includes('--http') || process.env.MCP_HTTP === 'true';
1731
- const useLegacyHttp = process.argv.includes('--legacy-http') || process.env.CRAWLFORGE_LEGACY_HTTP === 'true';
1732
1698
 
1733
1699
  async function runServer() {
1734
1700
  if (useHttp) {
@@ -1736,31 +1702,24 @@ async function runServer() {
1736
1702
  // Dockerfile `EXPOSE 10000`. Most PaaS providers inject $PORT — we honor it.
1737
1703
  const port = parseInt(process.env.PORT || '10000', 10);
1738
1704
 
1739
- if (useLegacyHttp) {
1740
- // One-release deprecation window for stateless legacy transport.
1741
- console.error('WARNING: --legacy-http is deprecated and will be removed in v3.3.0. Use the default Streamable HTTP transport.');
1742
- await connectHttp(server, AuthManager, logger, port);
1743
- } else {
1744
- // OAuth (opt-in)
1745
- let oauthProvider = null;
1746
- if (process.env.CRAWLFORGE_OAUTH_ENABLED === 'true') {
1747
- const issuer = process.env.CRAWLFORGE_OAUTH_ISSUER || `http://localhost:${port}`;
1748
- const apiKey = AuthManager.getConfig()?.apiKey;
1749
- if (!apiKey) {
1750
- console.error('OAuth enabled but no CrawlForge API key is configured — falling back to static-key auth.');
1751
- } else {
1752
- oauthProvider = createOAuthProvider({ issuer, apiKey, logger });
1753
- console.error(`OAuth 2.1 enabled — discovery at ${issuer}/.well-known/oauth-authorization-server`);
1754
- }
1705
+ // OAuth (opt-in)
1706
+ let oauthProvider = null;
1707
+ if (process.env.CRAWLFORGE_OAUTH_ENABLED === 'true') {
1708
+ const issuer = process.env.CRAWLFORGE_OAUTH_ISSUER || `http://localhost:${port}`;
1709
+ const apiKey = AuthManager.getConfig()?.apiKey;
1710
+ if (!apiKey) {
1711
+ console.error('OAuth enabled but no CrawlForge API key is configured — falling back to static-key auth.');
1712
+ } else {
1713
+ oauthProvider = createOAuthProvider({ issuer, apiKey, logger });
1714
+ console.error(`OAuth 2.1 enabled — discovery at ${issuer}/.well-known/oauth-authorization-server`);
1755
1715
  }
1756
-
1757
- await connectStreamableHttp(server, AuthManager, logger, {
1758
- port,
1759
- legacy: false,
1760
- oauth: oauthProvider,
1761
- metrics
1762
- });
1763
1716
  }
1717
+
1718
+ await connectStreamableHttp(server, AuthManager, logger, {
1719
+ port,
1720
+ oauth: oauthProvider,
1721
+ metrics
1722
+ });
1764
1723
  } else {
1765
1724
  await connectStdio(server);
1766
1725
  }