@browserless.io/mcp 1.6.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 (81) hide show
  1. package/LICENSE +557 -0
  2. package/README.md +280 -0
  3. package/bin/cli.js +2 -0
  4. package/build/src/@types/types.d.ts +538 -0
  5. package/build/src/config.d.ts +3 -0
  6. package/build/src/config.js +42 -0
  7. package/build/src/index.d.ts +4 -0
  8. package/build/src/index.js +153 -0
  9. package/build/src/lib/account-resolver.d.ts +17 -0
  10. package/build/src/lib/account-resolver.js +78 -0
  11. package/build/src/lib/agent-client.d.ts +58 -0
  12. package/build/src/lib/agent-client.js +530 -0
  13. package/build/src/lib/agent-format.d.ts +35 -0
  14. package/build/src/lib/agent-format.js +155 -0
  15. package/build/src/lib/amplitude.d.ts +11 -0
  16. package/build/src/lib/amplitude.js +65 -0
  17. package/build/src/lib/analytics.d.ts +18 -0
  18. package/build/src/lib/analytics.js +79 -0
  19. package/build/src/lib/api-client.d.ts +17 -0
  20. package/build/src/lib/api-client.js +357 -0
  21. package/build/src/lib/bounded-event-store.d.ts +22 -0
  22. package/build/src/lib/bounded-event-store.js +69 -0
  23. package/build/src/lib/cache.d.ts +12 -0
  24. package/build/src/lib/cache.js +49 -0
  25. package/build/src/lib/define-tool.d.ts +71 -0
  26. package/build/src/lib/define-tool.js +71 -0
  27. package/build/src/lib/error-classifier.d.ts +4 -0
  28. package/build/src/lib/error-classifier.js +125 -0
  29. package/build/src/lib/redis-oauth-proxy.d.ts +13 -0
  30. package/build/src/lib/redis-oauth-proxy.js +214 -0
  31. package/build/src/lib/retry.d.ts +2 -0
  32. package/build/src/lib/retry.js +19 -0
  33. package/build/src/lib/schema-fields.d.ts +10 -0
  34. package/build/src/lib/schema-fields.js +27 -0
  35. package/build/src/lib/supabase-token-patch.d.ts +6 -0
  36. package/build/src/lib/supabase-token-patch.js +33 -0
  37. package/build/src/lib/utils.d.ts +27 -0
  38. package/build/src/lib/utils.js +67 -0
  39. package/build/src/prompts/extract-content.d.ts +2 -0
  40. package/build/src/prompts/extract-content.js +33 -0
  41. package/build/src/prompts/scrape-url.d.ts +2 -0
  42. package/build/src/prompts/scrape-url.js +36 -0
  43. package/build/src/resources/api-docs.d.ts +3 -0
  44. package/build/src/resources/api-docs.js +54 -0
  45. package/build/src/resources/status.d.ts +3 -0
  46. package/build/src/resources/status.js +30 -0
  47. package/build/src/skills/autonomous-login.md +95 -0
  48. package/build/src/skills/captchas.md +48 -0
  49. package/build/src/skills/cookie-consent.md +50 -0
  50. package/build/src/skills/dynamic-content.md +72 -0
  51. package/build/src/skills/index.d.ts +9 -0
  52. package/build/src/skills/index.js +221 -0
  53. package/build/src/skills/modals.md +56 -0
  54. package/build/src/skills/screenshots.md +53 -0
  55. package/build/src/skills/shadow-dom.md +64 -0
  56. package/build/src/skills/snapshot-misses.md +67 -0
  57. package/build/src/skills/system-prompt.d.ts +2 -0
  58. package/build/src/skills/system-prompt.js +128 -0
  59. package/build/src/skills/tabs.md +77 -0
  60. package/build/src/tools/agent.d.ts +15 -0
  61. package/build/src/tools/agent.js +299 -0
  62. package/build/src/tools/crawl.d.ts +75 -0
  63. package/build/src/tools/crawl.js +426 -0
  64. package/build/src/tools/download.d.ts +11 -0
  65. package/build/src/tools/download.js +92 -0
  66. package/build/src/tools/export.d.ts +28 -0
  67. package/build/src/tools/export.js +129 -0
  68. package/build/src/tools/function.d.ts +24 -0
  69. package/build/src/tools/function.js +144 -0
  70. package/build/src/tools/map.d.ts +23 -0
  71. package/build/src/tools/map.js +129 -0
  72. package/build/src/tools/performance.d.ts +25 -0
  73. package/build/src/tools/performance.js +103 -0
  74. package/build/src/tools/schemas.d.ts +466 -0
  75. package/build/src/tools/schemas.js +487 -0
  76. package/build/src/tools/search.d.ts +67 -0
  77. package/build/src/tools/search.js +184 -0
  78. package/build/src/tools/smartscraper.d.ts +42 -0
  79. package/build/src/tools/smartscraper.js +136 -0
  80. package/package.json +111 -0
  81. package/patches/mcp-proxy+6.4.0.patch +31 -0
