crawlforge-mcp-server 4.9.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.
- package/CLAUDE.md +6 -5
- package/README.md +19 -3
- package/package.json +10 -12
- package/server.js +315 -214
- package/src/core/ActionExecutor.js +117 -33
- package/src/core/AgentOrchestrator.js +8 -2
- package/src/core/AuthManager.js +51 -17
- package/src/core/ChangeTracker.js +26 -10
- package/src/core/JobManager.js +9 -1
- package/src/core/LocalizationManager.js +19 -6
- package/src/core/ResearchOrchestrator.js +173 -35
- package/src/core/SnapshotManager.js +162 -165
- package/src/core/StealthBrowserManager.js +25 -3
- package/src/core/WebhookDispatcher.js +19 -14
- package/src/core/analysis/ContentAnalyzer.js +52 -7
- package/src/core/crawlers/BFSCrawler.js +27 -3
- package/src/core/processing/BrowserProcessor.js +19 -1
- package/src/core/processing/PDFProcessor.js +129 -65
- package/src/core/queue/QueueManager.js +3 -2
- package/src/schemas/toolOutputSchemas.js +269 -0
- package/src/server/auth/oauth.js +37 -7
- package/src/server/specHygiene.js +192 -0
- package/src/server/taskSupport.js +233 -0
- package/src/server/toolFilter.js +98 -0
- package/src/server/transports/streamableHttp.js +148 -11
- package/src/server/withAuth.js +11 -4
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
- package/src/tools/advanced/batchScrape/index.js +128 -27
- package/src/tools/advanced/batchScrape/worker.js +55 -5
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/_fetch.js +125 -70
- package/src/tools/basic/extractLinks.js +14 -12
- package/src/tools/basic/scrapeStructured.js +21 -4
- package/src/tools/crawl/crawlDeep.js +110 -48
- package/src/tools/crawl/mapSite.js +25 -6
- package/src/tools/extract/_fetchAndParse.js +98 -1
- package/src/tools/extract/extractContent.js +7 -4
- package/src/tools/extract/extractStructured.js +125 -84
- package/src/tools/extract/extractWithLlm.js +10 -2
- package/src/tools/extract/processDocument.js +54 -6
- package/src/tools/extract/summarizeContent.js +7 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
- package/src/tools/research/deepResearch.js +51 -31
- package/src/tools/scrape/_brandingExtractor.js +49 -11
- package/src/tools/scrape/unifiedScrape.js +27 -17
- package/src/tools/search/providers/searxng.js +5 -1
- package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
- package/src/tools/search/ranking/ResultRanker.js +17 -2
- package/src/tools/search/searchWeb.js +31 -14
- package/src/tools/search/serpRank.js +23 -0
- package/src/tools/templates/TemplateRegistry.js +7 -1
- package/src/tools/tracking/trackChanges/index.js +87 -26
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/CircuitBreaker.js +11 -9
- package/src/utils/contentUtils.js +66 -53
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +11 -9
- package/src/utils/ssrfGuard.js +212 -40
- package/src/utils/urlNormalizer.js +2 -2
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toolOutputSchemas — MCP `outputSchema` shapes for structured tool results (Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* Each entry is a plain zod RAW SHAPE (an object of validators), not a
|
|
5
|
+
* z.object(shape) — callers wrap it themselves (see src/server/registerTool.js).
|
|
6
|
+
*
|
|
7
|
+
* These are documentation/discovery schemas for MCP clients, not runtime
|
|
8
|
+
* enforcement: the SDK validates a tool's `structuredContent` against its
|
|
9
|
+
* outputSchema on every successful call, so every field here must be
|
|
10
|
+
* `.optional()` (every top-level key, and every nested object uses
|
|
11
|
+
* `.passthrough()`) to guarantee a legitimate result can never fail
|
|
12
|
+
* validation. Shapes are derived directly from the object each tool actually
|
|
13
|
+
* returns (see server.js registrations + the tool source files under
|
|
14
|
+
* src/tools/).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
// `_cost` is injected by withAuth into the legacy JSON-text copy of the
|
|
20
|
+
// result (never into structuredContent directly) — included here anyway so
|
|
21
|
+
// clients that copy `content[0].text` into structuredContent-shaped code
|
|
22
|
+
// don't fail validation.
|
|
23
|
+
const costShape = z.object({
|
|
24
|
+
projected: z.number().optional().describe('Credits projected for this call before execution'),
|
|
25
|
+
actual: z.number().optional().describe('Credits actually charged (0 in creator mode, half-rate on error)'),
|
|
26
|
+
remaining_credits: z.number().nullable().optional().describe('Credits remaining on the account after this call, if known'),
|
|
27
|
+
projection_note: z.string().optional().describe('Human-readable note about how the cost was projected')
|
|
28
|
+
}).passthrough().optional().describe('Cost-transparency metadata (D3.5), present when injected into the text copy of the result');
|
|
29
|
+
|
|
30
|
+
// ── scrape ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const scrapeLinkShape = z.object({
|
|
33
|
+
href: z.string().optional(),
|
|
34
|
+
text: z.string().optional(),
|
|
35
|
+
is_external: z.boolean().optional(),
|
|
36
|
+
original_href: z.string().optional()
|
|
37
|
+
}).passthrough();
|
|
38
|
+
|
|
39
|
+
const scrapeLinksShape = z.object({
|
|
40
|
+
links: z.array(scrapeLinkShape).optional(),
|
|
41
|
+
total_count: z.number().optional(),
|
|
42
|
+
internal_count: z.number().optional(),
|
|
43
|
+
external_count: z.number().optional()
|
|
44
|
+
}).passthrough();
|
|
45
|
+
|
|
46
|
+
const scrapeMetadataShape = z.object({
|
|
47
|
+
title: z.string().optional(),
|
|
48
|
+
description: z.string().optional(),
|
|
49
|
+
keywords: z.array(z.string()).optional(),
|
|
50
|
+
canonical_url: z.string().optional(),
|
|
51
|
+
author: z.string().optional(),
|
|
52
|
+
robots: z.string().optional(),
|
|
53
|
+
viewport: z.string().optional(),
|
|
54
|
+
og_tags: z.record(z.unknown()).optional(),
|
|
55
|
+
twitter_tags: z.record(z.unknown()).optional(),
|
|
56
|
+
json_ld: z.array(z.unknown()).optional(),
|
|
57
|
+
microdata: z.array(z.unknown()).optional(),
|
|
58
|
+
url: z.string().optional()
|
|
59
|
+
}).passthrough();
|
|
60
|
+
|
|
61
|
+
const scrapeShape = {
|
|
62
|
+
success: z.boolean().optional().describe('Whether the scrape completed'),
|
|
63
|
+
url: z.string().optional().describe('Final URL after redirects'),
|
|
64
|
+
content: z.object({
|
|
65
|
+
markdown: z.string().optional(),
|
|
66
|
+
html: z.string().optional(),
|
|
67
|
+
rawHtml: z.string().optional(),
|
|
68
|
+
text: z.string().optional(),
|
|
69
|
+
links: scrapeLinksShape.optional(),
|
|
70
|
+
metadata: scrapeMetadataShape.optional(),
|
|
71
|
+
branding: z.record(z.unknown()).optional().describe('Static design tokens: colors, fonts, logo'),
|
|
72
|
+
screenshots: z.array(z.object({}).passthrough()).optional().describe('Present for the "screenshot" format; each item carries a resourceUri once published'),
|
|
73
|
+
json: z.unknown().optional().describe('Result of the {type:"json"} format (LLM-structured extraction)')
|
|
74
|
+
}).passthrough().optional().describe('One key per requested format'),
|
|
75
|
+
warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
|
|
76
|
+
_cost: costShape
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ── map_site ────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const mapSiteShape = {
|
|
82
|
+
base_url: z.string().optional(),
|
|
83
|
+
total_urls: z.number().optional(),
|
|
84
|
+
urls: z.union([
|
|
85
|
+
z.array(z.string()),
|
|
86
|
+
z.record(z.array(z.string()))
|
|
87
|
+
]).optional().describe('Flat array of URLs, or grouped-by-path object when group_by_path=true (default)'),
|
|
88
|
+
metadata: z.record(z.unknown()).optional().describe('Per-URL metadata when include_metadata=true'),
|
|
89
|
+
site_map: z.object({
|
|
90
|
+
root: z.array(z.string()).optional(),
|
|
91
|
+
sections: z.record(z.unknown()).optional(),
|
|
92
|
+
depth_levels: z.record(z.unknown()).optional()
|
|
93
|
+
}).passthrough().optional(),
|
|
94
|
+
statistics: z.object({
|
|
95
|
+
total_urls: z.number().optional(),
|
|
96
|
+
unique_paths: z.number().optional(),
|
|
97
|
+
file_extensions: z.record(z.number()).optional(),
|
|
98
|
+
query_parameters: z.number().optional(),
|
|
99
|
+
secure_urls: z.number().optional(),
|
|
100
|
+
max_depth: z.number().optional(),
|
|
101
|
+
average_depth: z.number().optional(),
|
|
102
|
+
url_lengths: z.object({
|
|
103
|
+
min: z.number().nullable().optional(),
|
|
104
|
+
max: z.number().optional(),
|
|
105
|
+
average: z.number().optional()
|
|
106
|
+
}).passthrough().optional()
|
|
107
|
+
}).passthrough().optional(),
|
|
108
|
+
domain_filter_config: z.unknown().nullable().optional(),
|
|
109
|
+
filter_stats: z.unknown().nullable().optional(),
|
|
110
|
+
ranked_urls: z.array(z.object({
|
|
111
|
+
url: z.string().optional(),
|
|
112
|
+
score: z.number().optional()
|
|
113
|
+
}).passthrough()).optional().describe('Present only when the `search` param was set'),
|
|
114
|
+
_cost: costShape
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── serp_rank ───────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const serpRankResultShape = z.object({
|
|
120
|
+
position: z.number().nullable().optional(),
|
|
121
|
+
rankAbsolute: z.number().nullable().optional(),
|
|
122
|
+
domain: z.string().optional(),
|
|
123
|
+
url: z.string().nullable().optional(),
|
|
124
|
+
title: z.string().nullable().optional(),
|
|
125
|
+
snippet: z.string().optional()
|
|
126
|
+
}).passthrough();
|
|
127
|
+
|
|
128
|
+
const serpRankShape = {
|
|
129
|
+
configured: z.boolean().optional().describe('False when DATAFORSEO_LOGIN/PASSWORD are unset — no rank was fabricated'),
|
|
130
|
+
keyword: z.string().optional(),
|
|
131
|
+
target: z.string().optional().describe('Bare target domain, normalized'),
|
|
132
|
+
note: z.string().optional().describe('Present when configured=false, explains how to enable'),
|
|
133
|
+
found: z.boolean().optional().describe('Whether the target appeared anywhere in the scanned SERP'),
|
|
134
|
+
position: z.number().nullable().optional().describe('Best (lowest) organic rank; null = not within top `depth`'),
|
|
135
|
+
rankAbsolute: z.number().nullable().optional(),
|
|
136
|
+
url: z.string().nullable().optional().describe('URL of the target\'s best-ranking result'),
|
|
137
|
+
title: z.string().nullable().optional(),
|
|
138
|
+
allPositions: z.array(serpRankResultShape).optional().describe('Every position the target holds on this SERP'),
|
|
139
|
+
results: z.array(serpRankResultShape).optional().describe('Top organic competitors as Google actually ranks them (capped)'),
|
|
140
|
+
location: z.unknown().optional(),
|
|
141
|
+
device: z.string().optional(),
|
|
142
|
+
depthScanned: z.number().optional(),
|
|
143
|
+
organicResults: z.number().optional(),
|
|
144
|
+
seResultsCount: z.number().optional(),
|
|
145
|
+
checkUrl: z.string().optional().describe('Link to view the real SERP on DataForSEO'),
|
|
146
|
+
cost: z.number().optional().describe('USD charged by DataForSEO for this lookup (separate from CrawlForge credits)'),
|
|
147
|
+
checkedAt: z.string().optional(),
|
|
148
|
+
_cost: costShape
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ── search_web ──────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const searchWebResultShape = z.object({
|
|
154
|
+
title: z.string().optional(),
|
|
155
|
+
link: z.string().optional(),
|
|
156
|
+
snippet: z.string().optional(),
|
|
157
|
+
displayLink: z.string().optional(),
|
|
158
|
+
formattedUrl: z.string().optional(),
|
|
159
|
+
htmlSnippet: z.string().optional(),
|
|
160
|
+
pagemap: z.record(z.unknown()).optional(),
|
|
161
|
+
metadata: z.record(z.unknown()).optional()
|
|
162
|
+
}).passthrough();
|
|
163
|
+
|
|
164
|
+
const searchWebShape = {
|
|
165
|
+
query: z.string().optional(),
|
|
166
|
+
effective_query: z.string().optional().describe('Present when query expansion changed the query actually used'),
|
|
167
|
+
expanded_queries: z.array(z.string()).optional(),
|
|
168
|
+
results: z.array(searchWebResultShape).optional(),
|
|
169
|
+
total_results: z.union([z.string(), z.number()]).optional(),
|
|
170
|
+
search_time: z.number().optional(),
|
|
171
|
+
offset: z.number().optional(),
|
|
172
|
+
limit: z.number().optional(),
|
|
173
|
+
cached: z.boolean().optional(),
|
|
174
|
+
provider: z.object({
|
|
175
|
+
name: z.string().optional(),
|
|
176
|
+
backend: z.string().optional(),
|
|
177
|
+
note: z.string().optional(),
|
|
178
|
+
instanceUrl: z.string().nullable().optional(),
|
|
179
|
+
capabilities: z.record(z.unknown()).optional()
|
|
180
|
+
}).passthrough().optional(),
|
|
181
|
+
localization: z.object({
|
|
182
|
+
applied: z.boolean().optional(),
|
|
183
|
+
countryCode: z.string().optional(),
|
|
184
|
+
language: z.string().optional(),
|
|
185
|
+
searchDomain: z.string().optional(),
|
|
186
|
+
geoTargeting: z.boolean().optional()
|
|
187
|
+
}).passthrough().nullable().optional(),
|
|
188
|
+
processing: z.object({
|
|
189
|
+
ranking: z.record(z.unknown()).nullable().optional(),
|
|
190
|
+
deduplication: z.record(z.unknown()).nullable().optional(),
|
|
191
|
+
query_expansion: z.record(z.unknown()).nullable().optional(),
|
|
192
|
+
localization_applied: z.boolean().optional()
|
|
193
|
+
}).passthrough().optional(),
|
|
194
|
+
_cost: costShape
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ── extract_structured ───────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
const extractStructuredShape = {
|
|
200
|
+
url: z.string().optional(),
|
|
201
|
+
data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema'),
|
|
202
|
+
extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "none"'),
|
|
203
|
+
confidence: z.number().optional(),
|
|
204
|
+
schema_used: z.record(z.unknown()).optional(),
|
|
205
|
+
processingTime: z.number().optional(),
|
|
206
|
+
error: z.string().optional(),
|
|
207
|
+
validation: z.object({
|
|
208
|
+
valid: z.boolean().optional(),
|
|
209
|
+
errors: z.array(z.string()).optional()
|
|
210
|
+
}).passthrough().optional(),
|
|
211
|
+
extractionNotes: z.array(z.string()).optional(),
|
|
212
|
+
_cost: costShape
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ── crawl_deep ────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const crawlDeepPageShape = z.object({
|
|
218
|
+
url: z.string().optional(),
|
|
219
|
+
depth: z.number().optional(),
|
|
220
|
+
title: z.string().optional(),
|
|
221
|
+
links_count: z.number().optional(),
|
|
222
|
+
content_length: z.number().optional(),
|
|
223
|
+
timestamp: z.union([z.string(), z.number()]).optional(),
|
|
224
|
+
content: z.string().optional(),
|
|
225
|
+
truncated: z.boolean().optional(),
|
|
226
|
+
metadata: z.unknown().optional()
|
|
227
|
+
}).passthrough();
|
|
228
|
+
|
|
229
|
+
const crawlDeepShape = {
|
|
230
|
+
success: z.boolean().optional().describe('False only when the crawl was cancelled via elicitation decline'),
|
|
231
|
+
error: z.string().optional(),
|
|
232
|
+
url: z.string().optional(),
|
|
233
|
+
crawl_depth: z.number().optional(),
|
|
234
|
+
pages_crawled: z.number().optional(),
|
|
235
|
+
pages_found: z.number().optional(),
|
|
236
|
+
error_count: z.number().optional(),
|
|
237
|
+
duration_ms: z.number().optional(),
|
|
238
|
+
pages_per_second: z.number().optional(),
|
|
239
|
+
results: z.array(crawlDeepPageShape).optional(),
|
|
240
|
+
errors: z.array(z.unknown()).optional(),
|
|
241
|
+
stats: z.unknown().optional(),
|
|
242
|
+
site_structure: z.object({
|
|
243
|
+
total_pages: z.number().optional(),
|
|
244
|
+
depth_distribution: z.record(z.number()).optional(),
|
|
245
|
+
path_patterns: z.record(z.number()).optional(),
|
|
246
|
+
file_types: z.record(z.number()).optional(),
|
|
247
|
+
subdomains: z.array(z.string()).optional()
|
|
248
|
+
}).passthrough().optional(),
|
|
249
|
+
domain_filter_config: z.unknown().nullable().optional(),
|
|
250
|
+
link_analysis: z.unknown().nullable().optional(),
|
|
251
|
+
session: z.object({
|
|
252
|
+
enabled: z.boolean().optional(),
|
|
253
|
+
cookies_captured: z.number().optional()
|
|
254
|
+
}).passthrough().optional(),
|
|
255
|
+
_cost: costShape
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// ── Export ────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export const OUTPUT_SCHEMAS = {
|
|
261
|
+
scrape: scrapeShape,
|
|
262
|
+
map_site: mapSiteShape,
|
|
263
|
+
serp_rank: serpRankShape,
|
|
264
|
+
search_web: searchWebShape,
|
|
265
|
+
extract_structured: extractStructuredShape,
|
|
266
|
+
crawl_deep: crawlDeepShape
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export default OUTPUT_SCHEMAS;
|
package/src/server/auth/oauth.js
CHANGED
|
@@ -188,6 +188,22 @@ async function handleAuthorize(req, res, store, apiKey) {
|
|
|
188
188
|
return sendJson(res, 400, { error: 'invalid_request', error_description: 'only S256 PKCE is supported' });
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Proof-of-possession required: the caller must present the operator's
|
|
192
|
+
// CrawlForge API key — via `Authorization: Bearer <key>` header or an
|
|
193
|
+
// `api_key` query parameter — before a code is issued. Dynamic client
|
|
194
|
+
// registration (/oauth/register) is open and unauthenticated by design,
|
|
195
|
+
// so client_id/PKCE alone must never be sufficient to mint a code; the
|
|
196
|
+
// key proof is the actual authorization check.
|
|
197
|
+
//
|
|
198
|
+
// For a multi-tenant deployment, replace this with a real consent page that
|
|
199
|
+
// resolves to a CrawlForge user → API key mapping.
|
|
200
|
+
if (!verifyApiKeyProof(req, params, apiKey)) {
|
|
201
|
+
return sendJson(res, 401, {
|
|
202
|
+
error: 'invalid_client',
|
|
203
|
+
error_description: 'proof of API key possession required (Authorization: Bearer <key> header or api_key query parameter)'
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
const client = await store.getClient(params.client_id);
|
|
192
208
|
if (!client) {
|
|
193
209
|
return sendJson(res, 400, { error: 'invalid_client' });
|
|
@@ -196,13 +212,6 @@ async function handleAuthorize(req, res, store, apiKey) {
|
|
|
196
212
|
return sendJson(res, 400, { error: 'invalid_redirect_uri' });
|
|
197
213
|
}
|
|
198
214
|
|
|
199
|
-
// Auto-approve: this server is a personal MCP endpoint backed by a single
|
|
200
|
-
// CrawlForge API key the operator has already authenticated. No consent UI
|
|
201
|
-
// is needed — possession of the operator's `apiKey` IS the authorization.
|
|
202
|
-
// (Same trust model as the static-key transport.)
|
|
203
|
-
//
|
|
204
|
-
// For a multi-tenant deployment, replace this with a real consent page that
|
|
205
|
-
// resolves to a CrawlForge user → API key mapping.
|
|
206
215
|
const code = randomBytes(24).toString('base64url');
|
|
207
216
|
await store.setCode(code, {
|
|
208
217
|
clientId: params.client_id,
|
|
@@ -313,6 +322,27 @@ async function issueTokens(store, { clientId, mappedApiKey, scopes, accessTtlMs,
|
|
|
313
322
|
};
|
|
314
323
|
}
|
|
315
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Verify the caller has proven possession of the operator's CrawlForge API key.
|
|
327
|
+
* Accepted forms: `Authorization: Bearer <key>` header, or `api_key` query param.
|
|
328
|
+
* Comparison is constant-time over fixed-length SHA-256 digests (never compares
|
|
329
|
+
* the raw secrets directly, and never leaks timing based on input length).
|
|
330
|
+
*/
|
|
331
|
+
function verifyApiKeyProof(req, params, apiKey) {
|
|
332
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
333
|
+
let provided = null;
|
|
334
|
+
if (typeof authHeader === 'string' && /^Bearer\s+/i.test(authHeader)) {
|
|
335
|
+
provided = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
336
|
+
} else if (typeof params.api_key === 'string' && params.api_key.length > 0) {
|
|
337
|
+
provided = params.api_key;
|
|
338
|
+
}
|
|
339
|
+
if (!provided) return false;
|
|
340
|
+
|
|
341
|
+
const providedHash = createHash('sha256').update(provided).digest();
|
|
342
|
+
const expectedHash = createHash('sha256').update(apiKey).digest();
|
|
343
|
+
return timingSafeEqual(providedHash, expectedHash);
|
|
344
|
+
}
|
|
345
|
+
|
|
316
346
|
function verifyPkce(verifier, expectedChallenge) {
|
|
317
347
|
try {
|
|
318
348
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* specHygiene — Phase 6 protocol-hygiene wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Applies three MCP wire-level upgrades to an already-registered McpServer
|
|
5
|
+
* without changing how tools/prompts are declared anywhere else:
|
|
6
|
+
*
|
|
7
|
+
* 1. JSON Schema 2020-12 dialect stamping on every tool's inputSchema /
|
|
8
|
+
* outputSchema (the SDK's zod-to-json-schema conversion still emits
|
|
9
|
+
* draft-07-style `definitions` / `#/definitions/...` refs).
|
|
10
|
+
* 2. Deterministic (alphabetical) tool ordering in tools/list, so clients
|
|
11
|
+
* that prompt-cache tools/list get stable hits across restarts.
|
|
12
|
+
* 3. SEP-973 icons metadata on every tool/prompt lacking one.
|
|
13
|
+
* 4. SEP-2549-style cache hints on tools/call results for a fixed
|
|
14
|
+
* allowlist of read-only, idempotent tools (see note below).
|
|
15
|
+
*
|
|
16
|
+
* Wiring: SDK 1.30 has no plugin hook for this, so applySpecHygiene() must be
|
|
17
|
+
* called once, after all server.registerTool()/registerPrompt() calls and
|
|
18
|
+
* before transport.connect(). It reaches into `server.server` (the
|
|
19
|
+
* underlying Protocol), captures the ListTools/CallTool/ListPrompts handlers
|
|
20
|
+
* McpServer already installed in `_requestHandlers`, and re-registers wrapped
|
|
21
|
+
* versions via `setRequestHandler` — which silently replaces an existing
|
|
22
|
+
* handler for the same method (confirmed from the SDK source: only
|
|
23
|
+
* `assertCanSetRequestHandler`, called by McpServer's own registration path,
|
|
24
|
+
* throws on a pre-existing handler; `setRequestHandler` itself does not).
|
|
25
|
+
*
|
|
26
|
+
* SEP-2549 note: as specified (2026-07-28 RC), `ttlMs` / `cacheScope`
|
|
27
|
+
* ("public"|"private") are defined on tools/list, prompts/list,
|
|
28
|
+
* resources/list, resources/read and resources/templates/list results —
|
|
29
|
+
* there is no CacheableResult for tools/call. Since this deliverable asks
|
|
30
|
+
* for tools/call cache hints, the same two field names are carried into a
|
|
31
|
+
* namespaced `_meta` key following the SDK's own
|
|
32
|
+
* `io.modelcontextprotocol/<name>` convention (see RELATED_TASK_META_KEY in
|
|
33
|
+
* @modelcontextprotocol/sdk/types.js):
|
|
34
|
+
* result._meta["io.modelcontextprotocol/cacheable"] = { ttlMs, cacheScope }
|
|
35
|
+
* This is a documented adaptation, not a key confirmed by the spec text for
|
|
36
|
+
* tools/call — revisit if/when a SEP defines call-result caching explicitly.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
ListToolsRequestSchema,
|
|
41
|
+
CallToolRequestSchema,
|
|
42
|
+
ListPromptsRequestSchema
|
|
43
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
44
|
+
|
|
45
|
+
const APPLIED = Symbol('crawlforge.specHygiene.applied');
|
|
46
|
+
|
|
47
|
+
const JSON_SCHEMA_2020_12 = 'https://json-schema.org/draft/2020-12/schema';
|
|
48
|
+
const CACHE_META_KEY = 'io.modelcontextprotocol/cacheable';
|
|
49
|
+
|
|
50
|
+
const DEFAULT_ICON = Object.freeze({
|
|
51
|
+
src: 'https://www.crawlforge.dev/icon.png',
|
|
52
|
+
mimeType: 'image/png',
|
|
53
|
+
sizes: ['any']
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Read-only, idempotent tools whose tools/call results get a SEP-2549-style
|
|
57
|
+
// cache hint (see note above). ttlMs mirrors CACHE_TTL's 5-minute default;
|
|
58
|
+
// cacheScope 'private' because scraped content may reflect the caller's own
|
|
59
|
+
// request context (headers, auth) and should not be shared across clients.
|
|
60
|
+
const DEFAULT_CACHEABLE_TOOLS = Object.freeze({
|
|
61
|
+
fetch_url: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
62
|
+
extract_text: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
63
|
+
extract_links: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
64
|
+
extract_metadata: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
65
|
+
scrape_structured: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
66
|
+
map_site: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
67
|
+
search_web: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
68
|
+
serp_rank: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
69
|
+
extract_content: Object.freeze({ ttlMs: 300000, cacheScope: 'private' }),
|
|
70
|
+
scrape: Object.freeze({ ttlMs: 300000, cacheScope: 'private' })
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Deep-copies a JSON-schema-like tree, renaming `definitions` -> `$defs` and
|
|
75
|
+
* rewriting `"#/definitions/..."` refs to `"#/$defs/..."` anywhere in the
|
|
76
|
+
* tree. Never mutates the input (some schemas, e.g. the SDK's shared
|
|
77
|
+
* "no params" constant, are frozen and reused across tools).
|
|
78
|
+
*/
|
|
79
|
+
function rewriteDefs(node) {
|
|
80
|
+
if (Array.isArray(node)) return node.map(rewriteDefs);
|
|
81
|
+
if (node && typeof node === 'object') {
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const [key, value] of Object.entries(node)) {
|
|
84
|
+
if (key === 'definitions') {
|
|
85
|
+
out.$defs = rewriteDefs(value);
|
|
86
|
+
} else if (key === '$ref' && typeof value === 'string' && value.startsWith('#/definitions/')) {
|
|
87
|
+
out.$ref = `#/$defs/${value.slice('#/definitions/'.length)}`;
|
|
88
|
+
} else {
|
|
89
|
+
out[key] = rewriteDefs(value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
return node;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function stamp2020_12(schema) {
|
|
98
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
99
|
+
return { ...rewriteDefs(schema), $schema: JSON_SCHEMA_2020_12 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function withDefaultIcon(entry, icon) {
|
|
103
|
+
if (Array.isArray(entry.icons) && entry.icons.length > 0) return entry;
|
|
104
|
+
return { ...entry, icons: [icon] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function wrapToolsList(innerHandler, icon) {
|
|
108
|
+
return async (request, extra) => {
|
|
109
|
+
const result = await innerHandler(request, extra);
|
|
110
|
+
if (!result || !Array.isArray(result.tools)) return result;
|
|
111
|
+
|
|
112
|
+
const tools = result.tools
|
|
113
|
+
.map((tool) => {
|
|
114
|
+
let next = { ...tool };
|
|
115
|
+
if (next.inputSchema) next.inputSchema = stamp2020_12(next.inputSchema);
|
|
116
|
+
if (next.outputSchema) next.outputSchema = stamp2020_12(next.outputSchema);
|
|
117
|
+
next = withDefaultIcon(next, icon);
|
|
118
|
+
return next;
|
|
119
|
+
})
|
|
120
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
121
|
+
|
|
122
|
+
return { ...result, tools };
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function wrapPromptsList(innerHandler, icon) {
|
|
127
|
+
return async (request, extra) => {
|
|
128
|
+
const result = await innerHandler(request, extra);
|
|
129
|
+
if (!result || !Array.isArray(result.prompts)) return result;
|
|
130
|
+
|
|
131
|
+
const prompts = result.prompts.map((prompt) => withDefaultIcon(prompt, icon));
|
|
132
|
+
return { ...result, prompts };
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function wrapToolsCall(innerHandler, cacheableTools) {
|
|
137
|
+
return async (request, extra) => {
|
|
138
|
+
const result = await innerHandler(request, extra);
|
|
139
|
+
if (!result || typeof result !== 'object') return result;
|
|
140
|
+
if (result.isError) return result;
|
|
141
|
+
if (!Array.isArray(result.content)) return result; // task-augmented / content-less results pass through untouched
|
|
142
|
+
|
|
143
|
+
const hint = cacheableTools[request?.params?.name];
|
|
144
|
+
if (!hint) return result;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...result,
|
|
148
|
+
_meta: {
|
|
149
|
+
...(result._meta ?? {}),
|
|
150
|
+
[CACHE_META_KEY]: { ttlMs: hint.ttlMs, cacheScope: hint.cacheScope }
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Applies the protocol-hygiene wrappers to `server`. Call once, after all
|
|
158
|
+
* tools/prompts are registered and before transport.connect(). Idempotent:
|
|
159
|
+
* a second call on the same server is a no-op.
|
|
160
|
+
*
|
|
161
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
162
|
+
* @param {object} [overrides]
|
|
163
|
+
* @param {{src:string,mimeType?:string,sizes?:string[]}} [overrides.icon] — default icon injected into tools/prompts lacking one
|
|
164
|
+
* @param {Record<string,{ttlMs:number,cacheScope:'public'|'private'}>} [overrides.cacheableTools] — tool-name -> cache hint map for tools/call
|
|
165
|
+
*/
|
|
166
|
+
export function applySpecHygiene(server, overrides = {}) {
|
|
167
|
+
const protocol = server?.server;
|
|
168
|
+
if (!protocol || typeof protocol.setRequestHandler !== 'function' || !(protocol._requestHandlers instanceof Map)) {
|
|
169
|
+
throw new TypeError('applySpecHygiene requires a connected McpServer instance');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (protocol[APPLIED]) return;
|
|
173
|
+
protocol[APPLIED] = true;
|
|
174
|
+
|
|
175
|
+
const icon = overrides.icon ?? DEFAULT_ICON;
|
|
176
|
+
const cacheableTools = overrides.cacheableTools ?? DEFAULT_CACHEABLE_TOOLS;
|
|
177
|
+
|
|
178
|
+
const innerToolsList = protocol._requestHandlers.get('tools/list');
|
|
179
|
+
if (innerToolsList) {
|
|
180
|
+
protocol.setRequestHandler(ListToolsRequestSchema, wrapToolsList(innerToolsList, icon));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const innerToolsCall = protocol._requestHandlers.get('tools/call');
|
|
184
|
+
if (innerToolsCall) {
|
|
185
|
+
protocol.setRequestHandler(CallToolRequestSchema, wrapToolsCall(innerToolsCall, cacheableTools));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const innerPromptsList = protocol._requestHandlers.get('prompts/list');
|
|
189
|
+
if (innerPromptsList) {
|
|
190
|
+
protocol.setRequestHandler(ListPromptsRequestSchema, wrapPromptsList(innerPromptsList, icon));
|
|
191
|
+
}
|
|
192
|
+
}
|