scorchcrawl-mcp 2.1.1 → 2.2.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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Response Processing Utilities
3
+ *
4
+ * Two layers of intelligent response handling for MCP tool results:
5
+ * 1. Error Mapping — classifies errors and returns actionable guidance
6
+ * 2. Content Truncation — smart paragraph-boundary truncation with notices
7
+ *
8
+ * No competitor MCP scraper implements any of these. This is a ScorchCrawl
9
+ * differentiator.
10
+ */
11
+ // ---------------------------------------------------------------------------
12
+ // Configuration (from environment)
13
+ // ---------------------------------------------------------------------------
14
+ const MAX_CONTENT_CHARS = parseInt(process.env.SCORCHCRAWL_MAX_CONTENT_CHARS || '25000', 10);
15
+ /**
16
+ * Classify a raw error (from fetch, SDK, or engine) into a structured
17
+ * MappedError with actionable guidance for the LLM.
18
+ */
19
+ export function mapError(err) {
20
+ const raw = normalizeErrorString(err);
21
+ const lower = raw.toLowerCase();
22
+ // --- HTTP status-based patterns ---
23
+ if (matches(lower, ['403', 'forbidden', 'cloudflare', 'cf-ray', 'challenge', 'access denied'])) {
24
+ return {
25
+ code: 'ACCESS_DENIED',
26
+ message: 'This site blocks automated access.',
27
+ suggestions: [
28
+ 'Try scorch_search to find cached/indexed content instead',
29
+ 'Add waitFor: 5000 to let challenge pages resolve',
30
+ 'Try with proxy: "stealth" for enhanced bot evasion',
31
+ ],
32
+ originalError: raw,
33
+ };
34
+ }
35
+ if (matches(lower, ['404', 'not found', 'page not found', 'does not exist'])) {
36
+ return {
37
+ code: 'NOT_FOUND',
38
+ message: 'Page does not exist at this URL.',
39
+ suggestions: [
40
+ 'Use scorch_map to discover correct URLs on the site',
41
+ 'Check if the URL has a typo or outdated path',
42
+ 'Try scorch_search to find the current location of this content',
43
+ ],
44
+ originalError: raw,
45
+ };
46
+ }
47
+ if (matches(lower, ['429', 'rate limit', 'too many requests', 'throttl'])) {
48
+ return {
49
+ code: 'RATE_LIMITED',
50
+ message: 'Rate limited by the target site.',
51
+ suggestions: [
52
+ 'Wait 30 seconds and retry',
53
+ 'Try a different URL on the same site',
54
+ 'Use scorch_search to find the content from a different source',
55
+ ],
56
+ originalError: raw,
57
+ };
58
+ }
59
+ if (matchesStatusRange(lower, 500, 599)) {
60
+ return {
61
+ code: 'SERVER_ERROR',
62
+ message: 'The target site returned a server error.',
63
+ suggestions: [
64
+ 'Retry once — the site may be experiencing temporary issues',
65
+ 'Try again in a few minutes if it persists',
66
+ ],
67
+ originalError: raw,
68
+ };
69
+ }
70
+ // --- Network/connection errors ---
71
+ if (matches(lower, ['timeout', 'aborterror', 'aborted', 'timed out', 'etimedout'])) {
72
+ return {
73
+ code: 'TIMEOUT',
74
+ message: 'Page took too long to load.',
75
+ suggestions: [
76
+ 'Add onlyMainContent: true to reduce processing time',
77
+ 'Increase waitFor to give JS more time to render',
78
+ 'Try with a simpler format like markdown instead of screenshot',
79
+ ],
80
+ originalError: raw,
81
+ };
82
+ }
83
+ if (matches(lower, ['econnrefused', 'enotfound', 'dns', 'getaddrinfo', 'connect failed'])) {
84
+ // Distinguish between engine-down and remote-site-down
85
+ if (matches(lower, ['localhost', '127.0.0.1', '0.0.0.0', '3002', 'engine', 'api'])) {
86
+ return {
87
+ code: 'ENGINE_UNAVAILABLE',
88
+ message: 'Scraping engine is not running.',
89
+ suggestions: [
90
+ 'Check that Docker services are up: docker compose up -d',
91
+ 'Verify SCORCHCRAWL_API_URL is correct in your config',
92
+ ],
93
+ originalError: raw,
94
+ };
95
+ }
96
+ return {
97
+ code: 'CONNECTION_FAILED',
98
+ message: 'Cannot reach this URL.',
99
+ suggestions: [
100
+ 'Check if the URL is correct and accessible',
101
+ 'The site may be down — try again later',
102
+ 'Try scorch_search to find alternative sources',
103
+ ],
104
+ originalError: raw,
105
+ };
106
+ }
107
+ if (matches(lower, ['ssl', 'tls', 'certificate', 'cert', 'self-signed', 'unable to verify'])) {
108
+ return {
109
+ code: 'TLS_ERROR',
110
+ message: 'SSL certificate issue with this site.',
111
+ suggestions: [
112
+ 'Try with skipTlsVerification: true',
113
+ ],
114
+ originalError: raw,
115
+ };
116
+ }
117
+ if (matches(lower, ['empty', 'no content', 'no markdown', 'no extractable'])) {
118
+ return {
119
+ code: 'EMPTY_CONTENT',
120
+ message: 'Page returned no extractable content.',
121
+ suggestions: [
122
+ 'Use scorch_map with a search param to find the right page',
123
+ 'Add waitFor: 5000 for JavaScript-rendered pages',
124
+ 'Try with formats: ["html"] to see the raw page structure',
125
+ ],
126
+ originalError: raw,
127
+ };
128
+ }
129
+ if (matches(lower, ['spa_skeleton_detected', 'spa detected', 'javascript is required'])) {
130
+ return {
131
+ code: 'SPA_DETECTED',
132
+ message: 'Page requires JavaScript rendering (SPA detected).',
133
+ suggestions: [
134
+ 'Add waitFor: 5000 to allow JavaScript to render',
135
+ 'The engine will automatically retry with Playwright',
136
+ ],
137
+ originalError: raw,
138
+ };
139
+ }
140
+ // --- Catch-all ---
141
+ return {
142
+ code: 'UNKNOWN_ERROR',
143
+ message: `Scraping failed: ${truncateString(raw, 200)}`,
144
+ suggestions: [
145
+ 'Try a different URL or format',
146
+ 'Use scorch_search as an alternative data source',
147
+ ],
148
+ originalError: raw,
149
+ };
150
+ }
151
+ /** Normalize any error-like value to a string */
152
+ function normalizeErrorString(err) {
153
+ if (err == null)
154
+ return 'Unknown error';
155
+ if (err instanceof Error) {
156
+ // Include status code from Axios/fetch errors
157
+ const axiosStatus = err?.response?.status;
158
+ const status = err?.status || err?.statusCode || axiosStatus;
159
+ const prefix = status ? `HTTP ${status}: ` : '';
160
+ return `${prefix}${err.message}`;
161
+ }
162
+ if (typeof err === 'string')
163
+ return err;
164
+ try {
165
+ const s = JSON.stringify(err);
166
+ return s || 'Unknown error';
167
+ }
168
+ catch {
169
+ return String(err);
170
+ }
171
+ }
172
+ /** Check if a lowered string contains any of the patterns */
173
+ function matches(lower, patterns) {
174
+ return patterns.some((p) => lower.includes(p));
175
+ }
176
+ /** Check if an error string refers to an HTTP status in a range */
177
+ function matchesStatusRange(lower, from, to) {
178
+ const statusMatch = lower.match(/(?:status\s*(?:code\s*)?|http\s*)(\d{3})/);
179
+ if (statusMatch) {
180
+ const code = parseInt(statusMatch[1], 10);
181
+ return code >= from && code <= to;
182
+ }
183
+ // Also check for bare 5xx patterns
184
+ for (let s = from; s <= to; s++) {
185
+ if (lower.includes(String(s)))
186
+ return true;
187
+ }
188
+ return false;
189
+ }
190
+ /** Safely truncate a string for display */
191
+ function truncateString(s, maxLen) {
192
+ if (s.length <= maxLen)
193
+ return s;
194
+ return s.slice(0, maxLen - 3) + '...';
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Feature 2: Content Truncation
198
+ // ---------------------------------------------------------------------------
199
+ /**
200
+ * Content fields that may need truncation.
201
+ * Metadata, links, and structured data are NEVER truncated.
202
+ */
203
+ const CONTENT_FIELDS = ['markdown', 'html', 'rawHtml'];
204
+ /**
205
+ * For crawl results: max fraction of total limit a single page can use.
206
+ */
207
+ const SINGLE_PAGE_MAX_FRACTION = 0.3;
208
+ /**
209
+ * Truncate a markdown/html string at the nearest paragraph or heading
210
+ * boundary before the character limit.
211
+ */
212
+ export function truncateAtBoundary(content, maxChars) {
213
+ if (content.length <= maxChars) {
214
+ return { text: content, wasTruncated: false };
215
+ }
216
+ // Search backward from maxChars for a good break point
217
+ const searchWindow = content.slice(0, maxChars);
218
+ // Priority 1: Last heading boundary (# at start of line)
219
+ const headingMatch = searchWindow.lastIndexOf('\n#');
220
+ // Priority 2: Last double newline (paragraph boundary)
221
+ const paraMatch = searchWindow.lastIndexOf('\n\n');
222
+ // Priority 3: Last single newline
223
+ const lineMatch = searchWindow.lastIndexOf('\n');
224
+ // Priority 4: Last sentence end
225
+ const sentenceMatch = Math.max(searchWindow.lastIndexOf('. '), searchWindow.lastIndexOf('.\n'));
226
+ // Pick the best break point (prefer heading > paragraph > line > sentence)
227
+ let breakPoint = -1;
228
+ // Only use if it's in the last 30% of the window (don't cut too aggressively)
229
+ const minBreak = Math.floor(maxChars * 0.7);
230
+ if (headingMatch > minBreak)
231
+ breakPoint = headingMatch;
232
+ else if (paraMatch > minBreak)
233
+ breakPoint = paraMatch;
234
+ else if (lineMatch > minBreak)
235
+ breakPoint = lineMatch;
236
+ else if (sentenceMatch > minBreak)
237
+ breakPoint = sentenceMatch + 1; // include the period
238
+ else
239
+ breakPoint = maxChars; // hard cut as last resort
240
+ const truncated = content.slice(0, breakPoint).trimEnd();
241
+ return { text: truncated, wasTruncated: true };
242
+ }
243
+ /**
244
+ * Process a single scrape/crawl data object, truncating content fields
245
+ * while preserving metadata and structured data.
246
+ *
247
+ * Returns the processed data and truncation metadata.
248
+ */
249
+ export function truncateContent(data) {
250
+ if (MAX_CONTENT_CHARS <= 0) {
251
+ const serialized = JSON.stringify(data, null, 2);
252
+ return { result: data, wasTruncated: false, originalLength: serialized.length };
253
+ }
254
+ const serialized = JSON.stringify(data, null, 2);
255
+ if (serialized.length <= MAX_CONTENT_CHARS) {
256
+ return { result: data, wasTruncated: false, originalLength: serialized.length };
257
+ }
258
+ // Deep clone to avoid mutating the original
259
+ const cloned = JSON.parse(serialized);
260
+ let wasTruncated = false;
261
+ // Handle single scrape result (has data.markdown, data.html, etc.)
262
+ if (cloned && typeof cloned === 'object') {
263
+ const target = cloned.data || cloned;
264
+ for (const field of CONTENT_FIELDS) {
265
+ if (typeof target[field] === 'string' && target[field].length > 0) {
266
+ // Budget for this field: total limit minus overhead from other fields
267
+ const otherFieldsSize = serialized.length - target[field].length;
268
+ const fieldBudget = Math.max(MAX_CONTENT_CHARS - otherFieldsSize, Math.floor(MAX_CONTENT_CHARS * 0.5));
269
+ if (target[field].length > fieldBudget) {
270
+ const { text } = truncateAtBoundary(target[field], fieldBudget);
271
+ const originalLen = target[field].length;
272
+ target[field] = text + buildTruncationNotice(text.length, originalLen);
273
+ wasTruncated = true;
274
+ }
275
+ }
276
+ }
277
+ // Handle crawl results (array of pages)
278
+ if (Array.isArray(target)) {
279
+ const result = truncateCrawlArray(target);
280
+ return { result: cloned, wasTruncated: result.wasTruncated, originalLength: serialized.length };
281
+ }
282
+ // Add truncation metadata
283
+ if (wasTruncated) {
284
+ const meta = target.metadata || target;
285
+ meta._truncated = true;
286
+ meta._originalLength = serialized.length;
287
+ }
288
+ }
289
+ return { result: cloned, wasTruncated, originalLength: serialized.length };
290
+ }
291
+ /**
292
+ * Truncate a crawl result array: limit per-page content and total page count.
293
+ */
294
+ function truncateCrawlArray(pages) {
295
+ if (pages.length === 0)
296
+ return { wasTruncated: false };
297
+ const perPageLimit = Math.floor(MAX_CONTENT_CHARS * SINGLE_PAGE_MAX_FRACTION);
298
+ let wasTruncated = false;
299
+ let totalSize = 0;
300
+ for (let i = 0; i < pages.length; i++) {
301
+ const page = pages[i];
302
+ if (!page || typeof page !== 'object')
303
+ continue;
304
+ const target = page.data || page;
305
+ for (const field of CONTENT_FIELDS) {
306
+ if (typeof target[field] === 'string' && target[field].length > perPageLimit) {
307
+ const { text } = truncateAtBoundary(target[field], perPageLimit);
308
+ target[field] = text + buildTruncationNotice(text.length, target[field].length);
309
+ wasTruncated = true;
310
+ }
311
+ }
312
+ totalSize += JSON.stringify(page).length;
313
+ // If total exceeds limit, truncate the array
314
+ if (totalSize > MAX_CONTENT_CHARS && i < pages.length - 1) {
315
+ const originalCount = pages.length;
316
+ pages.length = i + 1;
317
+ pages.push({
318
+ _notice: `Showing ${i + 1} of ${originalCount} crawled pages. Use scorch_check_crawl_status with pagination to see more.`,
319
+ });
320
+ wasTruncated = true;
321
+ break;
322
+ }
323
+ }
324
+ return { wasTruncated };
325
+ }
326
+ /** Build a truncation notice for appending to content */
327
+ function buildTruncationNotice(shownChars, originalChars) {
328
+ return `\n\n---\n[Content truncated. Showing ~${Math.round(shownChars / 1000)}k of ~${Math.round(originalChars / 1000)}k characters.\nTo get specific information, try:\n- Use JSON format with a schema to extract only the data you need\n- Add onlyMainContent: true to exclude navigation/footers\n- Use scorch_map to find a more specific page URL]`;
329
+ }
330
+ // ---------------------------------------------------------------------------
331
+ // Combined Processing Pipeline
332
+ // ---------------------------------------------------------------------------
333
+ /**
334
+ * Safe wrapper for tool execution. Catches errors and returns
335
+ * mapped, LLM-friendly error responses.
336
+ */
337
+ export async function safeExecute(fn, context) {
338
+ try {
339
+ return await fn();
340
+ }
341
+ catch (err) {
342
+ const mapped = mapError(err);
343
+ console.error(`[${context.tool}] ${mapped.code}`, {
344
+ url: context.url,
345
+ original: mapped.originalError,
346
+ });
347
+ return JSON.stringify({
348
+ success: false,
349
+ error: mapped.message,
350
+ code: mapped.code,
351
+ suggestions: mapped.suggestions,
352
+ }, null, 2);
353
+ }
354
+ }
355
+ /**
356
+ * Process a successful response: truncate content if needed.
357
+ *
358
+ * Replaces the old `asText()` function.
359
+ */
360
+ export async function processResponse(data, options) {
361
+ const { result, wasTruncated } = truncateContent(data);
362
+ return JSON.stringify(result, null, 2);
363
+ }
364
+ /**
365
+ * Simple `asText` replacement for backward compatibility.
366
+ * Applies truncation only.
367
+ */
368
+ export function processResponseSync(data) {
369
+ const { result } = truncateContent(data);
370
+ return JSON.stringify(result, null, 2);
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // Exports for testing
374
+ // ---------------------------------------------------------------------------
375
+ export { MAX_CONTENT_CHARS };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "scorchcrawl-mcp",
3
- "version": "2.1.1",
4
- "description": "MCP client for ScorchCrawl connect Copilot to a self-hosted stealth web scraping server",
3
+ "version": "2.2.0",
4
+ "description": "Open-source MCP server for web scraping with stealth bot-detection bypass.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "scorchcrawl-mcp": "dist/cli.js"
7
+ "scorchcrawl-mcp": "dist/index.js"
8
8
  },
9
9
  "files": [
10
10
  "dist"
@@ -13,18 +13,29 @@
13
13
  "access": "public"
14
14
  },
15
15
  "scripts": {
16
- "build": "tsc",
17
- "start": "node dist/cli.js",
18
- "test": "npm run test:unit && npm run test:integration && npm run test:e2e",
19
- "test:unit": "npm run build && node --test test/unit/*.test.mjs",
20
- "test:integration": "npm run build && node --test test/integration/*.test.mjs",
21
- "test:e2e": "npm run build && node --test test/e2e/*.test.mjs",
22
- "smoke": "node test/smoke/smoke.js",
16
+ "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"",
17
+ "start": "node dist/index.js",
18
+ "start:server": "HTTP_STREAMABLE_SERVER=true node dist/index.js",
19
+ "dev": "tsc --watch",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "test:integration": "vitest run --config vitest.integration.config.ts",
23
+ "test:coverage": "vitest run --coverage",
24
+ "lint": "eslint src/**/*.ts",
25
+ "lint:fix": "eslint src/**/*.ts --fix",
26
+ "format": "prettier --write .",
23
27
  "prepare": "npm run build"
24
28
  },
25
29
  "license": "AGPL-3.0",
26
30
  "dependencies": {
27
- "dotenv": "^17.2.2"
31
+ "@mendable/firecrawl-js": "^4.9.3",
32
+ "cheerio": "^1.2.0",
33
+ "dotenv": "^17.2.2",
34
+ "firecrawl-fastmcp": "^1.0.4",
35
+ "turndown": "^7.2.2",
36
+ "typescript": "^5.9.2",
37
+ "uuid": "^11.1.0",
38
+ "zod": "^4.1.5"
28
39
  },
29
40
  "engines": {
30
41
  "node": ">=18.0.0"
@@ -32,12 +43,22 @@
32
43
  "keywords": [
33
44
  "mcp",
34
45
  "scorchcrawl",
35
- "copilot",
36
46
  "web-scraping",
37
- "stealth"
47
+ "stealth",
48
+ "bot-detection-bypass",
49
+ "crawler",
50
+ "content-extraction"
38
51
  ],
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/user/scorchcrawl.git"
55
+ },
56
+ "author": "ScorchCrawl Contributors",
57
+ "homepage": "https://github.com/user/scorchcrawl#readme",
39
58
  "devDependencies": {
40
- "@types/node": "^25.3.5",
41
- "typescript": "^5.9.3"
59
+ "@types/node": "^22.0.0",
60
+ "@types/turndown": "^5.0.5",
61
+ "@types/uuid": "^10.0.0",
62
+ "vitest": "^3.2.1"
42
63
  }
43
64
  }