@@ -0,0 +1,24 @@
1
+ import { FastMCP } from 'fastmcp';
2
+ import type { Content } from 'fastmcp';
3
+ import { z } from 'zod';
4
+ import { AnalyticsHelper } from '../lib/analytics.js';
5
+ import type { GenericApiResult, McpConfig } from '../@types/types.js';
6
+ export declare const FunctionParamsSchema: z.ZodObject<{
7
+ code: z.ZodString;
8
+ context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
9
+ timeout: z.ZodOptional<z.ZodNumber>;
10
+ profile: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strip>;
12
+ /**
13
+ * Hard cap for text responses. Larger payloads are rejected with a clear
14
+ * remediation message instead of silently torching context. ~4 chars/token →
15
+ * 200,000 chars ≈ 50,000 tokens.
16
+ */
17
+ export declare const MAX_TEXT_RESPONSE_CHARS = 200000;
18
+ /**
19
+ * Convert a /function HTTP response into MCP content blocks: image/* and
20
+ * audio/* become vision/audio input, other binary becomes a resource blob,
21
+ * and text becomes TextContent (throws past MAX_TEXT_RESPONSE_CHARS).
22
+ */
23
+ export declare const formatFunctionContent: (response: GenericApiResult) => Content[];
24
+ export declare function registerFunctionTool(server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper): void;
@@ -0,0 +1,144 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { defineTool } from '../lib/define-tool.js';
4
+ import { profileField } from './schemas.js';
5
+ export const FunctionParamsSchema = z.object({
6
+ code: z
7
+ .string()
8
+ .describe('JavaScript (ESM) code to execute. The default export receives ' +
9
+ '{ page, context } and should return { data, type } where data ' +
10
+ 'is the response payload and type is the Content-Type string.'),
11
+ context: z
12
+ .record(z.string(), z.unknown())
13
+ .optional()
14
+ .describe('Optional context object passed to the function as the second argument.'),
15
+ timeout: z
16
+ .number()
17
+ .int()
18
+ .positive()
19
+ .optional()
20
+ .describe('Request timeout in milliseconds'),
21
+ profile: profileField('before the function executes'),
22
+ });
23
+ /**
24
+ * Hard cap for text responses. Larger payloads are rejected with a clear
25
+ * remediation message instead of silently torching context. ~4 chars/token →
26
+ * 200,000 chars ≈ 50,000 tokens.
27
+ */
28
+ export const MAX_TEXT_RESPONSE_CHARS = 200_000;
29
+ const EXTENSION_BY_MIME = {
30
+ 'application/pdf': '.pdf',
31
+ 'application/zip': '.zip',
32
+ 'application/octet-stream': '.bin',
33
+ 'application/json': '.json',
34
+ };
35
+ const buildMetadata = (response) => [
36
+ '---',
37
+ `Content-Type: ${response.contentType}`,
38
+ `Status: ${response.statusCode}`,
39
+ `Size: ${response.size} bytes`,
40
+ '---',
41
+ ].join('\n');
42
+ /**
43
+ * Convert a /function HTTP response into MCP content blocks: image/* and
44
+ * audio/* become vision/audio input, other binary becomes a resource blob,
45
+ * and text becomes TextContent (throws past MAX_TEXT_RESPONSE_CHARS).
46
+ */
47
+ export const formatFunctionContent = (response) => {
48
+ const baseMime = response.contentType.split(';')[0].trim().toLowerCase();
49
+ const metadata = buildMetadata(response);
50
+ if (response.isBinary) {
51
+ if (baseMime.startsWith('image/')) {
52
+ return [
53
+ { type: 'text', text: metadata },
54
+ { type: 'image', data: response.data, mimeType: baseMime },
55
+ ];
56
+ }
57
+ if (baseMime.startsWith('audio/')) {
58
+ return [
59
+ { type: 'text', text: metadata },
60
+ { type: 'audio', data: response.data, mimeType: baseMime },
61
+ ];
62
+ }
63
+ const ext = EXTENSION_BY_MIME[baseMime] ?? '.bin';
64
+ return [
65
+ { type: 'text', text: metadata },
66
+ {
67
+ type: 'resource',
68
+ resource: {
69
+ uri: `browserless://function/result${ext}`,
70
+ mimeType: baseMime,
71
+ blob: response.data,
72
+ },
73
+ },
74
+ ];
75
+ }
76
+ if (response.data.length > MAX_TEXT_RESPONSE_CHARS) {
77
+ const approxTokens = Math.round(response.data.length / 4);
78
+ const capTokens = Math.round(MAX_TEXT_RESPONSE_CHARS / 4);
79
+ throw new UserError(`Function returned ${response.data.length} chars (~${approxTokens} tokens), ` +
80
+ `exceeding the ${MAX_TEXT_RESPONSE_CHARS}-char (~${capTokens}-token) limit. ` +
81
+ `Either filter/summarize inside your function, or — for binary outputs — ` +
82
+ `return { type: "image/jpeg" } / "image/png" / "audio/mpeg" / "application/pdf" ` +
83
+ `from your function so the bytes come back as a proper content block instead of base64 text.`);
84
+ }
85
+ return [
86
+ { type: 'text', text: response.data },
87
+ { type: 'text', text: metadata },
88
+ ];
89
+ };
90
+ export function registerFunctionTool(server, config, analytics) {
91
+ defineTool(server, config, analytics, {
92
+ name: 'browserless_function',
93
+ description: 'Execute custom Puppeteer JavaScript code on the Browserless cloud. ' +
94
+ 'Your function receives a Puppeteer `page` object and optional `context` data. ' +
95
+ 'Return { data, type } to control the response payload and Content-Type. ' +
96
+ '\n\n' +
97
+ 'For binary outputs, set `type` to a real MIME so the bytes come back as a proper ' +
98
+ 'content block instead of base64 text:\n' +
99
+ ' - `image/png` / `image/jpeg` / `image/webp` → vision content block (~1.5K tokens)\n' +
100
+ ' - `audio/mpeg` / `audio/wav` → audio content block\n' +
101
+ ' - `application/pdf` and other binaries → resource content block (attachment)\n' +
102
+ '\n' +
103
+ 'Text responses are capped at 200,000 characters (~50K tokens). Larger text payloads ' +
104
+ 'will be rejected — filter or summarize inside your function, or switch to a binary ' +
105
+ 'type if you actually meant to return bytes.\n' +
106
+ '\n' +
107
+ 'Useful for complex scraping, form filling, or any browser automation that requires custom code.',
108
+ parameters: FunctionParamsSchema,
109
+ annotations: {
110
+ title: 'Browserless Function',
111
+ readOnlyHint: false,
112
+ destructiveHint: true,
113
+ openWorldHint: true,
114
+ },
115
+ profileNotFoundMessage: (profile) => `Profile "${profile}" was not found for the configured API ` +
116
+ `token. Create the profile with Browserless.saveProfile in a ` +
117
+ `live session first, or omit the profile parameter to run the ` +
118
+ `function anonymously.`,
119
+ run: async ({ client, params, log }) => {
120
+ const response = await client.runFunction({
121
+ code: params.code,
122
+ context: params.context,
123
+ timeout: params.timeout,
124
+ profile: params.profile,
125
+ });
126
+ log.debug(`Function response: ok=${response.ok}, status=${response.statusCode}, ` +
127
+ `contentType=${response.contentType}, size=${response.size}`);
128
+ return response;
129
+ },
130
+ analyticsProps: (params, result) => ({
131
+ ok: result.ok,
132
+ status_code: result.statusCode,
133
+ content_type: result.contentType,
134
+ size: result.size,
135
+ profile_used: !!params.profile,
136
+ }),
137
+ format: (response) => {
138
+ if (!response.ok) {
139
+ throw new UserError(`Function execution failed (status ${response.statusCode}): ${response.data.slice(0, 500)}`);
140
+ }
141
+ return formatFunctionContent(response);
142
+ },
143
+ });
144
+ }
@@ -0,0 +1,23 @@
1
+ import { FastMCP } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { AnalyticsHelper } from '../lib/analytics.js';
4
+ import type { McpConfig } from '../@types/types.js';
5
+ export declare const SitemapModeSchema: z.ZodEnum<{
6
+ include: "include";
7
+ skip: "skip";
8
+ only: "only";
9
+ }>;
10
+ export declare const MapParamsSchema: z.ZodObject<{
11
+ url: z.ZodURL;
12
+ search: z.ZodOptional<z.ZodString>;
13
+ limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
14
+ sitemap: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
15
+ include: "include";
16
+ skip: "skip";
17
+ only: "only";
18
+ }>>>;
19
+ includeSubdomains: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
20
+ ignoreQueryParameters: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
21
+ timeout: z.ZodOptional<z.ZodNumber>;
22
+ }, z.core.$strip>;
23
+ export declare function registerMapTool(server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper): void;
@@ -0,0 +1,129 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { defineTool, validateHttpUrl } from '../lib/define-tool.js';
4
+ export const SitemapModeSchema = z.enum(['include', 'skip', 'only']);
5
+ export const MapParamsSchema = z.object({
6
+ url: z
7
+ .url()
8
+ .describe('The base URL to start mapping from (must be http or https)'),
9
+ search: z
10
+ .string()
11
+ .optional()
12
+ .describe('Search query to order results by relevance'),
13
+ limit: z
14
+ .number()
15
+ .int()
16
+ .positive()
17
+ .max(5000)
18
+ .optional()
19
+ .default(100)
20
+ .describe('Maximum number of links to return (default: 100, max: 5000)'),
21
+ sitemap: SitemapModeSchema.optional()
22
+ .default('include')
23
+ .describe('Sitemap handling: "include" (default), "skip", "only"'),
24
+ includeSubdomains: z
25
+ .boolean()
26
+ .optional()
27
+ .default(true)
28
+ .describe('Include URLs from subdomains (default: true)'),
29
+ ignoreQueryParameters: z
30
+ .boolean()
31
+ .optional()
32
+ .default(true)
33
+ .describe('Exclude URLs with query parameters (default: true)'),
34
+ timeout: z
35
+ .number()
36
+ .int()
37
+ .positive()
38
+ .optional()
39
+ .describe('Request timeout in milliseconds'),
40
+ });
41
+ export function registerMapTool(server, config, analytics) {
42
+ defineTool(server, config, analytics, {
43
+ name: 'browserless_map',
44
+ description: 'Discover and map all URLs on a website using Browserless. ' +
45
+ 'Crawls a site via sitemaps and link extraction to find all pages. ' +
46
+ 'Returns a list of URLs with optional titles and descriptions. ' +
47
+ 'Use the search parameter to order results by relevance to a query. ' +
48
+ 'Useful for site audits, content discovery, and building site maps.',
49
+ parameters: MapParamsSchema,
50
+ annotations: {
51
+ title: 'Browserless Map',
52
+ readOnlyHint: true,
53
+ destructiveHint: false,
54
+ openWorldHint: true,
55
+ },
56
+ validateUrl: (p) => validateHttpUrl(p.url),
57
+ run: async ({ client, params, log }) => {
58
+ const response = await client.map({
59
+ url: params.url,
60
+ search: params.search,
61
+ limit: params.limit,
62
+ sitemap: params.sitemap,
63
+ includeSubdomains: params.includeSubdomains,
64
+ ignoreQueryParameters: params.ignoreQueryParameters,
65
+ timeout: params.timeout,
66
+ });
67
+ if (!response.success) {
68
+ throw new UserError(`Map failed: ${response.error ?? 'Unknown error'}`);
69
+ }
70
+ log.debug(`Map response: success=${response.success}, links=${response.links?.length ?? 0}`);
71
+ return response;
72
+ },
73
+ analyticsProps: (params, result) => ({
74
+ url: params.url,
75
+ limit: params.limit ?? 100,
76
+ sitemap_mode: params.sitemap ?? 'include',
77
+ success: result.success,
78
+ links_found: result.links?.length ?? 0,
79
+ }),
80
+ format: (response, params) => {
81
+ const blocks = [];
82
+ if (response.links && response.links.length > 0) {
83
+ const linksText = response.links
84
+ .map((link, index) => {
85
+ let text = `${index + 1}. ${link.url}`;
86
+ if (link.title)
87
+ text += `\n Title: ${link.title}`;
88
+ if (link.description) {
89
+ const truncated = link.description.length > 200
90
+ ? `${link.description.slice(0, 200)}...`
91
+ : link.description;
92
+ text += `\n Description: ${truncated}`;
93
+ }
94
+ return text;
95
+ })
96
+ .join('\n\n');
97
+ blocks.push({
98
+ type: 'text',
99
+ text: `## Site Map Results (${response.links.length} URLs)\n\n${linksText}`,
100
+ });
101
+ const urlList = response.links.map((l) => l.url).join('\n');
102
+ blocks.push({
103
+ type: 'text',
104
+ text: `## URL List\n\n${urlList}`,
105
+ });
106
+ }
107
+ else {
108
+ blocks.push({
109
+ type: 'text',
110
+ text: `No URLs found for site: ${params.url}`,
111
+ });
112
+ }
113
+ blocks.push({
114
+ type: 'text',
115
+ text: [
116
+ '---',
117
+ `Base URL: ${params.url}`,
118
+ `URLs Found: ${response.links?.length ?? 0}`,
119
+ `Sitemap Mode: ${params.sitemap ?? 'include'}`,
120
+ params.search ? `Search Query: ${params.search}` : '',
121
+ '---',
122
+ ]
123
+ .filter(Boolean)
124
+ .join('\n'),
125
+ });
126
+ return blocks;
127
+ },
128
+ });
129
+ }
@@ -0,0 +1,25 @@
1
+ import { FastMCP } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { AnalyticsHelper } from '../lib/analytics.js';
4
+ import type { McpConfig } from '../@types/types.js';
5
+ export declare const LighthouseCategorySchema: z.ZodEnum<{
6
+ accessibility: "accessibility";
7
+ "best-practices": "best-practices";
8
+ performance: "performance";
9
+ pwa: "pwa";
10
+ seo: "seo";
11
+ }>;
12
+ export declare const PerformanceParamsSchema: z.ZodObject<{
13
+ url: z.ZodURL;
14
+ categories: z.ZodOptional<z.ZodArray<z.ZodEnum<{
15
+ accessibility: "accessibility";
16
+ "best-practices": "best-practices";
17
+ performance: "performance";
18
+ pwa: "pwa";
19
+ seo: "seo";
20
+ }>>>;
21
+ budgets: z.ZodOptional<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
22
+ timeout: z.ZodOptional<z.ZodNumber>;
23
+ profile: z.ZodOptional<z.ZodString>;
24
+ }, z.core.$strip>;
25
+ export declare function registerPerformanceTool(server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper): void;
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod';
2
+ import { defineTool, validateHttpUrl } from '../lib/define-tool.js';
3
+ import { profileField } from './schemas.js';
4
+ export const LighthouseCategorySchema = z.enum([
5
+ 'accessibility',
6
+ 'best-practices',
7
+ 'performance',
8
+ 'pwa',
9
+ 'seo',
10
+ ]);
11
+ export const PerformanceParamsSchema = z.object({
12
+ url: z.url().describe('The URL to audit (must be http or https)'),
13
+ categories: z
14
+ .array(LighthouseCategorySchema)
15
+ .optional()
16
+ .describe('Lighthouse categories to audit: "accessibility", "best-practices", ' +
17
+ '"performance", "pwa", "seo". Omit for all categories.'),
18
+ budgets: z
19
+ .array(z.record(z.string(), z.unknown()))
20
+ .optional()
21
+ .describe('Lighthouse performance budgets array. ' +
22
+ 'See https://developer.chrome.com/docs/lighthouse/performance/performance-budgets'),
23
+ timeout: z
24
+ .number()
25
+ .int()
26
+ .positive()
27
+ .optional()
28
+ .describe('Request timeout in milliseconds (audits can take 30s–120s)'),
29
+ profile: profileField('before the Lighthouse audit runs'),
30
+ });
31
+ export function registerPerformanceTool(server, config, analytics) {
32
+ defineTool(server, config, analytics, {
33
+ name: 'browserless_performance',
34
+ description: 'Run a Lighthouse performance audit on any URL via the Browserless /performance API. ' +
35
+ 'Returns scores and metrics for accessibility, best practices, performance, PWA, and SEO. ' +
36
+ 'Optionally filter by category or supply performance budgets. ' +
37
+ 'Note: audits can take 30s–120s depending on the site.',
38
+ parameters: PerformanceParamsSchema,
39
+ annotations: {
40
+ title: 'Browserless Lighthouse Performance Audit',
41
+ readOnlyHint: true,
42
+ destructiveHint: false,
43
+ openWorldHint: true,
44
+ },
45
+ validateUrl: (p) => validateHttpUrl(p.url),
46
+ profileNotFoundMessage: (profile) => `Profile "${profile}" was not found for the configured API ` +
47
+ `token. Create the profile with Browserless.saveProfile in a ` +
48
+ `live session first, or omit the profile parameter to audit ` +
49
+ `the page anonymously.`,
50
+ run: async ({ client, params, log }) => {
51
+ const response = await client.performance({
52
+ url: params.url,
53
+ categories: params.categories,
54
+ budgets: params.budgets,
55
+ timeout: params.timeout,
56
+ profile: params.profile,
57
+ });
58
+ log.debug(`Performance response: type=${response.type}, ` +
59
+ `dataKeys=${Object.keys(response.data ?? {}).length}`);
60
+ return response;
61
+ },
62
+ analyticsProps: (params) => ({
63
+ url: params.url,
64
+ categories: (params.categories ?? []).join(','),
65
+ profile_used: !!params.profile,
66
+ }),
67
+ format: (response, params) => {
68
+ const blocks = [];
69
+ const data = response.data ?? {};
70
+ const categories = (data.categories ?? {});
71
+ const categoryEntries = Object.entries(categories);
72
+ if (categoryEntries.length > 0) {
73
+ const summary = categoryEntries
74
+ .map(([id, cat]) => {
75
+ const score = cat.score != null
76
+ ? `${Math.round(cat.score * 100)}/100`
77
+ : 'N/A';
78
+ return `- ${cat.title ?? id}: ${score}`;
79
+ })
80
+ .join('\n');
81
+ blocks.push({
82
+ type: 'text',
83
+ text: `## Lighthouse Scores\n${summary}`,
84
+ });
85
+ }
86
+ blocks.push({
87
+ type: 'text',
88
+ text: JSON.stringify(data, null, 2),
89
+ });
90
+ const meta = [
91
+ '---',
92
+ `URL: ${params.url}`,
93
+ `Lighthouse Version: ${data.lighthouseVersion ?? 'unknown'}`,
94
+ ];
95
+ if (params.categories) {
96
+ meta.push(`Categories: ${params.categories.join(', ')}`);
97
+ }
98
+ meta.push('---');
99
+ blocks.push({ type: 'text', text: meta.join('\n') });
100
+ return blocks;
101
+ },
102
+ });
103
+ }