@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,357 @@
1
+ import { compact, hashToken, isTextContentType } from './utils.js';
2
+ import { retryWithBackoff } from './retry.js';
3
+ import { ResponseCache } from './cache.js';
4
+ /**
5
+ * Thrown when an API call references a profile that does not exist for the
6
+ * current API token. Tools catch this and re-throw as a UserError so the LLM
7
+ * sees a clean explanation instead of a downstream property-access crash on
8
+ * the 404 body shape `{ error: '...' }`.
9
+ */
10
+ export class ProfileNotFoundError extends Error {
11
+ profile;
12
+ constructor(profile, serverMessage) {
13
+ super(serverMessage ??
14
+ `Profile "${profile}" was not found for the configured token.`);
15
+ this.profile = profile;
16
+ this.name = 'ProfileNotFoundError';
17
+ }
18
+ }
19
+ /**
20
+ * If the response is a 404 from a profile-aware endpoint with a profile set,
21
+ * throw a typed ProfileNotFoundError so the caller can surface it as a
22
+ * UserError. We treat any 404 + profile as profile-not-found regardless of
23
+ * body shape — the error body varies (`error` / `message` / `detail` /
24
+ * malformed JSON) and the downstream `await res.json()` would crash anyway.
25
+ */
26
+ async function throwIfProfileMissing(res, profile) {
27
+ if (!profile || res.status !== 404)
28
+ return;
29
+ const body = await res
30
+ .clone()
31
+ .json()
32
+ .catch(() => null);
33
+ let serverMessage;
34
+ if (body && typeof body === 'object') {
35
+ const b = body;
36
+ const candidates = [
37
+ b.error,
38
+ b.message,
39
+ b.detail,
40
+ Array.isArray(b.errors) ? b.errors[0] : undefined,
41
+ ];
42
+ serverMessage = candidates.find((v) => typeof v === 'string');
43
+ }
44
+ throw new ProfileNotFoundError(profile, serverMessage);
45
+ }
46
+ const defaultShouldRetry = (error) => {
47
+ if (error instanceof ProfileNotFoundError)
48
+ return false;
49
+ return !error.message.startsWith('Server error 4');
50
+ };
51
+ async function defaultHandleResponse(res) {
52
+ if (!res.ok && res.status >= 500) {
53
+ throw new Error(`Server error ${res.status}: ${res.statusText}`);
54
+ }
55
+ if (!res.ok) {
56
+ const errorBody = await res.text().catch(() => res.statusText);
57
+ const message = errorBody.trim() || res.statusText;
58
+ throw new Error(`Server error ${res.status}: ${message}`);
59
+ }
60
+ return (await res.json());
61
+ }
62
+ function apiFetch(config, opts) {
63
+ const query = new URLSearchParams({ token: config.browserlessToken });
64
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
65
+ if (v !== undefined)
66
+ query.set(k, String(v));
67
+ }
68
+ const url = `${config.browserlessApiUrl}${opts.path}?${query.toString()}`;
69
+ const method = opts.method ?? 'POST';
70
+ const init = { method };
71
+ if (opts.body !== undefined) {
72
+ init.headers = { 'Content-Type': opts.contentType ?? 'application/json' };
73
+ init.body = JSON.stringify(opts.body);
74
+ }
75
+ const handle = opts.handleResponse ?? (defaultHandleResponse);
76
+ return retryWithBackoff(async () => {
77
+ const controller = new AbortController();
78
+ const timeoutId = setTimeout(() => controller.abort(), opts.timeout + 5000);
79
+ try {
80
+ const res = await fetch(url, { ...init, signal: controller.signal });
81
+ await throwIfProfileMissing(res, opts.profile);
82
+ return await handle(res);
83
+ }
84
+ finally {
85
+ clearTimeout(timeoutId);
86
+ }
87
+ }, {
88
+ maxRetries: opts.maxRetries ?? config.maxRetries,
89
+ baseDelayMs: 1000,
90
+ shouldRetry: opts.shouldRetry ?? defaultShouldRetry,
91
+ });
92
+ }
93
+ /** Read a Response as text or base64-encoded binary based on its content type. */
94
+ async function readGeneric(res) {
95
+ if (!res.ok && res.status >= 500) {
96
+ throw new Error(`Server error ${res.status}: ${res.statusText}`);
97
+ }
98
+ const respContentType = res.headers.get('content-type') ?? 'application/octet-stream';
99
+ const contentDisposition = res.headers.get('content-disposition') ?? null;
100
+ const isBinary = !isTextContentType(respContentType);
101
+ let data;
102
+ let size;
103
+ if (isBinary) {
104
+ const buf = Buffer.from(await res.arrayBuffer());
105
+ size = buf.byteLength;
106
+ data = buf.toString('base64');
107
+ }
108
+ else {
109
+ data = await res.text();
110
+ size = Buffer.byteLength(data, 'utf-8');
111
+ }
112
+ return {
113
+ data,
114
+ contentType: respContentType,
115
+ contentDisposition,
116
+ statusCode: res.status,
117
+ ok: res.ok,
118
+ size,
119
+ isBinary,
120
+ };
121
+ }
122
+ export function createApiClient(config, cache) {
123
+ const _cache = cache ?? new ResponseCache(config.cacheTtlMs);
124
+ return {
125
+ async smartScrape(params) {
126
+ const formats = params.formats ?? ['markdown'];
127
+ const tokenHash = hashToken(config.browserlessToken);
128
+ const cacheKey = JSON.stringify({
129
+ t: tokenHash,
130
+ // The api URL can be overridden per-session, so two backends sharing
131
+ // the same token must not share cache entries.
132
+ api: config.browserlessApiUrl,
133
+ url: params.url,
134
+ formats: [...formats].sort(),
135
+ // Profiles inject auth state — a cache hit across profiles would
136
+ // leak one user's session into another's response.
137
+ profile: params.profile ?? null,
138
+ });
139
+ const cached = _cache.get(cacheKey);
140
+ if (cached) {
141
+ return { ...cached, cacheHit: true };
142
+ }
143
+ const timeout = params.timeout ?? config.requestTimeout;
144
+ const result = await apiFetch(config, {
145
+ path: '/smart-scrape',
146
+ query: { timeout, profile: params.profile },
147
+ body: { url: params.url, formats },
148
+ timeout,
149
+ profile: params.profile,
150
+ });
151
+ _cache.set(cacheKey, result);
152
+ return { ...result, cacheHit: false };
153
+ },
154
+ async runFunction(params) {
155
+ const timeout = params.timeout ?? config.requestTimeout;
156
+ return apiFetch(config, {
157
+ path: '/function',
158
+ query: { timeout, profile: params.profile },
159
+ body: compact({ code: params.code, context: params.context }),
160
+ timeout,
161
+ profile: params.profile,
162
+ handleResponse: readGeneric,
163
+ });
164
+ },
165
+ async download(params) {
166
+ const timeout = params.timeout ?? config.requestTimeout;
167
+ return apiFetch(config, {
168
+ path: '/download',
169
+ query: { timeout, profile: params.profile },
170
+ body: compact({ code: params.code, context: params.context }),
171
+ timeout,
172
+ profile: params.profile,
173
+ handleResponse: readGeneric,
174
+ });
175
+ },
176
+ async exportPage(params) {
177
+ const timeout = params.timeout ?? config.requestTimeout;
178
+ return apiFetch(config, {
179
+ path: '/export',
180
+ query: { timeout, profile: params.profile },
181
+ body: compact({
182
+ url: params.url,
183
+ gotoOptions: params.gotoOptions,
184
+ bestAttempt: params.bestAttempt,
185
+ includeResources: params.includeResources,
186
+ waitForTimeout: params.waitForTimeout,
187
+ }),
188
+ timeout,
189
+ profile: params.profile,
190
+ handleResponse: readGeneric,
191
+ });
192
+ },
193
+ async getStatus() {
194
+ try {
195
+ const queryParams = new URLSearchParams({
196
+ token: config.browserlessToken,
197
+ });
198
+ const res = await fetch(`${config.browserlessApiUrl}/active?${queryParams.toString()}`);
199
+ if (res.ok) {
200
+ return { ok: true, message: 'Browserless API is reachable' };
201
+ }
202
+ return { ok: false, message: `API returned status ${res.status}` };
203
+ }
204
+ catch (err) {
205
+ return {
206
+ ok: false,
207
+ message: `Cannot reach API: ${err.message}`,
208
+ };
209
+ }
210
+ },
211
+ async search(params) {
212
+ const timeout = params.timeout ?? config.requestTimeout;
213
+ return apiFetch(config, {
214
+ path: '/search',
215
+ query: { timeout },
216
+ body: compact({
217
+ query: params.query,
218
+ limit: params.limit,
219
+ lang: params.lang,
220
+ country: params.country,
221
+ location: params.location,
222
+ tbs: params.tbs,
223
+ sources: params.sources,
224
+ categories: params.categories,
225
+ scrapeOptions: params.scrapeOptions,
226
+ }),
227
+ timeout,
228
+ });
229
+ },
230
+ async performance(params) {
231
+ const timeout = params.timeout ?? config.requestTimeout;
232
+ const body = { url: params.url };
233
+ if (params.categories) {
234
+ body.config = {
235
+ extends: 'lighthouse:default',
236
+ settings: { onlyCategories: params.categories },
237
+ };
238
+ }
239
+ if (params.budgets)
240
+ body.budgets = params.budgets;
241
+ return apiFetch(config, {
242
+ path: '/performance',
243
+ query: { timeout, profile: params.profile },
244
+ body,
245
+ timeout,
246
+ profile: params.profile,
247
+ });
248
+ },
249
+ async map(params) {
250
+ const timeout = params.timeout ?? config.requestTimeout;
251
+ return apiFetch(config, {
252
+ path: '/map',
253
+ query: { timeout },
254
+ body: compact({
255
+ url: params.url,
256
+ search: params.search,
257
+ limit: params.limit,
258
+ sitemap: params.sitemap,
259
+ includeSubdomains: params.includeSubdomains,
260
+ ignoreQueryParameters: params.ignoreQueryParameters,
261
+ }),
262
+ timeout,
263
+ });
264
+ },
265
+ async crawl(params) {
266
+ const timeout = params.timeout ?? config.requestTimeout;
267
+ return apiFetch(config, {
268
+ // /crawl accepts token + profile as query params; no `timeout` query.
269
+ path: '/crawl',
270
+ query: { profile: params.profile },
271
+ body: compact({
272
+ url: params.url,
273
+ limit: params.limit,
274
+ maxDepth: params.maxDepth,
275
+ maxRetries: params.maxRetries,
276
+ allowExternalLinks: params.allowExternalLinks,
277
+ allowSubdomains: params.allowSubdomains,
278
+ sitemap: params.sitemap,
279
+ includePaths: params.includePaths,
280
+ excludePaths: params.excludePaths,
281
+ delay: params.delay,
282
+ scrapeOptions: params.scrapeOptions,
283
+ }),
284
+ timeout,
285
+ profile: params.profile,
286
+ handleResponse: async (res) => {
287
+ if (!res.ok && res.status >= 500) {
288
+ throw new Error(`Server error ${res.status}: ${res.statusText}`);
289
+ }
290
+ // The /crawl endpoint returns a structured
291
+ // `{ success: false, error: string }` body on some 4xx responses
292
+ // (e.g. 429 rate limit). Forward those so the tool can surface
293
+ // them as a clean UserError. Non-JSON 4xx bodies surface as a
294
+ // Server error so retry suppression catches them.
295
+ if (!res.ok) {
296
+ const text = await res.text().catch(() => '');
297
+ try {
298
+ const parsed = JSON.parse(text);
299
+ if (parsed &&
300
+ typeof parsed === 'object' &&
301
+ parsed.success === false) {
302
+ return parsed;
303
+ }
304
+ }
305
+ catch {
306
+ // Non-JSON body — fall through.
307
+ }
308
+ const message = text.trim() || res.statusText;
309
+ throw new Error(`Server error ${res.status}: ${message}`);
310
+ }
311
+ return (await res.json());
312
+ },
313
+ });
314
+ },
315
+ async getCrawl(crawlId, skip) {
316
+ return apiFetch(config, {
317
+ path: `/crawl/${crawlId}`,
318
+ method: 'GET',
319
+ query: { skip: skip !== undefined && skip > 0 ? skip : undefined },
320
+ timeout: config.requestTimeout,
321
+ shouldRetry: (error) => !/^(Server|API) error 4/.test(error.message) &&
322
+ !error.message.toLowerCase().includes('not found'),
323
+ handleResponse: async (res) => {
324
+ if (res.status === 404)
325
+ throw new Error('Crawl not found');
326
+ if (!res.ok) {
327
+ const errorBody = await res.text().catch(() => res.statusText);
328
+ throw new Error(`API error ${res.status}: ${errorBody}`);
329
+ }
330
+ return (await res.json());
331
+ },
332
+ });
333
+ },
334
+ async cancelCrawl(crawlId) {
335
+ return apiFetch(config, {
336
+ path: `/crawl/${crawlId}`,
337
+ method: 'DELETE',
338
+ timeout: config.requestTimeout,
339
+ maxRetries: 0,
340
+ shouldRetry: () => false,
341
+ handleResponse: async (res) => {
342
+ if (res.status === 404)
343
+ throw new Error('Crawl not found');
344
+ if (res.status === 409) {
345
+ const body = (await res.json());
346
+ throw new Error(body.message ?? 'Crawl is already in terminal state');
347
+ }
348
+ if (!res.ok) {
349
+ const errorBody = await res.text().catch(() => res.statusText);
350
+ throw new Error(`API error ${res.status}: ${errorBody}`);
351
+ }
352
+ return (await res.json());
353
+ },
354
+ });
355
+ },
356
+ };
357
+ }
@@ -0,0 +1,22 @@
1
+ import type { EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
2
+ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
3
+ /**
4
+ * A bounded in-memory EventStore that caps the number of stored events
5
+ * to prevent unbounded memory growth. Evicts oldest entries when full.
6
+ *
7
+ * The default InMemoryEventStore from mcp-proxy never evicts events,
8
+ * causing a steady memory leak in long-running servers.
9
+ */
10
+ export declare class BoundedEventStore implements EventStore {
11
+ private events;
12
+ private lastTimestamp;
13
+ private lastTimestampCounter;
14
+ private readonly maxEvents;
15
+ constructor(maxEvents?: number);
16
+ storeEvent(streamId: string, message: JSONRPCMessage): Promise<string>;
17
+ replayEventsAfter(lastEventId: string, { send, }: {
18
+ send: (eventId: string, message: JSONRPCMessage) => Promise<void>;
19
+ }): Promise<string>;
20
+ private generateEventId;
21
+ private getStreamIdFromEventId;
22
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A bounded in-memory EventStore that caps the number of stored events
3
+ * to prevent unbounded memory growth. Evicts oldest entries when full.
4
+ *
5
+ * The default InMemoryEventStore from mcp-proxy never evicts events,
6
+ * causing a steady memory leak in long-running servers.
7
+ */
8
+ export class BoundedEventStore {
9
+ events = new Map();
10
+ lastTimestamp = 0;
11
+ lastTimestampCounter = 0;
12
+ maxEvents;
13
+ constructor(maxEvents = 10_000) {
14
+ this.maxEvents = maxEvents;
15
+ }
16
+ async storeEvent(streamId, message) {
17
+ const eventId = this.generateEventId(streamId);
18
+ this.events.set(eventId, { message, streamId });
19
+ // Evict oldest entries when over capacity
20
+ if (this.events.size > this.maxEvents) {
21
+ const keysToDelete = [...this.events.keys()].slice(0, this.events.size - this.maxEvents);
22
+ for (const key of keysToDelete) {
23
+ this.events.delete(key);
24
+ }
25
+ }
26
+ return eventId;
27
+ }
28
+ async replayEventsAfter(lastEventId, { send, }) {
29
+ if (!lastEventId || !this.events.has(lastEventId)) {
30
+ return '';
31
+ }
32
+ const streamId = this.getStreamIdFromEventId(lastEventId);
33
+ if (!streamId) {
34
+ return '';
35
+ }
36
+ let foundLastEvent = false;
37
+ // Snapshot to avoid absorbing events added concurrently during async send()
38
+ const snapshot = [...this.events.entries()];
39
+ for (const [eventId, event] of snapshot) {
40
+ if (event.streamId !== streamId)
41
+ continue;
42
+ if (!foundLastEvent) {
43
+ if (eventId === lastEventId)
44
+ foundLastEvent = true;
45
+ continue;
46
+ }
47
+ await send(eventId, event.message);
48
+ }
49
+ return streamId;
50
+ }
51
+ generateEventId(streamId) {
52
+ const now = Date.now();
53
+ if (now === this.lastTimestamp) {
54
+ this.lastTimestampCounter++;
55
+ }
56
+ else {
57
+ this.lastTimestamp = now;
58
+ this.lastTimestampCounter = 0;
59
+ }
60
+ const random = Math.random().toString(36).slice(2, 8);
61
+ return `${streamId}_${now}_${this.lastTimestampCounter}_${random}`;
62
+ }
63
+ getStreamIdFromEventId(eventId) {
64
+ // Event ID format: {streamId}_{timestamp}_{counter}_{random}
65
+ // streamId itself may contain underscores, so take everything except the last 3 segments
66
+ const parts = eventId.split('_');
67
+ return parts.length >= 4 ? parts.slice(0, -3).join('_') : undefined;
68
+ }
69
+ }
@@ -0,0 +1,12 @@
1
+ export declare class ResponseCache {
2
+ private store;
3
+ private readonly ttlMs;
4
+ private sweepTimer;
5
+ constructor(ttlMs: number);
6
+ private sweep;
7
+ get<T>(key: string): T | undefined;
8
+ set<T>(key: string, value: T): void;
9
+ clear(): void;
10
+ dispose(): void;
11
+ get size(): number;
12
+ }
@@ -0,0 +1,49 @@
1
+ export class ResponseCache {
2
+ store = new Map();
3
+ ttlMs;
4
+ sweepTimer;
5
+ constructor(ttlMs) {
6
+ this.ttlMs = ttlMs;
7
+ if (ttlMs > 0) {
8
+ this.sweepTimer = setInterval(() => this.sweep(), ttlMs * 2);
9
+ this.sweepTimer.unref();
10
+ }
11
+ }
12
+ sweep() {
13
+ const now = Date.now();
14
+ for (const [key, entry] of this.store) {
15
+ if (now > entry.expiresAt) {
16
+ this.store.delete(key);
17
+ }
18
+ }
19
+ }
20
+ get(key) {
21
+ const entry = this.store.get(key);
22
+ if (!entry)
23
+ return undefined;
24
+ if (Date.now() > entry.expiresAt) {
25
+ this.store.delete(key);
26
+ return undefined;
27
+ }
28
+ return entry.value;
29
+ }
30
+ set(key, value) {
31
+ this.store.set(key, {
32
+ value,
33
+ expiresAt: Date.now() + this.ttlMs,
34
+ });
35
+ }
36
+ clear() {
37
+ this.store.clear();
38
+ }
39
+ dispose() {
40
+ if (this.sweepTimer) {
41
+ clearInterval(this.sweepTimer);
42
+ this.sweepTimer = undefined;
43
+ }
44
+ this.store.clear();
45
+ }
46
+ get size() {
47
+ return this.store.size;
48
+ }
49
+ }
@@ -0,0 +1,71 @@
1
+ import { FastMCP } from 'fastmcp';
2
+ import type { Content } from 'fastmcp';
3
+ import { type ZodType } from 'zod';
4
+ import { ResponseCache } from './cache.js';
5
+ import { AnalyticsHelper } from './analytics.js';
6
+ import type { ApiClient, McpConfig } from '../@types/types.js';
7
+ /**
8
+ * Minimal log surface tools use. Tools only call the level methods with a
9
+ * string today, so the extra `data` param FastMCP's Logger accepts is just
10
+ * dropped here. Optional extra params on the source remain assignable.
11
+ */
12
+ interface ToolLog {
13
+ debug(message: string): void;
14
+ error(message: string): void;
15
+ info(message: string): void;
16
+ warn(message: string): void;
17
+ }
18
+ interface ToolAnnotations {
19
+ title?: string;
20
+ readOnlyHint?: boolean;
21
+ destructiveHint?: boolean;
22
+ idempotentHint?: boolean;
23
+ openWorldHint?: boolean;
24
+ streamingHint?: boolean;
25
+ }
26
+ export interface ToolRunContext<P> {
27
+ client: ApiClient;
28
+ params: P;
29
+ log: ToolLog;
30
+ /** For tools that fire analytics from inside their own logic (e.g. crawl polling). */
31
+ analytics?: AnalyticsHelper;
32
+ token: string;
33
+ apiUrl: string;
34
+ reportProgress: (progress: {
35
+ progress: number;
36
+ total: number;
37
+ }) => Promise<void>;
38
+ /** MCP session id (httpStream transport) or undefined for stdio — used by agent tool. */
39
+ sessionId: string | undefined;
40
+ }
41
+ export interface ToolDefinition<P, R> {
42
+ name: string;
43
+ description: string;
44
+ parameters: ZodType<P>;
45
+ annotations?: ToolAnnotations;
46
+ /** Throw UserError if any URL in params is invalid. Runs before progress 0. */
47
+ validateUrl?: (params: P) => void;
48
+ /** Override the default ProfileNotFoundError → UserError message. */
49
+ profileNotFoundMessage?: (profile: string) => string;
50
+ /**
51
+ * Persistent ResponseCache shared across executions of this tool. Pass
52
+ * a cache here if the tool relies on caching (e.g. smartScrape) — without
53
+ * it createApiClient builds a fresh per-execution cache.
54
+ */
55
+ cache?: ResponseCache;
56
+ /** Main tool logic. Returns the value `format` will render. */
57
+ run: (ctx: ToolRunContext<P>) => Promise<R>;
58
+ /** Render the result into MCP content blocks. May throw UserError. */
59
+ format: (result: R, params: P) => Content[];
60
+ /**
61
+ * Extra analytics properties beyond the base `{ token, tool, api_url }`.
62
+ * Fired AFTER `run` completes and BEFORE `format` runs — so analytics
63
+ * still fire if `format` throws (e.g. on `!response.ok`). Omit when the
64
+ * tool fires its own intra-execution events.
65
+ */
66
+ analyticsProps?: (params: P, result: R) => Record<string, unknown>;
67
+ }
68
+ /** Throw a UserError if `url` is not an http/https URL. */
69
+ export declare function validateHttpUrl(url: string): void;
70
+ export declare function defineTool<P, R>(server: FastMCP, config: McpConfig, analytics: AnalyticsHelper | undefined, def: ToolDefinition<P, R>): void;
71
+ export {};
@@ -0,0 +1,71 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { createApiClient, ProfileNotFoundError } from './api-client.js';
3
+ const defaultProfileMessage = (profile) => `Profile "${profile}" was not found for the configured API token. ` +
4
+ `Create the profile with Browserless.saveProfile in a live session first, ` +
5
+ `or omit the profile parameter.`;
6
+ /** Throw a UserError if `url` is not an http/https URL. */
7
+ export function validateHttpUrl(url) {
8
+ const urlObj = new URL(url);
9
+ if (!['http:', 'https:'].includes(urlObj.protocol)) {
10
+ throw new UserError(`Invalid URL protocol "${urlObj.protocol}". Only http and https are supported.`);
11
+ }
12
+ }
13
+ export function defineTool(server, config, analytics, def) {
14
+ server.addTool({
15
+ name: def.name,
16
+ description: def.description,
17
+ parameters: def.parameters,
18
+ annotations: def.annotations,
19
+ execute: async (args, { reportProgress, session, sessionId, log }) => {
20
+ const params = args;
21
+ // Single localized cast — FastMCP types session as Record<string, unknown>
22
+ // for the unconstrained generic. Tools see the typed session via this helper
23
+ // and never cast token/apiUrl themselves.
24
+ const s = session;
25
+ const token = s?.token ?? config.browserlessToken;
26
+ if (!token) {
27
+ throw new UserError('No Browserless API token provided. ' +
28
+ 'For stdio: set the BROWSERLESS_TOKEN environment variable. ' +
29
+ 'For HTTP: pass Authorization: Bearer <token> header.');
30
+ }
31
+ const apiUrl = s?.apiUrl ?? config.browserlessApiUrl;
32
+ def.validateUrl?.(params);
33
+ await reportProgress({ progress: 0, total: 100 });
34
+ const client = createApiClient({
35
+ ...config,
36
+ browserlessToken: token,
37
+ browserlessApiUrl: apiUrl,
38
+ }, def.cache);
39
+ let result;
40
+ try {
41
+ result = await def.run({
42
+ client,
43
+ params,
44
+ log,
45
+ analytics,
46
+ token,
47
+ apiUrl,
48
+ reportProgress,
49
+ sessionId,
50
+ });
51
+ }
52
+ catch (err) {
53
+ if (err instanceof ProfileNotFoundError) {
54
+ const msg = def.profileNotFoundMessage
55
+ ? def.profileNotFoundMessage(err.profile)
56
+ : defaultProfileMessage(err.profile);
57
+ throw new UserError(msg);
58
+ }
59
+ throw err;
60
+ }
61
+ await reportProgress({ progress: 100, total: 100 });
62
+ if (analytics && def.analyticsProps) {
63
+ analytics.fireToolRequest(token, def.name, {
64
+ api_url: apiUrl,
65
+ ...def.analyticsProps(params, result),
66
+ });
67
+ }
68
+ return { content: def.format(result, params) };
69
+ },
70
+ });
71
+ }
@@ -0,0 +1,4 @@
1
+ import type { ClassifiedError, ClassifyInput } from '../@types/types.js';
2
+ export type { ErrorCategory, ClassifiedError, ClassifyInput, } from '../@types/types.js';
3
+ export declare const classifyAgentError: (input: ClassifyInput) => ClassifiedError;
4
+ export declare const formatClassifiedError: (classified: ClassifiedError, bodyLines: string[]) => string;