crawlforge-mcp-server 5.9.0 → 6.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 (33) hide show
  1. package/package.json +8 -6
  2. package/server.js +232 -261
  3. package/src/core/ActionExecutor.js +1 -1
  4. package/src/core/AuthManager.js +29 -2
  5. package/src/core/ChangeTracker.js +1 -1
  6. package/src/core/ElicitationHelper.js +83 -34
  7. package/src/core/SamplingClient.js +8 -2
  8. package/src/core/analysis/ContentAnalyzer.js +1 -1
  9. package/src/core/processing/BrowserProcessor.js +1 -1
  10. package/src/core/processing/ContentProcessor.js +1 -1
  11. package/src/core/processing/PDFProcessor.js +2 -2
  12. package/src/schemas/toolOutputSchemas.js +25 -0
  13. package/src/server/inlineThreshold.js +5 -0
  14. package/src/server/redaction.js +469 -0
  15. package/src/server/registerTool.js +1 -1
  16. package/src/server/specHygiene.js +17 -22
  17. package/src/server/transports/stdio.js +2 -3
  18. package/src/server/transports/streamableHttp.js +128 -66
  19. package/src/server/withAuth.js +42 -2
  20. package/src/tools/crawl/crawlDeep.js +4 -4
  21. package/src/tools/extract/analyzeContent.js +1 -1
  22. package/src/tools/extract/extractContent.js +1 -1
  23. package/src/tools/extract/processDocument.js +1 -1
  24. package/src/tools/extract/summarizeContent.js +1 -1
  25. package/src/tools/llmstxt/generateLLMsTxt.js +2 -2
  26. package/src/tools/research/deepResearch.js +1 -1
  27. package/src/tools/search/batchSearch.js +50 -0
  28. package/src/tools/search/searchWeb.js +44 -0
  29. package/src/tools/tracking/trackChanges/schema.js +4 -4
  30. package/src/utils/HumanBehaviorSimulator.js +7 -7
  31. package/src/utils/secretMask.js +11 -7
  32. package/src/server/taskSupport.js +0 -233
  33. package/src/server/transports/http.js +0 -22
@@ -166,7 +166,7 @@ const ActionChainSchema = z.object({
166
166
  continueOnError: z.boolean().default(false),
167
167
  timeout: z.number().min(1000).max(300000).default(30000),
168
168
  retryChain: z.number().min(0).max(3).default(0),
169
- metadata: z.record(z.any()).default({})
169
+ metadata: z.record(z.any()).prefault({})
170
170
  });
171
171
 
172
172
  export class ActionExecutor extends EventEmitter {
@@ -14,6 +14,8 @@ import { logger } from '../utils/Logger.js';
14
14
  import { maskSecrets } from '../utils/secretMask.js';
15
15
  import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
16
16
  import { scrapeEscalationSurcharge } from '../tools/scrape/escalation.js';
17
+ import { searchQueryCount } from '../tools/search/batchSearch.js';
18
+ import { redactionSurcharge, REDACT_PII_MODEL_CREDITS } from '../server/redaction.js';
17
19
  // Stamped on every usage report so support can tell which client version
18
20
  // made a call; it read '3.0.3' from 3.0.3 to 5.6.10 (the website stores it
19
21
  // per record from its Phase 1.3 on).
@@ -626,6 +628,11 @@ class AuthManager {
626
628
  return costs.search_web;
627
629
  }
628
630
 
631
+ // redact_pii's regex pass is free; its opt-in model pass adds 3 once per
632
+ // call, on every tool that offers the param. The rule lives with the
633
+ // schema that declares it (src/server/redaction.js).
634
+ const redaction = redactionSurcharge(tool, params);
635
+
629
636
  // The query-scoped formats (highlights, question) add 1 once per call,
630
637
  // and mode:"model" adds 3 once; escalate:true adds the stealth browser's
631
638
  // own 5, since that is the ceiling the call may reach. The base price in
@@ -633,10 +640,17 @@ class AuthManager {
633
640
  // that declare the params.
634
641
  if (tool === 'scrape') {
635
642
  const { query, model } = scrapeFormatSurcharge(params?.formats);
636
- return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate);
643
+ return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate) + redaction;
644
+ }
645
+
646
+ // search_web's batch form (5.1) runs one backend search per query, so it
647
+ // is priced as that many search_web calls. The count rule lives with the
648
+ // schema that declares `queries` (src/tools/search/batchSearch.js).
649
+ if (tool === 'search_web') {
650
+ return costs.search_web * searchQueryCount(params?.queries) + redaction;
637
651
  }
