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.
- package/package.json +8 -6
- package/server.js +232 -261
- package/src/core/ActionExecutor.js +1 -1
- package/src/core/AuthManager.js +29 -2
- package/src/core/ChangeTracker.js +1 -1
- package/src/core/ElicitationHelper.js +83 -34
- package/src/core/SamplingClient.js +8 -2
- package/src/core/analysis/ContentAnalyzer.js +1 -1
- package/src/core/processing/BrowserProcessor.js +1 -1
- package/src/core/processing/ContentProcessor.js +1 -1
- package/src/core/processing/PDFProcessor.js +2 -2
- package/src/schemas/toolOutputSchemas.js +25 -0
- package/src/server/inlineThreshold.js +5 -0
- package/src/server/redaction.js +469 -0
- package/src/server/registerTool.js +1 -1
- package/src/server/specHygiene.js +17 -22
- package/src/server/transports/stdio.js +2 -3
- package/src/server/transports/streamableHttp.js +128 -66
- package/src/server/withAuth.js +42 -2
- package/src/tools/crawl/crawlDeep.js +4 -4
- package/src/tools/extract/analyzeContent.js +1 -1
- package/src/tools/extract/extractContent.js +1 -1
- package/src/tools/extract/processDocument.js +1 -1
- package/src/tools/extract/summarizeContent.js +1 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +2 -2
- package/src/tools/research/deepResearch.js +1 -1
- package/src/tools/search/batchSearch.js +50 -0
- package/src/tools/search/searchWeb.js +44 -0
- package/src/tools/tracking/trackChanges/schema.js +4 -4
- package/src/utils/HumanBehaviorSimulator.js +7 -7
- package/src/utils/secretMask.js +11 -7
- package/src/server/taskSupport.js +0 -233
- 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()).
|
|
169
|
+
metadata: z.record(z.any()).prefault({})
|
|
170
170
|
});
|
|
171
171
|
|
|
172
172
|
export class ActionExecutor extends EventEmitter {
|
package/src/core/AuthManager.js
CHANGED
|
@@ -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().
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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.
|
|
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.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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)
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
35
|
-
}).optional().
|
|
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,
|