@browserless.io/mcp 1.6.2 → 1.7.1
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.
- package/README.md +13 -12
- package/build/src/@types/types.d.ts +27 -3
- package/build/src/index.js +21 -29
- package/build/src/lib/agent-client.d.ts +5 -4
- package/build/src/lib/agent-client.js +87 -16
- package/build/src/lib/agent-format.d.ts +1 -1
- package/build/src/lib/agent-format.js +22 -4
- package/build/src/lib/define-tool.d.ts +5 -0
- package/build/src/lib/define-tool.js +1 -0
- package/build/src/lib/download-store.d.ts +17 -0
- package/build/src/lib/download-store.js +84 -0
- package/build/src/lib/http-auth.d.ts +22 -0
- package/build/src/lib/http-auth.js +33 -0
- package/build/src/resources/download-route.d.ts +16 -0
- package/build/src/resources/download-route.js +53 -0
- package/build/src/resources/upload-route.d.ts +3 -0
- package/build/src/resources/upload-route.js +53 -0
- package/build/src/skills/auth-profile.md +66 -0
- package/build/src/skills/autonomous-login.md +44 -43
- package/build/src/skills/file-transfers.md +88 -0
- package/build/src/skills/index.js +19 -0
- package/build/src/skills/shadow-dom.md +10 -1
- package/build/src/skills/system-prompt.d.ts +3 -2
- package/build/src/skills/system-prompt.js +32 -2
- package/build/src/tools/agent.d.ts +23 -0
- package/build/src/tools/agent.js +212 -30
- package/build/src/tools/map.js +1 -1
- package/build/src/tools/schemas.d.ts +79 -0
- package/build/src/tools/schemas.js +126 -3
- package/build/src/tools/smartscraper.js +4 -3
- package/package.json +5 -3
- package/build/src/tools/download.d.ts +0 -11
- package/build/src/tools/download.js +0 -92
package/README.md
CHANGED
|
@@ -22,18 +22,17 @@ No local install — see [Configuration](#configuration) for per-client snippets
|
|
|
22
22
|
|
|
23
23
|
## Tools
|
|
24
24
|
|
|
25
|
-
| Tool | Description
|
|
26
|
-
| -------------------------- |
|
|
27
|
-
| `browserless_smartscraper` | Scrape
|
|
28
|
-
| `browserless_search` | Search the web using Browserless and optionally scrape each result. Supports web, news, and image search with geo-targeting and time filters.
|
|
29
|
-
| `browserless_map` | Discover and map all URLs on a website.
|
|
30
|
-
| `browserless_crawl` | Crawl a website and scrape every discovered page. Supports depth control, path filtering, sitemap strategies, and configurable scrape options. Returns scraped content and metadata for each page.
|
|
31
|
-
| `browserless_performance` | Run Lighthouse audits on any URL. Returns scores and metrics for accessibility, best practices, performance, PWA, and SEO. Optionally filter by category or supply performance budgets.
|
|
32
|
-
| `browserless_function` | Execute custom Puppeteer JavaScript on the Browserless cloud. The function receives a `page` object and optional `context`; return `{ data, type }` to control the payload and Content-Type.
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `browserless_skill` | Load an on-demand recipe for a non-trivial page mechanic (shadow DOM, cookie consent, modals, captchas, dynamic content, snapshot misses, screenshots, tabs). Companion to `browserless_agent`. |
|
|
25
|
+
| Tool | Description |
|
|
26
|
+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
27
|
+
| `browserless_smartscraper` | Scrape a single webpage and return its content as markdown or HTML. Handles JavaScript-heavy pages and anti-bot measures automatically. For content across multiple pages, use `browserless_crawl`; to list a site's URLs, use `browserless_map`. |
|
|
28
|
+
| `browserless_search` | Search the web using Browserless and optionally scrape each result. Supports web, news, and image search with geo-targeting and time filters. |
|
|
29
|
+
| `browserless_map` | Discover and map all URLs on a website. Scans via sitemaps and link extraction. Returns URLs with optional titles and descriptions. Useful for site audits and content discovery. |
|
|
30
|
+
| `browserless_crawl` | Crawl a website and scrape every discovered page. Supports depth control, path filtering, sitemap strategies, and configurable scrape options. Returns scraped content and metadata for each page. |
|
|
31
|
+
| `browserless_performance` | Run Lighthouse audits on any URL. Returns scores and metrics for accessibility, best practices, performance, PWA, and SEO. Optionally filter by category or supply performance budgets. |
|
|
32
|
+
| `browserless_function` | Execute custom Puppeteer JavaScript on the Browserless cloud. The function receives a `page` object and optional `context`; return `{ data, type }` to control the payload and Content-Type. |
|
|
33
|
+
| `browserless_export` | Export a webpage via the Browserless `/export` API. Fetches the URL and returns its native content (HTML, PDF, image, etc.) with automatic content-type detection. |
|
|
34
|
+
| `browserless_agent` | Drive a persistent browser session via a ReAct loop: snapshot the page, plan, batch interactions (click, type, scroll, evaluate, etc.), and re-snapshot. Uses ref-based selectors derived from snapshots, supports multi-tab workflows, screenshots, captcha solving, live URLs, and file upload/download (captured downloads auto-surface as handles; bytes never enter context). |
|
|
35
|
+
| `browserless_skill` | Load an on-demand recipe for a non-trivial page mechanic (shadow DOM, cookie consent, modals, captchas, dynamic content, snapshot misses, screenshots, tabs). Companion to `browserless_agent`. |
|
|
37
36
|
|
|
38
37
|
## Skills
|
|
39
38
|
|
|
@@ -103,6 +102,8 @@ The `proxy` object is read once at session creation. To change it, call `close`
|
|
|
103
102
|
|
|
104
103
|
The server is hosted at `https://mcp.browserless.io/mcp`. Authenticate via headers (preferred) or a `?token=` query parameter.
|
|
105
104
|
|
|
105
|
+
Installing via an AI agent? See [install.md](install.md) for agent-readable setup instructions.
|
|
106
|
+
|
|
106
107
|
**Using headers** (recommended for clients that support them):
|
|
107
108
|
|
|
108
109
|
```json
|
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
SmartScraperResponseSchema,
|
|
7
7
|
} from '../tools/smartscraper.js';
|
|
8
8
|
import type { FunctionParamsSchema } from '../tools/function.js';
|
|
9
|
-
import type { DownloadParamsSchema } from '../tools/download.js';
|
|
10
9
|
import type { ExportParamsSchema } from '../tools/export.js';
|
|
11
10
|
import type {
|
|
12
11
|
SearchSourceSchema,
|
|
@@ -27,6 +26,7 @@ import type {
|
|
|
27
26
|
CrawlParamsSchema,
|
|
28
27
|
} from '../tools/crawl.js';
|
|
29
28
|
import type { AgentParamsSchema } from '../tools/agent.js';
|
|
29
|
+
import type { CreateProfileParams } from '../tools/schemas.js';
|
|
30
30
|
import type { ProxyOptionsSchema } from '../lib/agent-client.js';
|
|
31
31
|
|
|
32
32
|
/* ------------------------------------------------------------------ */
|
|
@@ -36,6 +36,13 @@ import type { ProxyOptionsSchema } from '../lib/agent-client.js';
|
|
|
36
36
|
export interface BrowserlessSession extends Record<string, unknown> {
|
|
37
37
|
token: string;
|
|
38
38
|
apiUrl: string;
|
|
39
|
+
/**
|
|
40
|
+
* A pre-created browser session id to ATTACH to (via /chromium/agent?sessionId),
|
|
41
|
+
* threaded by the caller through the `x-browserless-session-id` header. Used by
|
|
42
|
+
* the autologin runner, which does POST /profile itself and hands the agent the
|
|
43
|
+
* resulting id instead of letting the model open a `createProfile` session.
|
|
44
|
+
*/
|
|
45
|
+
attachSessionId?: string;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
export interface SupabaseJwtPayload {
|
|
@@ -133,6 +140,7 @@ export interface SnapshotElement {
|
|
|
133
140
|
focused?: boolean;
|
|
134
141
|
required?: boolean;
|
|
135
142
|
ariaLabel?: string;
|
|
143
|
+
frameId?: string;
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
export interface TabInfo {
|
|
@@ -142,6 +150,13 @@ export interface TabInfo {
|
|
|
142
150
|
active: boolean;
|
|
143
151
|
}
|
|
144
152
|
|
|
153
|
+
// for iframe handling
|
|
154
|
+
export interface FrameInfo {
|
|
155
|
+
frameId: string;
|
|
156
|
+
url: string;
|
|
157
|
+
crossOrigin: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
export interface SnapshotResult {
|
|
146
161
|
url: string;
|
|
147
162
|
title: string;
|
|
@@ -150,6 +165,7 @@ export interface SnapshotResult {
|
|
|
150
165
|
tabs?: TabInfo[];
|
|
151
166
|
activeTargetId?: string | null;
|
|
152
167
|
detectedChallenges?: string[];
|
|
168
|
+
frames?: FrameInfo[];
|
|
153
169
|
}
|
|
154
170
|
|
|
155
171
|
export interface ActiveSession {
|
|
@@ -161,6 +177,13 @@ export interface ActiveSession {
|
|
|
161
177
|
readonly token: string;
|
|
162
178
|
readonly proxy?: ProxyOptions;
|
|
163
179
|
readonly profile?: string;
|
|
180
|
+
// When set, this session was opened in profile-creation mode: the WS is bound
|
|
181
|
+
// to a creation session from POST /profile rather than a fresh launch. Feeds
|
|
182
|
+
// the session-cache key (see getSessionKey), so it's readonly.
|
|
183
|
+
readonly createProfile?: CreateProfileParams;
|
|
184
|
+
// The creation session id returned by POST /profile. Reconnects attach to it
|
|
185
|
+
// via /chromium/agent?sessionId rather than launching a new browser.
|
|
186
|
+
creationSessionId?: string;
|
|
164
187
|
reconnecting?: Promise<WebSocket>;
|
|
165
188
|
skillState: SkillFireState;
|
|
166
189
|
lastUsedAt: number;
|
|
@@ -208,7 +231,9 @@ export type SkillId =
|
|
|
208
231
|
| 'dynamic-content'
|
|
209
232
|
| 'screenshots'
|
|
210
233
|
| 'tabs'
|
|
211
|
-
| 'autonomous-login'
|
|
234
|
+
| 'autonomous-login'
|
|
235
|
+
| 'auth-profile'
|
|
236
|
+
| 'file-transfers';
|
|
212
237
|
|
|
213
238
|
export interface DetectContext {
|
|
214
239
|
snapshot?: SnapshotResult;
|
|
@@ -269,7 +294,6 @@ export type ScrapeFormat = z.infer<typeof ScrapeFormatSchema>;
|
|
|
269
294
|
export type SmartScraperParams = z.infer<typeof SmartScraperParamsSchema>;
|
|
270
295
|
export type SmartScraperResponse = z.infer<typeof SmartScraperResponseSchema>;
|
|
271
296
|
export type FunctionParams = z.infer<typeof FunctionParamsSchema>;
|
|
272
|
-
export type DownloadParams = z.infer<typeof DownloadParamsSchema>;
|
|
273
297
|
export type ExportParams = z.infer<typeof ExportParamsSchema>;
|
|
274
298
|
export type ProxyOptions = z.infer<typeof ProxyOptionsSchema>;
|
|
275
299
|
export type SearchSource = z.infer<typeof SearchSourceSchema>;
|
package/build/src/index.js
CHANGED
|
@@ -6,7 +6,6 @@ import { OAuthProxy } from 'fastmcp/auth';
|
|
|
6
6
|
import { getConfig } from './config.js';
|
|
7
7
|
import { registerSmartScraperTool } from './tools/smartscraper.js';
|
|
8
8
|
import { registerFunctionTool } from './tools/function.js';
|
|
9
|
-
import { registerDownloadTool } from './tools/download.js';
|
|
10
9
|
import { registerExportTool } from './tools/export.js';
|
|
11
10
|
import { registerAgentTools } from './tools/agent.js';
|
|
12
11
|
import { registerSearchTool } from './tools/search.js';
|
|
@@ -15,10 +14,14 @@ import { registerCrawlTool } from './tools/crawl.js';
|
|
|
15
14
|
import { registerPerformanceTool } from './tools/performance.js';
|
|
16
15
|
import { registerApiDocsResource } from './resources/api-docs.js';
|
|
17
16
|
import { registerStatusResource } from './resources/status.js';
|
|
17
|
+
import { registerUploadRoute } from './resources/upload-route.js';
|
|
18
|
+
import { registerDownloadRoute } from './resources/download-route.js';
|
|
19
|
+
import { clearSession } from './lib/download-store.js';
|
|
18
20
|
import { registerScrapeUrlPrompt } from './prompts/scrape-url.js';
|
|
19
21
|
import { registerExtractContentPrompt } from './prompts/extract-content.js';
|
|
20
22
|
import { AnalyticsHelper } from './lib/analytics.js';
|
|
21
|
-
import {
|
|
23
|
+
import { installSupabaseTokenTtlPatch } from './lib/account-resolver.js';
|
|
24
|
+
import { resolveBrowserlessAuth } from './lib/http-auth.js';
|
|
22
25
|
import { BoundedEventStore } from './lib/bounded-event-store.js';
|
|
23
26
|
import { RedisOAuthProxy } from './lib/redis-oauth-proxy.js';
|
|
24
27
|
import { Redis } from 'ioredis';
|
|
@@ -78,32 +81,14 @@ const oauthProvider = config.oauthEnabled && config.transport === 'httpStream'
|
|
|
78
81
|
const hybridAuthenticate = config.transport === 'httpStream'
|
|
79
82
|
? async (request) => {
|
|
80
83
|
const params = new URLSearchParams(request.url?.split('?')[1] ?? '');
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const isJwt = headerToken ? headerToken.split('.').length === 3 : false;
|
|
90
|
-
// 1. Authorization header with plain API key
|
|
91
|
-
if (headerToken && !isJwt) {
|
|
92
|
-
return { token: headerToken, apiUrl };
|
|
93
|
-
}
|
|
94
|
-
// 2. ?token= query param
|
|
95
|
-
const directToken = params.get('token') || undefined;
|
|
96
|
-
if (directToken) {
|
|
97
|
-
return { token: directToken, apiUrl };
|
|
98
|
-
}
|
|
99
|
-
// 3. Authorization header with JWT → decode Supabase token directly
|
|
100
|
-
if (isJwt && headerToken) {
|
|
101
|
-
const { apiKey } = await resolveApiKey(config.supabaseUrl, config.supabaseServiceRoleKey, headerToken);
|
|
102
|
-
return { token: apiKey, apiUrl };
|
|
103
|
-
}
|
|
104
|
-
throw new Error('No Browserless API token provided. ' +
|
|
105
|
-
'Pass it as Authorization: Bearer <token> header, ' +
|
|
106
|
-
'?token= query parameter, or authenticate via OAuth.');
|
|
84
|
+
return (await resolveBrowserlessAuth({
|
|
85
|
+
authHeader: request.headers.authorization,
|
|
86
|
+
tokenQuery: params.get('token') || undefined,
|
|
87
|
+
apiUrlHeader: request.headers['x-browserless-api-url'],
|
|
88
|
+
browserlessUrlQuery: params.get('browserlessUrl') || undefined,
|
|
89
|
+
sessionIdHeader: request.headers['x-browserless-session-id'],
|
|
90
|
+
sessionIdQuery: params.get('browserlessSessionId') || undefined,
|
|
91
|
+
}, config));
|
|
107
92
|
}
|
|
108
93
|
: undefined;
|
|
109
94
|
const server = new FastMCP({
|
|
@@ -114,7 +99,6 @@ const server = new FastMCP({
|
|
|
114
99
|
});
|
|
115
100
|
registerSmartScraperTool(server, config, analytics);
|
|
116
101
|
registerFunctionTool(server, config, analytics);
|
|
117
|
-
registerDownloadTool(server, config, analytics);
|
|
118
102
|
registerExportTool(server, config, analytics);
|
|
119
103
|
registerAgentTools(server, config, analytics);
|
|
120
104
|
registerSearchTool(server, config, analytics);
|
|
@@ -131,6 +115,8 @@ server.on('connect', (event) => {
|
|
|
131
115
|
});
|
|
132
116
|
server.on('disconnect', (event) => {
|
|
133
117
|
const id = event.session.sessionId ?? 'stdio';
|
|
118
|
+
// Drop any files staged/captured for this session (TTL is the backstop).
|
|
119
|
+
clearSession(event.session.sessionId);
|
|
134
120
|
console.error(`[browserless-mcp] Client disconnected: ${id}`);
|
|
135
121
|
});
|
|
136
122
|
if (config.transport === 'httpStream') {
|
|
@@ -143,6 +129,12 @@ if (config.transport === 'httpStream') {
|
|
|
143
129
|
stateless: false,
|
|
144
130
|
},
|
|
145
131
|
});
|
|
132
|
+
// Out-of-band file staging for uploads (the LLM curls a file here and gets a
|
|
133
|
+
// handle, instead of base64-ing it through the conversation). httpStream only.
|
|
134
|
+
registerUploadRoute(server, config);
|
|
135
|
+
// Single-use, out-of-band fetch for captured downloads (the LLM GETs the file
|
|
136
|
+
// instead of pulling bytes through the conversation). httpStream only.
|
|
137
|
+
registerDownloadRoute(server, config);
|
|
146
138
|
console.error(`[browserless-mcp] HTTP Streamable server listening on port ${config.port}`);
|
|
147
139
|
}
|
|
148
140
|
else {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import type { CreateProfileParams } from '../tools/schemas.js';
|
|
2
3
|
import type { ActiveSession, AgentResponse, ProxyOptions } from '../@types/types.js';
|
|
3
4
|
export type { ProxyOptions, ActiveSession, AgentMessage, AgentResponse, AgentError, } from '../@types/types.js';
|
|
4
5
|
export declare const ProxyOptionsSchema: z.ZodObject<{
|
|
@@ -46,13 +47,13 @@ export declare const proxyFingerprint: (proxy?: ProxyOptions) => string;
|
|
|
46
47
|
* swap http(s)→ws(s), and append `token` plus proxy params. Boolean proxy
|
|
47
48
|
* flags follow the API's presence-only contract (set only when truthy).
|
|
48
49
|
*/
|
|
49
|
-
export declare const buildAgentWsUrl: (apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string) => string;
|
|
50
|
-
export declare const getOrCreateSession: (mcpSessionId: string | undefined, apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string) => Promise<ActiveSession>;
|
|
50
|
+
export declare const buildAgentWsUrl: (apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string, sessionId?: string) => string;
|
|
51
|
+
export declare const getOrCreateSession: (mcpSessionId: string | undefined, apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string, createProfile?: CreateProfileParams, attachSessionId?: string) => Promise<ActiveSession>;
|
|
51
52
|
export declare const send: (session: ActiveSession, method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<AgentResponse>;
|
|
52
|
-
export declare const closeSession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string) => void;
|
|
53
|
+
export declare const closeSession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string, createProfile?: CreateProfileParams, attachSessionId?: string) => void;
|
|
53
54
|
/**
|
|
54
55
|
* Force-destroy a session after a browser crash or unrecoverable state, so
|
|
55
56
|
* the next call reconnects fresh. Unlike `closeSession`, it also drops any
|
|
56
57
|
* in-flight connect for the key so a concurrent caller can't reuse a dead WS.
|
|
57
58
|
*/
|
|
58
|
-
export declare const destroySession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string) => void;
|
|
59
|
+
export declare const destroySession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string, createProfile?: CreateProfileParams, attachSessionId?: string) => void;
|
|
@@ -99,11 +99,16 @@ export class ProfileNotFoundError extends UpgradeError {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
// Upgrade statuses where a one-shot retry cannot help: bad request (400),
|
|
102
|
-
// bad auth (401), forbidden by plan/policy (403),
|
|
103
|
-
//
|
|
104
|
-
|
|
102
|
+
// bad auth (401), forbidden by plan/policy (403), missing resource (404), or
|
|
103
|
+
// concurrency limit (429). Retrying a 429 just opens another session and
|
|
104
|
+
// stacks more lingering sessions against the same limit, so stop instead.
|
|
105
|
+
const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404, 429]);
|
|
105
106
|
export const isRetryableUpgradeError = (err) => {
|
|
106
107
|
if (err instanceof UpgradeError) {
|
|
108
|
+
// A 2xx UpgradeError is a structurally-bad success response — retrying
|
|
109
|
+
// can't fix the shape (and may duplicate side effects), so don't.
|
|
110
|
+
if (err.statusCode >= 200 && err.statusCode < 300)
|
|
111
|
+
return false;
|
|
107
112
|
return !NON_RETRYABLE_UPGRADE_STATUSES.has(err.statusCode);
|
|
108
113
|
}
|
|
109
114
|
return true;
|
|
@@ -172,18 +177,26 @@ export const proxyFingerprint = (proxy) => {
|
|
|
172
177
|
// Hash the profile rather than serialize it raw: like externalProxyServer,
|
|
173
178
|
// the eviction-logged session key may otherwise leak a user-identifying
|
|
174
179
|
// profile name. Hashing keeps per-profile distinctness without that leak.
|
|
175
|
-
const getSessionKey = (mcpSessionId, token, proxy, profile) => (mcpSessionId ?? `stdio:${hashToken(token)}`) +
|
|
180
|
+
const getSessionKey = (mcpSessionId, token, proxy, profile, createProfile, attachSessionId) => (mcpSessionId ?? `stdio:${hashToken(token)}`) +
|
|
176
181
|
proxyFingerprint(proxy) +
|
|
177
|
-
(profile ? KEY_SEP + 'profile#' + hashToken(profile) : '')
|
|
182
|
+
(profile ? KEY_SEP + 'profile#' + hashToken(profile) : '') +
|
|
183
|
+
(createProfile ? KEY_SEP + 'create#' + hashToken(createProfile.name) : '') +
|
|
184
|
+
(attachSessionId ? KEY_SEP + 'attach#' + attachSessionId : '');
|
|
178
185
|
/**
|
|
179
186
|
* Build the WebSocket URL for `/chromium/agent`: normalize trailing slashes,
|
|
180
187
|
* swap http(s)→ws(s), and append `token` plus proxy params. Boolean proxy
|
|
181
188
|
* flags follow the API's presence-only contract (set only when truthy).
|
|
182
189
|
*/
|
|
183
|
-
export const buildAgentWsUrl = (apiUrl, token, proxy, profile) => {
|
|
190
|
+
export const buildAgentWsUrl = (apiUrl, token, proxy, profile, sessionId) => {
|
|
184
191
|
const base = apiUrl.replace(/^http/i, 'ws').replace(/\/+$/, '');
|
|
185
192
|
const url = new URL(base + '/chromium/agent');
|
|
186
193
|
url.searchParams.set('token', token);
|
|
194
|
+
// A creation session already owns its proxy/profile (baked in at POST /profile);
|
|
195
|
+
// the WS only needs to attach to it by id, so proxy/profile params are skipped.
|
|
196
|
+
if (sessionId) {
|
|
197
|
+
url.searchParams.set('sessionId', sessionId);
|
|
198
|
+
return url.toString();
|
|
199
|
+
}
|
|
187
200
|
if (proxy?.proxy)
|
|
188
201
|
url.searchParams.set('proxy', proxy.proxy);
|
|
189
202
|
if (proxy?.proxyCountry)
|
|
@@ -322,8 +335,50 @@ const readUpgradeError = (res, profile) => new Promise((resolve) => {
|
|
|
322
335
|
// `res.destroy()` can fire 'close' without 'end' or 'error'; settle here too.
|
|
323
336
|
res.on('close', finish);
|
|
324
337
|
});
|
|
325
|
-
|
|
326
|
-
|
|
338
|
+
// POST /profile launches a non-headless browser, which can take several seconds.
|
|
339
|
+
const CREATE_PROFILE_TIMEOUT_MS = 60_000;
|
|
340
|
+
/**
|
|
341
|
+
* Open a profile-creation session via POST /profile. Returns the tracked
|
|
342
|
+
* session id the agent WS then attaches to with `?sessionId`. Non-2xx responses
|
|
343
|
+
* throw UpgradeError so the tool layer's retry/4xx classification applies
|
|
344
|
+
* uniformly with the WS-upgrade path.
|
|
345
|
+
*/
|
|
346
|
+
const postCreateProfile = async (apiUrl, token, createProfile) => {
|
|
347
|
+
const base = apiUrl.replace(/\/+$/, '');
|
|
348
|
+
const url = new URL(base + '/profile');
|
|
349
|
+
url.searchParams.set('token', token);
|
|
350
|
+
const controller = new AbortController();
|
|
351
|
+
const timer = setTimeout(() => controller.abort(), CREATE_PROFILE_TIMEOUT_MS);
|
|
352
|
+
let res;
|
|
353
|
+
try {
|
|
354
|
+
res = await fetch(url.toString(), {
|
|
355
|
+
method: 'POST',
|
|
356
|
+
headers: { 'Content-Type': 'application/json' },
|
|
357
|
+
body: JSON.stringify(createProfile),
|
|
358
|
+
signal: controller.signal,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
throw new Error(`POST /profile failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
}
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
const body = await res.text().catch(() => '');
|
|
369
|
+
throw new UpgradeError(res.status, res.statusText, body);
|
|
370
|
+
}
|
|
371
|
+
const json = await res.json();
|
|
372
|
+
if (typeof json !== 'object' ||
|
|
373
|
+
json === null ||
|
|
374
|
+
typeof json.id !== 'string' ||
|
|
375
|
+
!json.id) {
|
|
376
|
+
throw new UpgradeError(res.status, res.statusText, `POST /profile returned a malformed response (missing or invalid "id")`);
|
|
377
|
+
}
|
|
378
|
+
return json;
|
|
379
|
+
};
|
|
380
|
+
const connect = (apiUrl, token, proxy, profile, sessionId) => new Promise((resolve, reject) => {
|
|
381
|
+
const wsUrl = buildAgentWsUrl(apiUrl, token, proxy, profile, sessionId);
|
|
327
382
|
const ws = new WebSocket(wsUrl);
|
|
328
383
|
let settled = false;
|
|
329
384
|
const settle = (err, value) => {
|
|
@@ -411,9 +466,9 @@ const sendMessage = (ws, msg, timeoutMs = DEFAULT_TIMEOUT) => new Promise((resol
|
|
|
411
466
|
ws.on('close', closeHandler);
|
|
412
467
|
ws.send(JSON.stringify(msg));
|
|
413
468
|
});
|
|
414
|
-
export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, profile) => {
|
|
469
|
+
export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, profile, createProfile, attachSessionId) => {
|
|
415
470
|
sweepSessions();
|
|
416
|
-
const key = getSessionKey(mcpSessionId, token, proxy, profile);
|
|
471
|
+
const key = getSessionKey(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
|
|
417
472
|
const existing = sessions.get(key);
|
|
418
473
|
if (existing && existing.ws.readyState === WebSocket.OPEN) {
|
|
419
474
|
existing.lastUsedAt = Date.now();
|
|
@@ -434,7 +489,19 @@ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, pro
|
|
|
434
489
|
sessions.delete(key);
|
|
435
490
|
}
|
|
436
491
|
const creation = (async () => {
|
|
437
|
-
|
|
492
|
+
// Three modes for the session to attach to:
|
|
493
|
+
// - attachSessionId: a session the caller already created (autologin
|
|
494
|
+
// runner did POST /profile itself) — attach by id, no POST here.
|
|
495
|
+
// - createProfile: open a tracked session via POST /profile, then attach.
|
|
496
|
+
// - neither: launch a fresh agent browser.
|
|
497
|
+
let creationSessionId;
|
|
498
|
+
if (attachSessionId) {
|
|
499
|
+
creationSessionId = attachSessionId;
|
|
500
|
+
}
|
|
501
|
+
else if (createProfile) {
|
|
502
|
+
creationSessionId = (await postCreateProfile(apiUrl, token, createProfile)).id;
|
|
503
|
+
}
|
|
504
|
+
const ws = await connect(apiUrl, token, proxy, profile, creationSessionId);
|
|
438
505
|
const session = {
|
|
439
506
|
ws,
|
|
440
507
|
msgId: 0,
|
|
@@ -442,6 +509,8 @@ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, pro
|
|
|
442
509
|
token,
|
|
443
510
|
proxy,
|
|
444
511
|
profile,
|
|
512
|
+
createProfile,
|
|
513
|
+
creationSessionId,
|
|
445
514
|
skillState: createSkillState(),
|
|
446
515
|
lastUsedAt: Date.now(),
|
|
447
516
|
};
|
|
@@ -473,7 +542,9 @@ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, pro
|
|
|
473
542
|
export const send = async (session, method, params = {}, timeoutMs) => {
|
|
474
543
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
475
544
|
if (!session.reconnecting) {
|
|
476
|
-
|
|
545
|
+
// A creation session must re-attach to the same browser by id — a fresh
|
|
546
|
+
// connect() would launch a new one and lose all auth progress.
|
|
547
|
+
session.reconnecting = connect(session.apiUrl, session.token, session.proxy, session.profile, session.creationSessionId).finally(() => {
|
|
477
548
|
session.reconnecting = undefined;
|
|
478
549
|
});
|
|
479
550
|
}
|
|
@@ -496,8 +567,8 @@ export const send = async (session, method, params = {}, timeoutMs) => {
|
|
|
496
567
|
session.lastUsedAt = Date.now();
|
|
497
568
|
return sendMessage(session.ws, { id: session.msgId, method, params }, timeoutMs);
|
|
498
569
|
};
|
|
499
|
-
export const closeSession = (mcpSessionId, token, proxy, profile) => {
|
|
500
|
-
const key = getSessionKey(mcpSessionId, token, proxy, profile);
|
|
570
|
+
export const closeSession = (mcpSessionId, token, proxy, profile, createProfile, attachSessionId) => {
|
|
571
|
+
const key = getSessionKey(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
|
|
501
572
|
const session = sessions.get(key);
|
|
502
573
|
if (session) {
|
|
503
574
|
try {
|
|
@@ -514,8 +585,8 @@ export const closeSession = (mcpSessionId, token, proxy, profile) => {
|
|
|
514
585
|
* the next call reconnects fresh. Unlike `closeSession`, it also drops any
|
|
515
586
|
* in-flight connect for the key so a concurrent caller can't reuse a dead WS.
|
|
516
587
|
*/
|
|
517
|
-
export const destroySession = (mcpSessionId, token, proxy, profile) => {
|
|
518
|
-
const key = getSessionKey(mcpSessionId, token, proxy, profile);
|
|
588
|
+
export const destroySession = (mcpSessionId, token, proxy, profile, createProfile, attachSessionId) => {
|
|
589
|
+
const key = getSessionKey(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
|
|
519
590
|
const session = sessions.get(key);
|
|
520
591
|
if (session) {
|
|
521
592
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SnapshotResult } from '../@types/types.js';
|
|
2
|
-
export type { SnapshotResult, SnapshotElement, TabInfo, } from '../@types/types.js';
|
|
2
|
+
export type { SnapshotResult, SnapshotElement, TabInfo, FrameInfo, } from '../@types/types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Build the cross-origin notice shown above a snapshot when the page changed
|
|
5
5
|
* origin (protocol + host + port) since the last snapshot. Returns '' when
|
|
@@ -84,7 +84,7 @@ export const formatConnectError = (err) => {
|
|
|
84
84
|
case 403:
|
|
85
85
|
return `Forbidden (403) — your plan does not include this feature${detail ? ` (server says: ${detail})` : ''}.`;
|
|
86
86
|
case 429:
|
|
87
|
-
return `Concurrency limit reached (429)${detail ? `: ${detail}` : ''}.
|
|
87
|
+
return `Concurrency limit reached (429)${detail ? `: ${detail}` : ''}. Stop retrying — each new attempt opens another session and stacks more against the limit. Close any sessions you still have open (call browserless_agent with method "close"), wait for in-flight sessions to finish, or upgrade the plan, then start over.`;
|
|
88
88
|
default: {
|
|
89
89
|
const fallback = detail || err.statusMessage || '';
|
|
90
90
|
return `Failed to connect to browser agent (HTTP ${err.statusCode})${fallback ? `: ${fallback}` : ''}.`;
|
|
@@ -98,10 +98,13 @@ export const formatConnectError = (err) => {
|
|
|
98
98
|
};
|
|
99
99
|
/**
|
|
100
100
|
* Format a single snapshot element as a compact one-liner:
|
|
101
|
-
* [ref] tag role "name" ref=selector value="…" (state)
|
|
101
|
+
* [ref] tag role "name" ref=selector value="…" (state) [frame#N]
|
|
102
102
|
* e.g. [7] input checkbox "Remember me" ref=input#remember (checked, required)
|
|
103
|
+
* `frameLabels` maps a frameId to its display label (frame#1, …); when an
|
|
104
|
+
* element carries a frameId, the label is appended so the agent sees which
|
|
105
|
+
* iframe it lives in.
|
|
103
106
|
*/
|
|
104
|
-
const formatElement = (el) => {
|
|
107
|
+
const formatElement = (el, frameLabels) => {
|
|
105
108
|
const parts = [`[${el.ref}]`, el.tag, el.role];
|
|
106
109
|
const name = el.name || el.text || '';
|
|
107
110
|
if (name)
|
|
@@ -125,6 +128,9 @@ const formatElement = (el) => {
|
|
|
125
128
|
flags.push('required');
|
|
126
129
|
if (flags.length)
|
|
127
130
|
parts.push(`(${flags.join(', ')})`);
|
|
131
|
+
const frameLabel = el.frameId && frameLabels?.get(el.frameId);
|
|
132
|
+
if (frameLabel)
|
|
133
|
+
parts.push(`[${frameLabel}]`);
|
|
128
134
|
return parts.join(' ');
|
|
129
135
|
};
|
|
130
136
|
export const formatSnapshot = (snapshot) => {
|
|
@@ -146,9 +152,21 @@ export const formatSnapshot = (snapshot) => {
|
|
|
146
152
|
lines.push(`! Detected challenge: ${type}`);
|
|
147
153
|
}
|
|
148
154
|
}
|
|
155
|
+
// Label cross-origin iframes (frame#1, …) and list them so the agent knows
|
|
156
|
+
// which elements live in a frame and that their deep-ref selectors pierce it.
|
|
157
|
+
const frameLabels = new Map();
|
|
158
|
+
if (snapshot.frames?.length) {
|
|
159
|
+
snapshot.frames.forEach((frame, i) => frameLabels.set(frame.frameId, `frame#${i + 1}`));
|
|
160
|
+
lines.push(`Frames (${snapshot.frames.length} iframes):`);
|
|
161
|
+
for (const frame of snapshot.frames) {
|
|
162
|
+
const origin = frame.crossOrigin ? 'cross-origin' : 'same-origin';
|
|
163
|
+
lines.push(` ${frameLabels.get(frame.frameId)} ${frame.url} (${origin})`);
|
|
164
|
+
}
|
|
165
|
+
lines.push('Elements tagged [frame#N] live in that iframe; their deep-ref selectors pierce it — pass as-is to click/type/hover.');
|
|
166
|
+
}
|
|
149
167
|
lines.push('');
|
|
150
168
|
for (const el of snapshot.elements) {
|
|
151
|
-
lines.push(formatElement(el));
|
|
169
|
+
lines.push(formatElement(el, frameLabels));
|
|
152
170
|
}
|
|
153
171
|
lines.push('--- END SNAPSHOT ---');
|
|
154
172
|
return lines.join('\n');
|
|
@@ -37,6 +37,11 @@ export interface ToolRunContext<P> {
|
|
|
37
37
|
}) => Promise<void>;
|
|
38
38
|
/** MCP session id (httpStream transport) or undefined for stdio — used by agent tool. */
|
|
39
39
|
sessionId: string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Pre-created browser session id to attach to (from the `x-browserless-session-id`
|
|
42
|
+
* header). When set, the agent tool attaches to it instead of opening its own.
|
|
43
|
+
*/
|
|
44
|
+
attachSessionId?: string;
|
|
40
45
|
}
|
|
41
46
|
export interface ToolDefinition<P, R> {
|
|
42
47
|
name: string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface StoredDownload {
|
|
2
|
+
id: string;
|
|
3
|
+
path: string;
|
|
4
|
+
filename: string;
|
|
5
|
+
mimeType: string;
|
|
6
|
+
size: number;
|
|
7
|
+
sessionId?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const DOWNLOAD_URI_SCHEME = "browserless-download";
|
|
10
|
+
export declare const FILE_TRANSFER_MAX_BYTES: number;
|
|
11
|
+
/** Build the handle URI for a stored download id. */
|
|
12
|
+
export declare const downloadUri: (id: string) => string;
|
|
13
|
+
export declare const storeDownload: (filename: string, mimeType: string, data: Buffer, sessionId?: string) => Promise<StoredDownload>;
|
|
14
|
+
export declare const getDownload: (handle: string) => StoredDownload | undefined;
|
|
15
|
+
export declare const consumeDownload: (handle: string) => StoredDownload | undefined;
|
|
16
|
+
/** Drop every file owned by an MCP session (called when the session ends). */
|
|
17
|
+
export declare const clearSession: (sessionId: string | undefined) => void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
export const DOWNLOAD_URI_SCHEME = 'browserless-download';
|
|
5
|
+
const TTL_MS = 15 * 60 * 1000;
|
|
6
|
+
// Hard ceiling on a single file transfer (mirrors the enterprise cap).
|
|
7
|
+
export const FILE_TRANSFER_MAX_BYTES = 50 * 1024 * 1024;
|
|
8
|
+
const store = new Map();
|
|
9
|
+
let counter = 0;
|
|
10
|
+
// Where captured files land on the MCP server. Defaults to a temp dir; override
|
|
11
|
+
// with BROWSERLESS_DOWNLOAD_DIR (e.g. a stable folder in local/stdio setups).
|
|
12
|
+
const downloadsDir = () => process.env.BROWSERLESS_DOWNLOAD_DIR ||
|
|
13
|
+
join(tmpdir(), 'browserless-mcp-downloads');
|
|
14
|
+
/** Build the handle URI for a stored download id. */
|
|
15
|
+
export const downloadUri = (id) => `${DOWNLOAD_URI_SCHEME}://${id}`;
|
|
16
|
+
const idFromHandle = (handle) => handle.startsWith(`${DOWNLOAD_URI_SCHEME}://`)
|
|
17
|
+
? handle.slice(`${DOWNLOAD_URI_SCHEME}://`.length)
|
|
18
|
+
: handle;
|
|
19
|
+
// Strip the internal timer handle before handing an entry to callers.
|
|
20
|
+
const toRecord = (entry) => {
|
|
21
|
+
const { timer: _timer, ...record } = entry;
|
|
22
|
+
return record;
|
|
23
|
+
};
|
|
24
|
+
const dropEntry = (entry) => {
|
|
25
|
+
if (entry.timer)
|
|
26
|
+
clearTimeout(entry.timer);
|
|
27
|
+
store.delete(entry.id);
|
|
28
|
+
void rm(entry.path, { force: true }).catch(() => { });
|
|
29
|
+
};
|
|
30
|
+
// Persist bytes to disk under a fresh handle. `sessionId` ties the file to an
|
|
31
|
+
// MCP session for cleanup on session end (no session → TTL only).
|
|
32
|
+
export const storeDownload = async (filename, mimeType, data, sessionId) => {
|
|
33
|
+
const dir = downloadsDir();
|
|
34
|
+
await mkdir(dir, { recursive: true });
|
|
35
|
+
counter += 1;
|
|
36
|
+
const id = `${Date.now().toString(36)}-${counter}`;
|
|
37
|
+
const safe = basename(filename) || 'download';
|
|
38
|
+
// Prefix with the id so files that share a name don't collide.
|
|
39
|
+
const path = join(dir, `${id}-${safe}`);
|
|
40
|
+
await writeFile(path, data);
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
store.delete(id);
|
|
43
|
+
void rm(path, { force: true }).catch(() => { });
|
|
44
|
+
}, TTL_MS);
|
|
45
|
+
timer.unref?.();
|
|
46
|
+
const entry = {
|
|
47
|
+
id,
|
|
48
|
+
path,
|
|
49
|
+
filename: safe,
|
|
50
|
+
mimeType,
|
|
51
|
+
size: data.byteLength,
|
|
52
|
+
sessionId,
|
|
53
|
+
timer,
|
|
54
|
+
};
|
|
55
|
+
store.set(id, entry);
|
|
56
|
+
return toRecord(entry);
|
|
57
|
+
};
|
|
58
|
+
// Resolve a handle (id, URI, or stored path) WITHOUT removing it. Used by
|
|
59
|
+
// uploadFile, which may reference the same file more than once.
|
|
60
|
+
export const getDownload = (handle) => {
|
|
61
|
+
const entry = store.get(idFromHandle(handle)) ??
|
|
62
|
+
[...store.values()].find((r) => r.path === handle);
|
|
63
|
+
return entry && toRecord(entry);
|
|
64
|
+
};
|
|
65
|
+
// Resolve a handle and remove it (single-use): entry, TTL timer, and bytes.
|
|
66
|
+
// Backs `GET /download/:id` so a download can only be fetched once.
|
|
67
|
+
export const consumeDownload = (handle) => {
|
|
68
|
+
const entry = store.get(idFromHandle(handle));
|
|
69
|
+
if (!entry)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (entry.timer)
|
|
72
|
+
clearTimeout(entry.timer);
|
|
73
|
+
store.delete(entry.id);
|
|
74
|
+
return toRecord(entry);
|
|
75
|
+
};
|
|
76
|
+
/** Drop every file owned by an MCP session (called when the session ends). */
|
|
77
|
+
export const clearSession = (sessionId) => {
|
|
78
|
+
if (!sessionId)
|
|
79
|
+
return;
|
|
80
|
+
for (const entry of [...store.values()]) {
|
|
81
|
+
if (entry.sessionId === sessionId)
|
|
82
|
+
dropEntry(entry);
|
|
83
|
+
}
|
|
84
|
+
};
|