638
652
 
639
- return costs[tool] ?? 1;
653
+ return (costs[tool] ?? 1) + redaction;
640
654
  }
641
655
 
642
656
  /**
@@ -677,6 +691,13 @@ class AuthManager {
677
691
  note = `Lower-bound estimate. crawl_deep cost grows with page count (${maxPages} max).`;
678
692
  break;
679
693
  }
694
+ case 'search_web': {
695
+ const queryCount = searchQueryCount(params?.queries);
696
+ note = queryCount > 1
697
+ ? `5 per query, ${queryCount} queries — exactly what ${queryCount} separate calls cost. A query that fails before it reaches a backend is not billed.`
698
+ : 'Fixed cost per invocation.';
699
+ break;
700
+ }
680
701
  case 'extract_with_llm':
681
702
  note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
682
703
  break;
@@ -708,6 +729,12 @@ class AuthManager {
708
729
  note = 'Fixed cost per invocation.';
709
730
  }
710
731
 
732
+ // redact_pii applies to nine different tools, so its note is appended
733
+ // rather than owned by any one case.
734
+ if (redactionSurcharge(toolName, params) > 0) {
735
+ note += ` redact_pii mode:"model" adds ${REDACT_PII_MODEL_CREDITS} once for the PERSON/LOCATION pass; the charge drops back when no LLM route answered.`;
736
+ }
737
+
711
738
  return { projected, note };
712
739
  }
713
740
 
@@ -37,7 +37,7 @@ const ChangeTrackingSchema = z.object({
37
37
  moderate: z.number().min(0).max(1).default(0.3),
38
38
  major: z.number().min(0).max(1).default(0.7)
39
39
  }).optional()
40
- }).optional().default({})
40
+ }).optional().prefault({})
41
41
  });
42
42
 
43
43
  const ChangeComparisonSchema = z.object({
@@ -5,8 +5,58 @@
5
5
  * expensive or ambiguous operations. Falls back gracefully when the
6
6
  * MCP client does not support elicitation.
7
7
  *
8
- * MCP Spec 2025-11-25: client/elicit request with requestedSchema
8
+ * The request goes out as a 2025-era server→client `elicitation/create`,
9
+ * awaited inline in the middle of a tool's work. Two things the SDK does NOT
10
+ * do for us, and which `supported` therefore decides before we send:
11
+ *
12
+ * - `Server.elicitInput()` refuses a client that declared a bare
13
+ * `elicitation: {}` (it demands `elicitation.form`), even though the SDK's
14
+ * own capability rule counts a bare declaration as form-capable — that is
15
+ * the pre-mode 2025 meaning, and it is what the SDK's legacy shim and its
16
+ * 2026 seam both apply. We send through `Server.request()`, which applies
17
+ * that same lenient rule, so those clients get their prompt.
18
+ *
19
+ * - Both `elicitInput()` and `request()` throw on a 2026-07-28-era
20
+ * connection: that revision has no server→client request channel at all.
21
+ * There is no inline substitute — the replacement is an `input_required`
22
+ * result RETURNED by a tools/call handler, which this helper cannot do
23
+ * from the middle of a tool's execution. So `supported` reports false and
24
+ * the operation proceeds unasked, exactly as it does for a client with no
25
+ * elicitation capability. A confirmation prompt is a nicety; failing the
26
+ * call is not an acceptable substitute. See docs/mcp-spec-adoption.md.
27
+ */
28
+
29
+ /**
30
+ * First revision of the modern protocol era. Revisions are ISO dates, so
31
+ * lexicographic comparison orders them chronologically (the SDK's own rule).
9
32
  */
