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.
Files changed (60) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +315 -214
  5. package/src/core/ActionExecutor.js +117 -33
  6. package/src/core/AgentOrchestrator.js +8 -2
  7. package/src/core/AuthManager.js +51 -17
  8. package/src/core/ChangeTracker.js +26 -10
  9. package/src/core/JobManager.js +9 -1
  10. package/src/core/LocalizationManager.js +19 -6
  11. package/src/core/ResearchOrchestrator.js +173 -35
  12. package/src/core/SnapshotManager.js +162 -165
  13. package/src/core/StealthBrowserManager.js +25 -3
  14. package/src/core/WebhookDispatcher.js +19 -14
  15. package/src/core/analysis/ContentAnalyzer.js +52 -7
  16. package/src/core/crawlers/BFSCrawler.js +27 -3
  17. package/src/core/processing/BrowserProcessor.js +19 -1
  18. package/src/core/processing/PDFProcessor.js +129 -65
  19. package/src/core/queue/QueueManager.js +3 -2
  20. package/src/schemas/toolOutputSchemas.js +269 -0
  21. package/src/server/auth/oauth.js +37 -7
  22. package/src/server/specHygiene.js +192 -0
  23. package/src/server/taskSupport.js +233 -0
  24. package/src/server/toolFilter.js +98 -0
  25. package/src/server/transports/streamableHttp.js +148 -11
  26. package/src/server/withAuth.js +11 -4
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
  28. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  29. package/src/tools/advanced/batchScrape/index.js +128 -27
  30. package/src/tools/advanced/batchScrape/worker.js +55 -5
  31. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  32. package/src/tools/basic/_fetch.js +125 -70
  33. package/src/tools/basic/extractLinks.js +14 -12
  34. package/src/tools/basic/scrapeStructured.js +21 -4
  35. package/src/tools/crawl/crawlDeep.js +110 -48
  36. package/src/tools/crawl/mapSite.js +25 -6
  37. package/src/tools/extract/_fetchAndParse.js +98 -1
  38. package/src/tools/extract/extractContent.js +7 -4
  39. package/src/tools/extract/extractStructured.js +125 -84
  40. package/src/tools/extract/extractWithLlm.js +10 -2
  41. package/src/tools/extract/processDocument.js +54 -6
  42. package/src/tools/extract/summarizeContent.js +7 -1
  43. package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
  44. package/src/tools/research/deepResearch.js +51 -31
  45. package/src/tools/scrape/_brandingExtractor.js +49 -11
  46. package/src/tools/scrape/unifiedScrape.js +27 -17
  47. package/src/tools/search/providers/searxng.js +5 -1
  48. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  49. package/src/tools/search/ranking/ResultRanker.js +17 -2
  50. package/src/tools/search/searchWeb.js +31 -14
  51. package/src/tools/search/serpRank.js +23 -0
  52. package/src/tools/templates/TemplateRegistry.js +7 -1
  53. package/src/tools/tracking/trackChanges/index.js +87 -26
  54. package/src/tools/tracking/trackChanges/schema.js +2 -2
  55. package/src/utils/CircuitBreaker.js +11 -9
  56. package/src/utils/contentUtils.js +66 -53
  57. package/src/utils/secretMask.js +1 -1
  58. package/src/utils/sitemapParser.js +11 -9
  59. package/src/utils/ssrfGuard.js +212 -40
  60. 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;
@@ -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
+ }