package/dist/cli.js DELETED
@@ -1,207 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * scorchcrawl-mcp CLI
4
- *
5
- * A thin wrapper that connects to a ScorchCrawl server and exposes its MCP
6
- * tools over stdio transport. This lets MCP clients (VS Code, Copilot CLI,
7
- * etc.) use a remote ScorchCrawl server as if it were a local MCP server.
8
- *
9
- * Usage:
10
- * SCORCHCRAWL_URL=http://localhost:24787 scorchcrawl-mcp
11
- *
12
- * Or with a remote server + API key:
13
- * SCORCHCRAWL_URL=https://your-server.com/mcp-api/scorchcrawl/YOUR_KEY scorchcrawl-mcp
14
- *
15
- * Environment variables:
16
- * SCORCHCRAWL_URL - Base URL of the ScorchCrawl MCP server (required)
17
- * GITHUB_TOKEN - GitHub PAT for Copilot SDK agent (optional, sent as x-copilot-token)
18
- * SCORCHCRAWL_LOCAL_PROXY - Set to "true" to route scraping through your local IP
19
- */
20
- import { pathToFileURL } from 'url';
21
- async function loadDotenv() {
22
- try {
23
- const dotenv = await import('dotenv');
24
- dotenv.config({ quiet: true });
25
- }
26
- catch {
27
- // Allow direct source execution in constrained environments.
28
- }
29
- }
30
- const DEFAULT_MCP_URL = 'http://localhost:24787';
31
- function trimTrailingSlash(value) {
32
- return value.replace(/\/+$/, '');
33
- }
34
- function normalizeServerBaseUrl(value) {
35
- const trimmed = trimTrailingSlash(value.trim());
36
- if (!trimmed) {
37
- return DEFAULT_MCP_URL;
38
- }
39
- if (trimmed.endsWith('/mcp')) {
40
- return trimmed.slice(0, -4);
41
- }
42
- return trimmed;
43
- }
44
- function deriveLocalMcpUrl(apiUrl) {
45
- const localHosts = new Set(['localhost', '127.0.0.1', '::1']);
46
- if (!localHosts.has(apiUrl.hostname)) {
47
- return null;
48
- }
49
- if (apiUrl.port === '24786') {
50
- const derived = new URL(apiUrl.toString());
51
- derived.port = '24787';
52
- derived.pathname = '';
53
- derived.search = '';
54
- derived.hash = '';
55
- return normalizeServerBaseUrl(derived.toString());
56
- }
57
- return null;
58
- }
59
- export function resolveServerConfig(env = process.env) {
60
- const warnings = [];
61
- const directUrl = env.SCORCHCRAWL_URL?.trim();
62
- if (directUrl) {
63
- return {
64
- serverBaseUrl: normalizeServerBaseUrl(directUrl),
65
- source: 'SCORCHCRAWL_URL',
66
- warnings,
67
- };
68
- }
69
- const apiUrlValue = env.SCORCHCRAWL_API_URL?.trim();
70
- if (!apiUrlValue) {
71
- return {
72
- serverBaseUrl: DEFAULT_MCP_URL,
73
- source: 'default',
74
- warnings,
75
- };
76
- }
77
- let parsed;
78
- try {
79
- parsed = new URL(apiUrlValue);
80
- }
81
- catch {
82
- throw new Error(`Invalid SCORCHCRAWL_API_URL: ${apiUrlValue}. Set SCORCHCRAWL_URL to your MCP server URL instead.`);
83
- }
84
- if (parsed.port === '24787' || parsed.pathname.endsWith('/mcp')) {
85
- warnings.push('SCORCHCRAWL_API_URL is being used as an MCP endpoint alias. Prefer SCORCHCRAWL_URL for the npm client.');
86
- return {
87
- serverBaseUrl: normalizeServerBaseUrl(apiUrlValue),
88
- source: 'SCORCHCRAWL_API_URL',
89
- warnings,
90
- };
91
- }
92
- const derivedLocalUrl = deriveLocalMcpUrl(parsed);
93
- if (derivedLocalUrl) {
94
- warnings.push(`SCORCHCRAWL_API_URL points to the scraping engine (${apiUrlValue}). The npm client needs the MCP server, so it will use ${derivedLocalUrl} instead.`);
95
- return {
96
- serverBaseUrl: derivedLocalUrl,
97
- source: 'SCORCHCRAWL_API_URL',
98
- warnings,
99
- };
100
- }
101
- throw new Error(`SCORCHCRAWL_API_URL points to the scraping engine (${apiUrlValue}), not the MCP server. Set SCORCHCRAWL_URL to the MCP endpoint, for example http://localhost:24787.`);
102
- }
103
- export function isLocalProxyEnabled(env = process.env) {
104
- return env.SCORCHCRAWL_LOCAL_PROXY === 'true';
105
- }
106
- /**
107
- * Forward a JSON-RPC request to the remote ScorchCrawl server.
108
- */
109
- export async function forwardToServer(request, serverBaseUrl, githubToken) {
110
- const url = `${serverBaseUrl}/mcp`;
111
- const headers = {
112
- 'Content-Type': 'application/json',
113
- 'Accept': 'application/json, text/event-stream',
114
- };
115
- if (githubToken) {
116
- headers['x-copilot-token'] = githubToken;
117
- }
118
- try {
119
- const response = await fetch(url, {
120
- method: 'POST',
121
- headers,
122
- body: JSON.stringify(request),
123
- });
124
- const text = await response.text();
125
- // Handle SSE responses (event: message\ndata: {...})
126
- if (text.startsWith('event:') || text.startsWith('data:')) {
127
- const dataLine = text.split('\n').find(l => l.startsWith('data:'));
128
- if (dataLine) {
129
- return JSON.parse(dataLine.slice(5).trim());
130
- }
131
- }
132
- // Handle direct JSON responses
133
- if (text.trim().startsWith('{')) {
134
- return JSON.parse(text);
135
- }
136
- return {
137
- jsonrpc: '2.0',
138
- error: { code: -32603, message: `Unexpected response: ${text.substring(0, 200)}` },
139
- id: request.id,
140
- };
141
- }
142
- catch (err) {
143
- return {
144
- jsonrpc: '2.0',
145
- error: { code: -32603, message: `Connection failed: ${err.message}` },
146
- id: request.id,
147
- };
148
- }
149
- }
150
- /**
151
- * Read JSON-RPC messages from stdin and forward to the server.
152
- */
153
- async function main() {
154
- await loadDotenv();
155
- const { serverBaseUrl, warnings } = resolveServerConfig(process.env);
156
- const githubToken = process.env.GITHUB_TOKEN;
157
- const localProxy = isLocalProxyEnabled(process.env);
158
- if (!serverBaseUrl || serverBaseUrl === DEFAULT_MCP_URL) {
159
- process.stderr.write(`[scorchcrawl-mcp] Connecting to ${serverBaseUrl}\n` +
160
- `[scorchcrawl-mcp] Set SCORCHCRAWL_URL to change the server address\n`);
161
- }
162
- else {
163
- process.stderr.write(`[scorchcrawl-mcp] Connecting to ${serverBaseUrl}\n`);
164
- }
165
- for (const warning of warnings) {
166
- process.stderr.write(`[scorchcrawl-mcp] ${warning}\n`);
167
- }
168
- if (localProxy) {
169
- process.stderr.write('[scorchcrawl-mcp] Local proxy mode: ON (scraping through your IP)\n');
170
- process.stderr.write('[scorchcrawl-mcp] Note: this only works when the MCP server itself is running on this machine.\n');
171
- }
172
- // Read from stdin line by line
173
- let buffer = '';
174
- process.stdin.setEncoding('utf8');
175
- process.stdin.on('data', async (chunk) => {
176
- buffer += chunk;
177
- // Process complete lines
178
- const lines = buffer.split('\n');
179
- buffer = lines.pop() || '';
180
- for (const line of lines) {
181
- const trimmed = line.trim();
182
- if (!trimmed)
183
- continue;
184
- try {
185
- const request = JSON.parse(trimmed);
186
- const response = await forwardToServer(request, serverBaseUrl, githubToken);
187
- process.stdout.write(JSON.stringify(response) + '\n');
188
- }
189
- catch (err) {
190
- const errorResponse = {
191
- jsonrpc: '2.0',
192
- error: { code: -32700, message: `Parse error: ${err.message}` },
193
- };
194
- process.stdout.write(JSON.stringify(errorResponse) + '\n');
195
- }
196
- }
197
- });
198
- process.stdin.on('end', () => {
199
- process.exit(0);
200
- });
201
- }
202
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
203
- main().catch((err) => {
204
- process.stderr.write(`[scorchcrawl-mcp] Fatal error: ${err.message}\n`);
205
- process.exit(1);
206
- });
207
- }