33
+ const FIRST_MODERN_PROTOCOL_VERSION = '2026-07-28';
34
+
35
+ /** The one-boolean schema a confirmation asks with. */
36
+ const CONFIRM_SCHEMA = {
37
+ type: 'object',
38
+ properties: {
39
+ confirmed: {
40
+ type: 'boolean',
41
+ title: 'Proceed?',
42
+ description: 'Confirm to proceed with the operation',
43
+ },
44
+ },
45
+ required: ['confirmed'],
46
+ };
47
+
48
+ /**
49
+ * Whether the client's declared capabilities cover FORM elicitation, by the
50
+ * SDK's own rule: `elicitation.form` counts, and so does a bare `elicitation`
51
+ * declaration naming neither mode (the pre-mode 2025 meaning). A client that
52
+ * declared only `elicitation.url` has not declared form support.
53
+ */
54
+ function formElicitationDeclared(caps) {
55
+ const elicitation = caps?.elicitation;
56
+ if (!elicitation) return false;
57
+ if (elicitation.form !== undefined) return true;
58
+ return elicitation.url === undefined;
59
+ }
10
60
 
11
61
  export class ElicitationHelper {
12
62
  /**
@@ -20,22 +70,35 @@ export class ElicitationHelper {
20
70
  }
21
71
 
22
72
  /**
23
- * Whether the connected MCP client supports elicitation.
73
+ * Whether an inline elicitation round trip will actually reach the user.
24
74
  * @returns {boolean}
25
75
  */
26
76
  get supported() {
27
77
  const server = this._mcpServer?.server;
28
- // The MCP SDK exposes elicitation via Server.elicitInput(); it is only
29
- // usable when the connected CLIENT advertised the `elicitation` capability.
30
- if (typeof server?.elicitInput !== 'function') return false;
78
+ if (typeof server?.request !== 'function') return false;
31
79
  try {
32
- const caps = server.getClientCapabilities?.();
33
- return !!caps?.elicitation;
80
+ // No server→client request channel exists on the 2026-07-28 era.
81
+ const negotiated = server.getNegotiatedProtocolVersion?.();
82
+ if (typeof negotiated === 'string' && negotiated >= FIRST_MODERN_PROTOCOL_VERSION) return false;
83
+ return formElicitationDeclared(server.getClientCapabilities?.());
34
84
  } catch {
35
85
  return false;
36
86
  }
37
87
  }
38
88
 
89
+ /**
90
+ * Send one form-mode `elicitation/create` and return the ElicitResult.
91
+ * This is byte-for-byte the message `Server.elicitInput()` sends; it just
92
+ * does not impose that method's stricter `elicitation.form` gate.
93
+ * @private
94
+ */
95
+ async _elicit(message, requestedSchema) {
96
+ return this._mcpServer.server.request({
97
+ method: 'elicitation/create',
98
+ params: { message, requestedSchema, mode: 'form' },
99
+ });
100
+ }
101
+
39
102
  /**
40
103
  * Ask for user confirmation before proceeding with an expensive operation.
41
104
  * Returns true if confirmed (or if elicitation is unsupported — fail-open
@@ -57,20 +120,7 @@ export class ElicitationHelper {
57
120
  .join('\n');
58
121
  const fullMessage = detailLines ? `${message}\n\n${detailLines}` : message;
59
122
 
60
- const result = await this._mcpServer.server.elicitInput({
61
- message: fullMessage,
62
- requestedSchema: {
63
- type: 'object',
64
- properties: {
65
- confirmed: {
66
- type: 'boolean',
67
- title: 'Proceed?',
68
- description: 'Confirm to proceed with the operation',
69
- },
70
- },
71
- required: ['confirmed'],
72
- },
73
- });
123
+ const result = await this._elicit(fullMessage, CONFIRM_SCHEMA);
74
124
 
75
125
  // Only an explicit accept + confirmed=true proceeds; decline/cancel = stop.
76
126
  return result?.action === 'accept' && result?.content?.confirmed === true;
@@ -97,23 +147,22 @@ export class ElicitationHelper {
97
147
  }
98
148
 
99
149
  try {
100
- const result = await this._mcpServer.server.elicitInput({
101
- message,
102
- requestedSchema: {
103
- type: 'object',
104
- properties: {
105
- [fieldName]: {
106
- type: 'string',
107
- title: fieldName,
108
- description: fieldDescription,
109
- ...(defaultValue ? { default: defaultValue } : {}),
110
- },
150
+ const result = await this._elicit(message, {
151
+ type: 'object',
152
+ properties: {
153
+ [fieldName]: {
154
+ type: 'string',
155
+ title: fieldName,
156
+ description: fieldDescription,
157
+ ...(defaultValue ? { default: defaultValue } : {}),
111
158
  },
112
- required: [fieldName],
113
159
  },
160
+ required: [fieldName],
114
161
  });
115
162
 
116
- if (result?.action === 'accept' && result?.content?.[fieldName] != null) {
163
+ // The answer is client-supplied and no longer schema-checked by the SDK
164
+ // on this path, so hold it to the type we asked for.
165
+ if (result?.action === 'accept' && typeof result?.content?.[fieldName] === 'string') {
117
166
  return result.content[fieldName];
118
167
  }
119
168
  return defaultValue || null;
@@ -7,7 +7,9 @@
7
7
  * Fallback chain (applied in resolveCompletion):
8
8
  * 1. Ollama (local, no API key needed)
9
9
  * 2. Server-side API key (OPENAI_API_KEY / ANTHROPIC_API_KEY)
10
- * 3. MCP sampling request to client
10
+ * 3. MCP sampling request to client — DEPRECATED in MCP revision 2026-07-28
11
+ * (SEP-2577); removal on or after 2027-07-28. Emits a one-line stderr
12
+ * deprecation notice when it serves a completion.
11
13
  * 4. Error
12
14
  */
13
15
 
@@ -157,7 +159,11 @@ export class SamplingClient {
157
159
  includeContext: 'none',
158
160
  });
