scorchcrawl-mcp 1.0.2 → 1.1.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,619 @@
1
+ /**
2
+ * Copilot SDK Agent Engine
3
+ *
4
+ * Replaces the proprietary cloud-only agent with a Copilot SDK-powered
5
+ * agent that uses scorchcrawl tools (scrape, search, map, extract) as custom tools.
6
+ *
7
+ * Model selection is controlled via COPILOT_AGENT_MODELS environment variable.
8
+ */
9
+ import { CopilotClient } from '@github/copilot-sdk';
10
+ import { v4 as uuidv4 } from 'uuid';
11
+ import { RateLimitGuard, buildRateLimitConfig, buildErrorHook, findStaleJobs, } from './rate-limiter.js';
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ /** Parse COPILOT_AGENT_MODELS env var into an array of model names */
16
+ export function parseAllowedModels() {
17
+ const envModels = process.env.COPILOT_AGENT_MODELS;
18
+ if (!envModels) {
19
+ // Defaults from user requirements
20
+ return ['gpt-4.1', 'gpt-4o', 'gpt-5-mini', 'grok-code-fast-1'];
21
+ }
22
+ return envModels
23
+ .split(',')
24
+ .map((m) => m.trim())
25
+ .filter(Boolean);
26
+ }
27
+ /** Get default model (first in list or from env) */
28
+ export function getDefaultModel() {
29
+ const defaultFromEnv = process.env.COPILOT_AGENT_DEFAULT_MODEL;
30
+ if (defaultFromEnv)
31
+ return defaultFromEnv.trim();
32
+ const allowed = parseAllowedModels();
33
+ return allowed[0] || 'gpt-4.1';
34
+ }
35
+ /** Build the agent configuration from environment */
36
+ export function buildAgentConfig() {
37
+ const config = {
38
+ allowedModels: parseAllowedModels(),
39
+ defaultModel: getDefaultModel(),
40
+ };
41
+ // Optional BYOK provider
42
+ if (process.env.COPILOT_AGENT_PROVIDER_TYPE && process.env.COPILOT_AGENT_PROVIDER_BASE_URL) {
43
+ config.provider = {
44
+ type: process.env.COPILOT_AGENT_PROVIDER_TYPE,
45
+ baseUrl: process.env.COPILOT_AGENT_PROVIDER_BASE_URL,
46
+ apiKey: process.env.COPILOT_AGENT_PROVIDER_API_KEY,
47
+ };
48
+ }
49
+ return config;
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // ScorchCrawl Tools for Copilot SDK Agent
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Build the custom tools that the Copilot SDK agent will have access to.
56
+ * These wrap the scraping engine client methods so the agent can scrape, search,
57
+ * map, crawl, and extract data from the web.
58
+ */
59
+ function buildScrapingTools(client, origin) {
60
+ return [
61
+ {
62
+ name: 'web_scrape',
63
+ description: 'Scrape content from a single URL. Returns markdown content by default. ' +
64
+ 'Use formats parameter to request JSON extraction with a schema, or other formats like html, screenshot, links. ' +
65
+ 'If the result is empty, blocked, or shows anti-bot content, use web_screenshot to visually inspect the page.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ url: { type: 'string', description: 'The URL to scrape' },
70
+ formats: {
71
+ type: 'array',
72
+ description: 'Output formats: "markdown", "html", "links", or JSON extraction object',
73
+ items: { type: 'string' },
74
+ },
75
+ onlyMainContent: {
76
+ type: 'boolean',
77
+ description: 'Extract only the main content, excluding nav/footer/etc',
78
+ },
79
+ waitFor: {
80
+ type: 'number',
81
+ description: 'Wait time in ms for JS-rendered pages (default 0)',
82
+ },
83
+ },
84
+ required: ['url'],
85
+ },
86
+ handler: async (args) => {
87
+ try {
88
+ const a = args;
89
+ const { url, ...opts } = a;
90
+ const res = await client.scrape(String(url), {
91
+ ...opts,
92
+ origin,
93
+ });
94
+ return {
95
+ textResultForLlm: JSON.stringify(res, null, 2),
96
+ resultType: 'success',
97
+ };
98
+ }
99
+ catch (err) {
100
+ return {
101
+ textResultForLlm: `Scrape failed: ${err.message || err}`,
102
+ resultType: 'failure',
103
+ error: String(err.message || err),
104
+ };
105
+ }
106
+ },
107
+ },
108
+ {
109
+ name: 'web_screenshot',
110
+ description: 'Take a screenshot of a web page for visual inspection. ' +
111
+ 'Use this when web_scrape returns empty, blocked, or anti-bot content. ' +
112
+ 'The screenshot is passed directly to your vision capability so you can analyze what is on the page — ' +
113
+ 'cookie popups, CAPTCHAs, anti-bot walls, or the actual rendered content.',
114
+ parameters: {
115
+ type: 'object',
116
+ properties: {
117
+ url: { type: 'string', description: 'The URL to screenshot' },
118
+ fullPage: {
119
+ type: 'boolean',
120
+ description: 'Capture the full scrollable page (default: false, viewport only)',
121
+ },
122
+ waitFor: {
123
+ type: 'number',
124
+ description: 'Wait time in ms for JS rendering before screenshot (default 3000)',
125
+ },
126
+ },
127
+ required: ['url'],
128
+ },
129
+ handler: async (args) => {
130
+ try {
131
+ const a = args;
132
+ const url = String(a.url);
133
+ const fullPage = a.fullPage === true;
134
+ const waitFor = typeof a.waitFor === 'number' ? a.waitFor : 3000;
135
+ const screenshotFormat = {
136
+ type: 'screenshot',
137
+ fullPage,
138
+ };
139
+ const res = await client.scrape(url, {
140
+ formats: [screenshotFormat],
141
+ waitFor,
142
+ origin,
143
+ });
144
+ const resAny = res;
145
+ const screenshot = resAny?.screenshot || resAny?.data?.screenshot;
146
+ const metadata = resAny?.metadata || resAny?.data?.metadata || {};
147
+ if (screenshot) {
148
+ const imgBytes = Buffer.from(screenshot, 'base64').length;
149
+ const description = [
150
+ `Screenshot of: ${url}`,
151
+ `Title: ${metadata.title || 'Unknown'}`,
152
+ `Status: ${metadata.statusCode || 'Unknown'}`,
153
+ `Size: ${Math.round(imgBytes / 1024)}KB`,
154
+ '',
155
+ 'Analyze the attached image to determine:',
156
+ '- Is there a cookie consent popup blocking content?',
157
+ '- Is there a CAPTCHA or anti-bot challenge?',
158
+ '- Is the page content actually rendered?',
159
+ '- What text/data is visible?',
160
+ ].join('\n');
161
+ return {
162
+ textResultForLlm: description,
163
+ binaryResultsForLlm: [{
164
+ data: screenshot,
165
+ mimeType: 'image/png',
166
+ type: 'image',
167
+ description: `Screenshot of ${url}`,
168
+ }],
169
+ resultType: 'success',
170
+ };
171
+ }
172
+ return {
173
+ textResultForLlm: `Screenshot request completed but no image was returned. Page metadata: ${JSON.stringify(metadata, null, 2)}`,
174
+ resultType: 'success',
175
+ };
176
+ }
177
+ catch (err) {
178
+ return {
179
+ textResultForLlm: `Screenshot failed: ${err.message || err}. The page may be unreachable or the screenshot service unavailable.`,
180
+ resultType: 'failure',
181
+ error: String(err.message || err),
182
+ };
183
+ }
184
+ },
185
+ },
186
+ {
187
+ name: 'web_search',
188
+ description: 'Search the web for information. Returns search results with titles, URLs, and snippets. ' +
189
+ 'Supports search operators like site:, inurl:, intitle:, and quoted phrases.',
190
+ parameters: {
191
+ type: 'object',
192
+ properties: {
193
+ query: { type: 'string', description: 'The search query' },
194
+ limit: {
195
+ type: 'number',
196
+ description: 'Maximum number of results (default 5)',
197
+ },
198
+ },
199
+ required: ['query'],
200
+ },
201
+ handler: async (args) => {
202
+ try {
203
+ const a = args;
204
+ const { query, ...opts } = a;
205
+ const res = await client.search(String(query), {
206
+ ...opts,
207
+ origin,
208
+ });
209
+ return {
210
+ textResultForLlm: JSON.stringify(res, null, 2),
211
+ resultType: 'success',
212
+ };
213
+ }
214
+ catch (err) {
215
+ return {
216
+ textResultForLlm: `Search failed: ${err.message || err}`,
217
+ resultType: 'failure',
218
+ error: String(err.message || err),
219
+ };
220
+ }
221
+ },
222
+ },
223
+ {
224
+ name: 'web_map',
225
+ description: 'Map a website to discover all indexed URLs. Use the search parameter to find specific pages. ' +
226
+ 'Useful when you need to find the right page before scraping.',
227
+ parameters: {
228
+ type: 'object',
229
+ properties: {
230
+ url: { type: 'string', description: 'The website URL to map' },
231
+ search: {
232
+ type: 'string',
233
+ description: 'Optional search query to filter URLs',
234
+ },
235
+ limit: {
236
+ type: 'number',
237
+ description: 'Maximum number of URLs to return',
238
+ },
239
+ },
240
+ required: ['url'],
241
+ },
242
+ handler: async (args) => {
243
+ try {
244
+ const a = args;
245
+ const { url, ...opts } = a;
246
+ const res = await client.map(String(url), {
247
+ ...opts,
248
+ origin,
249
+ });
250
+ return {
251
+ textResultForLlm: JSON.stringify(res, null, 2),
252
+ resultType: 'success',
253
+ };
254
+ }
255
+ catch (err) {
256
+ return {
257
+ textResultForLlm: `Map failed: ${err.message || err}`,
258
+ resultType: 'failure',
259
+ error: String(err.message || err),
260
+ };
261
+ }
262
+ },
263
+ },
264
+ {
265
+ name: 'web_extract',
266
+ description: 'Extract structured information from web pages using LLM capabilities. ' +
267
+ 'Provide URLs, a prompt describing what to extract, and an optional JSON schema for the output format.',
268
+ parameters: {
269
+ type: 'object',
270
+ properties: {
271
+ urls: {
272
+ type: 'array',
273
+ items: { type: 'string' },
274
+ description: 'URLs to extract information from',
275
+ },
276
+ prompt: {
277
+ type: 'string',
278
+ description: 'Description of what information to extract',
279
+ },
280
+ schema: {
281
+ type: 'object',
282
+ description: 'JSON schema for structured output',
283
+ },
284
+ },
285
+ required: ['urls'],
286
+ },
287
+ handler: async (args) => {
288
+ try {
289
+ const a = args;
290
+ const res = await client.extract({
291
+ urls: a.urls,
292
+ prompt: a.prompt,
293
+ schema: a.schema,
294
+ origin,
295
+ });
296
+ return {
297
+ textResultForLlm: JSON.stringify(res, null, 2),
298
+ resultType: 'success',
299
+ };
300
+ }
301
+ catch (err) {
302
+ return {
303
+ textResultForLlm: `Extract failed: ${err.message || err}`,
304
+ resultType: 'failure',
305
+ error: String(err.message || err),
306
+ };
307
+ }
308
+ },
309
+ },
310
+ ];
311
+ }
312
+ // ---------------------------------------------------------------------------
313
+ // Agent Engine
314
+ // ---------------------------------------------------------------------------
315
+ /** In-memory store for agent jobs */
316
+ const agentJobs = new Map();
317
+ // ---------------------------------------------------------------------------
318
+ // Rate Limiting & Concurrency Guard (singleton)
319
+ // ---------------------------------------------------------------------------
320
+ const rateLimitGuard = new RateLimitGuard(buildRateLimitConfig());
321
+ /** Periodic stale-job reaper */
322
+ setInterval(() => {
323
+ const staleIds = findStaleJobs(agentJobs.values(), rateLimitGuard.config.staleJobTimeoutMs);
324
+ for (const id of staleIds) {
325
+ const job = agentJobs.get(id);
326
+ if (job) {
327
+ job.status = 'failed';
328
+ job.error = `Job timed out after ${rateLimitGuard.config.staleJobTimeoutMs / 1000}s without completing.`;
329
+ job.completedAt = Date.now();
330
+ // Release concurrency slot (use empty-string key for unknown user)
331
+ rateLimitGuard.release(job._userKey || '');
332
+ console.warn(`[RateLimit] Reaped stale job ${id}`);
333
+ }
334
+ }
335
+ }, rateLimitGuard.config.gcIntervalMs);
336
+ /**
337
+ * Cache of CopilotClient instances keyed by GitHub token.
338
+ * The empty-string key holds the server-wide (env-based) client.
339
+ * Entries are evicted after MAX_CLIENT_AGE_MS of inactivity.
340
+ */
341
+ const clientCache = new Map();
342
+ const MAX_CLIENT_AGE_MS = 30 * 60 * 1000; // 30 min
343
+ /** Periodically purge stale clients */
344
+ setInterval(() => {
345
+ const now = Date.now();
346
+ for (const [key, entry] of clientCache) {
347
+ if (now - entry.lastUsed > MAX_CLIENT_AGE_MS) {
348
+ try {
349
+ entry.client.stop();
350
+ }
351
+ catch { /* ignore */ }
352
+ clientCache.delete(key);
353
+ }
354
+ }
355
+ }, 5 * 60 * 1000); // every 5 min
356
+ /**
357
+ * Get or create a CopilotClient for the given token.
358
+ * If `userToken` is provided it takes priority; otherwise
359
+ * the server-wide GITHUB_TOKEN env var is used.
360
+ *
361
+ * Exported so the summarization layer in response-utils.ts can
362
+ * reuse the same cached Copilot client instances.
363
+ */
364
+ export async function getCopilotClient(userToken) {
365
+ const cacheKey = userToken || '';
366
+ const existing = clientCache.get(cacheKey);
367
+ if (existing) {
368
+ existing.lastUsed = Date.now();
369
+ return existing.client;
370
+ }
371
+ const clientOpts = {};
372
+ // Allow configuring Copilot CLI path
373
+ if (process.env.COPILOT_CLI_PATH) {
374
+ clientOpts.cliPath = process.env.COPILOT_CLI_PATH;
375
+ }
376
+ // Allow connecting to external CLI server
377
+ if (process.env.COPILOT_CLI_URL) {
378
+ clientOpts.cliUrl = process.env.COPILOT_CLI_URL;
379
+ }
380
+ // Per-user token takes priority, then env var
381
+ const token = userToken || process.env.GITHUB_TOKEN;
382
+ if (token) {
383
+ clientOpts.githubToken = token;
384
+ }
385
+ const client = new CopilotClient(clientOpts);
386
+ clientCache.set(cacheKey, { client, lastUsed: Date.now() });
387
+ return client;
388
+ }
389
+ /**
390
+ * Start a new agent job and wait for it to complete.
391
+ * Returns the final result (or error) directly — no polling needed.
392
+ *
393
+ * Rate limiting is enforced before the job is accepted:
394
+ * 1. Per-user and global concurrency limits
395
+ * 2. Sliding-window request rate
396
+ * 3. Proactive Copilot quota check
397
+ */
398
+ export async function startAgent(request, scrapingClient, origin, config, copilotToken) {
399
+ const jobId = uuidv4();
400
+ const userKey = copilotToken || '__server__';
401
+ // --- Rate limit gate ---
402
+ const gate = rateLimitGuard.check(userKey);
403
+ if (!gate.allowed) {
404
+ return {
405
+ id: jobId,
406
+ status: 'rate_limited',
407
+ rateLimited: true,
408
+ retryAfterSeconds: gate.retryAfterSeconds,
409
+ error: gate.reason,
410
+ };
411
+ }
412
+ // Validate model
413
+ const requestedModel = request.model || config.defaultModel;
414
+ if (!config.allowedModels.includes(requestedModel)) {
415
+ return {
416
+ id: jobId,
417
+ status: 'failed',
418
+ error: `Model "${requestedModel}" is not in the allowed list: ${config.allowedModels.join(', ')}`,
419
+ };
420
+ }
421
+ // --- Acquire concurrency slot & record request ---
422
+ rateLimitGuard.acquire(userKey);
423
+ // Create the job entry
424
+ const job = {
425
+ id: jobId,
426
+ status: 'processing',
427
+ prompt: request.prompt,
428
+ createdAt: Date.now(),
429
+ _userKey: userKey,
430
+ };
431
+ agentJobs.set(jobId, job);
432
+ // Run the agent synchronously — wait for completion
433
+ try {
434
+ await runAgentJob(job, request, scrapingClient, origin, config, requestedModel, copilotToken);
435
+ }
436
+ catch (err) {
437
+ job.status = 'failed';
438
+ job.error = String(err.message || err);
439
+ job.completedAt = Date.now();
440
+ }
441
+ finally {
442
+ // Always release the concurrency slot when the job finishes
443
+ rateLimitGuard.release(userKey);
444
+ }
445
+ return {
446
+ id: jobId,
447
+ status: job.status,
448
+ data: job.result || undefined,
449
+ error: job.error || undefined,
450
+ duration: job.completedAt
451
+ ? `${((job.completedAt - job.createdAt) / 1000).toFixed(1)}s`
452
+ : undefined,
453
+ };
454
+ }
455
+ /**
456
+ * Run an agent job using the Copilot SDK.
457
+ */
458
+ async function runAgentJob(job, request, scrapingClient, origin, config, model, copilotToken) {
459
+ try {
460
+ job.progress = 'Initializing Copilot SDK agent...';
461
+ const client = await getCopilotClient(copilotToken);
462
+ const tools = buildScrapingTools(scrapingClient, origin);
463
+ // Build session config
464
+ const sessionConfig = {
465
+ model,
466
+ tools,
467
+ // Disable all built-in tools - we only want our scorchcrawl tools
468
+ availableTools: tools.map((t) => t.name),
469
+ systemMessage: {
470
+ mode: 'replace',
471
+ content: buildSystemPrompt(request),
472
+ },
473
+ };
474
+ // Add BYOK provider if configured
475
+ if (config.provider) {
476
+ sessionConfig.provider = {
477
+ type: config.provider.type,
478
+ baseUrl: config.provider.baseUrl,
479
+ ...(config.provider.apiKey && { apiKey: config.provider.apiKey }),
480
+ };
481
+ }
482
+ job.progress = `Creating agent session with model: ${model}`;
483
+ const session = await client.createSession(sessionConfig);
484
+ // Register error hook for intelligent retry/abort decisions
485
+ try {
486
+ const errorHook = buildErrorHook(job.id);
487
+ if (typeof session.registerHooks === 'function') {
488
+ session.registerHooks({ onErrorOccurred: errorHook });
489
+ }
490
+ }
491
+ catch {
492
+ // Hooks may not be supported in all SDK versions — non-fatal
493
+ }
494
+ // Listen for quota snapshots from assistant.usage events
495
+ try {
496
+ const userKey = copilotToken || '__server__';
497
+ session.on?.('assistant.usage', (event) => {
498
+ const snapshots = event?.data?.quotaSnapshots || event?.quotaSnapshots;
499
+ if (snapshots) {
500
+ // Take the first available quota category (usually 'chat')
501
+ const snap = snapshots.chat || Object.values(snapshots)[0];
502
+ if (snap) {
503
+ rateLimitGuard.quota.update(userKey, {
504
+ remainingPercent: snap.remainingPercentage ?? snap.percent_remaining ?? 100,
505
+ usedRequests: snap.usedRequests ?? snap.remaining ?? 0,
506
+ entitlementRequests: snap.entitlementRequests ?? snap.entitlement ?? -1,
507
+ isUnlimited: snap.isUnlimitedEntitlement ?? snap.unlimited ?? false,
508
+ resetDate: snap.resetDate ?? undefined,
509
+ });
510
+ }
511
+ }
512
+ });
513
+ }
514
+ catch {
515
+ // Event subscription may not be supported — non-fatal
516
+ }
517
+ try {
518
+ job.progress = 'Agent is researching...';
519
+ // Build the user prompt
520
+ let userPrompt = request.prompt;
521
+ if (request.urls && request.urls.length > 0) {
522
+ userPrompt += `\n\nFocus on these URLs:\n${request.urls.map((u) => `- ${u}`).join('\n')}`;
523
+ }
524
+ if (request.schema) {
525
+ userPrompt += `\n\nReturn the results in this JSON schema format:\n${JSON.stringify(request.schema, null, 2)}`;
526
+ }
527
+ // Determine timeout: request-level > env var > SDK default (60s)
528
+ const timeoutMs = request.timeout
529
+ || (process.env.AGENT_TIMEOUT_MS ? parseInt(process.env.AGENT_TIMEOUT_MS, 10) : undefined)
530
+ || undefined; // undefined lets SDK use its own 60s default
531
+ // Send the prompt and wait for completion
532
+ const response = await session.sendAndWait({ prompt: userPrompt }, timeoutMs);
533
+ job.status = 'completed';
534
+ job.completedAt = Date.now();
535
+ job.result = {
536
+ success: true,
537
+ data: response?.data?.content || 'No response generated',
538
+ model,
539
+ };
540
+ }
541
+ finally {
542
+ // Clean up the session
543
+ try {
544
+ await session.destroy();
545
+ }
546
+ catch {
547
+ // Ignore cleanup errors
548
+ }
549
+ }
550
+ }
551
+ catch (err) {
552
+ job.status = 'failed';
553
+ job.error = `Agent error: ${err.message || err}`;
554
+ job.completedAt = Date.now();
555
+ }
556
+ }
557
+ /**
558
+ * Build the system prompt for the agent session.
559
+ */
560
+ function buildSystemPrompt(request) {
561
+ return `You are an autonomous web research agent. Your job is to browse the internet, search for information, navigate through pages, and extract structured data based on the user's query.
562
+
563
+ You have access to the following web tools:
564
+ - **web_search**: Search the web for information. Start with this for broad queries.
565
+ - **web_scrape**: Scrape content from a specific URL. Use this to get page content.
566
+ - **web_screenshot**: Take a screenshot of a page. The image is sent directly to you for vision analysis. Use this when scrape returns empty/blocked content.
567
+ - **web_map**: Map a website to discover all its URLs. Use this to find specific pages on a site.
568
+ - **web_extract**: Extract structured data from URLs using LLM. Use this for structured extraction.
569
+
570
+ ## Research Strategy
571
+
572
+ 1. **Start with search** to find relevant URLs if no specific URLs are provided.
573
+ 2. **Map websites** to discover relevant pages when you need to explore a site's structure.
574
+ 3. **Scrape specific pages** to get their content.
575
+ 4. **If scrape fails or returns empty/blocked content**, use **web_screenshot** to visually inspect the page and determine what's blocking (cookie popup, CAPTCHA, anti-bot, JS rendering issue).
576
+ 5. **Extract structured data** when you need specific fields from pages.
577
+
578
+ ## Visual Inspection Fallback
579
+
580
+ When web_scrape returns any of these, ALWAYS follow up with web_screenshot:
581
+ - Empty or very short content (< 100 characters)
582
+ - Anti-bot messages ("Checking your browser", "Access denied", "Just a moment")
583
+ - Cookie consent popup HTML instead of actual content
584
+ - CAPTCHA challenges
585
+ - Generic error pages
586
+
587
+ The screenshot helps you understand what the user would see in a browser, and report back what's actually on the page even when text extraction fails.
588
+
589
+ ## Guidelines
590
+
591
+ - Be thorough: check multiple sources when possible.
592
+ - Be efficient: don't scrape pages that aren't relevant.
593
+ - If a page fails to load or returns empty content, try an alternative approach (screenshot, different URL, waitFor parameter).
594
+ - Always provide your final answer with all the information you gathered.
595
+ - If the user provided a JSON schema, format your final response to match that schema.
596
+ - Cite your sources with URLs when providing information.
597
+ - When reporting screenshot results, describe what you observed on the page.
598
+
599
+ ## Important
600
+
601
+ - You MUST use the provided tools to gather information. Do not make up or hallucinate data.
602
+ - If you cannot find the requested information after reasonable effort, say so clearly.
603
+ - Provide complete, well-structured responses.`;
604
+ }
605
+ /**
606
+ * Gracefully shut down the Copilot client.
607
+ */
608
+ export async function shutdownAgent() {
609
+ rateLimitGuard.shutdown();
610
+ for (const [, entry] of clientCache) {
611
+ try {
612
+ await entry.client.stop();
613
+ }
614
+ catch {
615
+ // Ignore shutdown errors
616
+ }
617
+ }
618
+ clientCache.clear();
619
+ }