159
161
  const text = samplingResult?.content?.text || '';
160
- if (text) return { text, provider: 'sampling' };
162
+ if (text) {
163
+ // stderr, never stdout — stdout is the JSON-RPC stream on stdio.
164
+ console.error('[deprecation] MCP sampling served this completion. Sampling was deprecated in MCP revision 2026-07-28 (SEP-2577); CrawlForge removes this fallback on or after 2027-07-28. Run Ollama or set OPENAI_API_KEY / ANTHROPIC_API_KEY.');
165
+ return { text, provider: 'sampling' };
166
+ }
161
167
  } catch (_samplingErr) {
162
168
  // Sampling not supported or failed
163
169
  }
@@ -28,7 +28,7 @@ const ContentAnalyzerSchema = z.object({
28
28
  maxKeywords: z.number().min(1).max(50).default(15),
29
29
  includeReadabilityMetrics: z.boolean().default(true),
30
30
  includeSentiment: z.boolean().default(true)
31
- }).optional().default({})
31
+ }).optional().prefault({})
32
32
  });
33
33
 
34
34
  const AnalysisResult = z.object({
@@ -74,7 +74,7 @@ const BrowserProcessorSchema = z.object({
74
74
  enableTimezoneSpoof: z.boolean().default(true),
75
75
  enableGeoLocationSpoof: z.boolean().default(true)
76
76
  }).optional()
77
- }).optional().default({})
77
+ }).optional().prefault({})
78
78
  });
79
79
 
80
80
  const BrowserResult = z.object({
@@ -20,7 +20,7 @@ const ContentProcessorSchema = z.object({
20
20
  removeBoilerplate: z.boolean().default(true),
21
21
  preserveImageInfo: z.boolean().default(true),
22
22
  extractMetadata: z.boolean().default(true)
23
- }).optional().default({})
23
+ }).optional().prefault({})
24
24
  });
25
25
 
26
26
  const ReadabilityResult = z.object({
@@ -31,8 +31,8 @@ const PDFProcessorSchema = z.object({
31
31
  parseOptions: z.object({
32
32
  normalizeWhitespace: z.boolean().default(true),
33
33
  disableCombineTextItems: z.boolean().default(false)
34
- }).optional().default({})
35
- }).optional().default({})
34
+ }).optional().prefault({})
35
+ }).optional().prefault({})
36
36
  });
37
37
 
38
38
  const PDFResult = z.object({
@@ -42,6 +42,16 @@ const resultHandleShape = {
42
42
  expires_at: z.string().optional().describe('When the stored result is dropped (ISO 8601)')
43
43
  };
44
44
 
45
+ // ── redact_pii (Phase 5, 5.3) ───────────────────────────────────────────────
46
+
47
+ // What the redaction stage reports back on every tool that offers redact_pii.
48
+ const redactionShape = z.object({
49
+ entities: z.record(z.number()).optional().describe('How many spans were replaced, by entity class; a class with no hits is omitted'),
50
+ count: z.number().optional().describe('Total spans replaced'),
51
+ mode: z.enum(['fast', 'model']).optional().describe('"fast" is the free regex pass; "model" added an Ollama NER pass for PERSON and LOCATION'),
52
+ model_ran: z.boolean().optional().describe('mode "model" only: whether a model actually answered. False means no LLM route existed and the model surcharge was not charged')
53
+ }).passthrough().optional().describe('Present when redact_pii was set: what was redacted from the text of this result');
54
+
45
55
  // ── scrape ──────────────────────────────────────────────────────────────────
46
56
 
47
57
  const scrapeLinkShape = z.object({
@@ -131,6 +141,7 @@ const scrapeShape = {
131
141
  }).passthrough().optional().describe('Present when escalated is true: the stealth engine that ran, and the bot-defence vendor the plain fetch hit (null when the block named none)'),
132
142
  content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
133
143
  warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
144
+ redaction: redactionShape,
134
145
  ...resultHandleShape,
135
146
  _cost: costShape
136
147
  };
@@ -272,8 +283,20 @@ const searchWebResultShape = z.object({
272
283
  metadata: z.record(z.unknown()).optional()
273
284
  }).passthrough();
274
285
 
286
+ // The batch form (5.1): one entry per query, in the order they were given.
287
+ // Each carries the same fields a single-query call returns, plus the query
288
+ // itself — or `error` when that one query failed and the others did not.
289
+ const searchWebBatchEntryShape = z.object({
290
+ query: z.string().optional(),
291
+ error: z.string().optional().describe('Present when this query failed; the other queries in the batch are unaffected'),
292
+ results: z.array(searchWebResultShape).optional()
293
+ }).passthrough();
294
+
275
295
  const searchWebShape = {
276
296
  query: z.string().optional(),
297
+ queries: z.array(z.string()).optional().describe('Batch form: the queries that ran, in order'),
298
+ count: z.number().optional().describe('Batch form: how many queries ran'),
299
+ results_by_query: z.array(searchWebBatchEntryShape).optional().describe('Batch form: one entry per query, in order'),
277
300
  effective_query: z.string().optional().describe('Present when query expansion changed the query actually used'),
278
301
  expanded_queries: z.array(z.string()).optional(),
279
302
  results: z.array(searchWebResultShape).optional(),
@@ -302,6 +325,7 @@ const searchWebShape = {
302
325
  query_expansion: z.record(z.unknown()).nullable().optional(),
303
326
  localization_applied: z.boolean().optional()
304
327
  }).passthrough().optional(),
328
+ redaction: redactionShape,
305
329
  _cost: costShape
306
330
  };
307
331
 
@@ -380,6 +404,7 @@ const crawlDeepShape = {
380
404
  }).passthrough().optional(),
381
405
  crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
382
406
  cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
407
+ redaction: redactionShape,
383
408
  ...resultHandleShape,
384
409
  _cost: costShape
385
410
  };
@@ -135,6 +135,11 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
135
135
  shaped[key] = value;
136
136
  }
137
137
  }
138
+ // redact_pii's report is an object, so the scalar filter above drops it —
139
+ // and the caller was charged for it. Carry it through like warnings.
140
+ if (resultObject.redaction && typeof resultObject.redaction === 'object') {
141
+ shaped.redaction = resultObject.redaction;
142
+ }
138
143
  Object.assign(shaped, {
139
144
  preview,
140
145
  result_handle: handle,