Skip to content
Merged
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,16 @@ Then point your MCP client at `http://localhost:8080/mcp` using the same header/

### Self-hosted environment variables

| Variable | Required | Default | Description |
| ------------------------- | -------- | --------------------------------------- | -------------------------------------------------- |
| `BROWSERLESS_TOKEN` | Yes | — | Your Browserless API token |
| `BROWSERLESS_API_URL` | No | `https://production-sfo.browserless.io` | API endpoint (for self-hosted Browserless) |
| `TRANSPORT` | No | `stdio` | Transport type: `stdio` or `httpStream` |
| `PORT` | No | `8080` | HTTP server port (only for `httpStream` transport) |
| `BROWSERLESS_TIMEOUT` | No | `30000` | Request timeout in milliseconds |
| `BROWSERLESS_MAX_RETRIES` | No | `3` | Max retry attempts for failed requests |
| `BROWSERLESS_CACHE_TTL` | No | `60000` | Cache TTL in milliseconds (0 to disable) |
| Variable | Required | Default | Description |
| ------------------------- | -------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `BROWSERLESS_TOKEN` | Yes | — | Your Browserless API token |
| `BROWSERLESS_API_URL` | No | `https://production-sfo.browserless.io` | API endpoint (for self-hosted Browserless) |
| `TRANSPORT` | No | `stdio` | Transport type: `stdio` or `httpStream` |
| `PORT` | No | `8080` | HTTP server port (only for `httpStream` transport) |
| `BROWSERLESS_TIMEOUT` | No | `30000` | Request timeout in milliseconds |
| `BROWSERLESS_MAX_RETRIES` | No | `3` | Max retry attempts for failed requests |
| `BROWSERLESS_CACHE_TTL` | No | `60000` | Cache TTL in milliseconds (0 to disable) |
| `MCP_COMPLIANCE_MODE` | No | unset (full surface) | Serve the reduced, directory-compliant surface. Fails closed: any set value except `false`/`0`/`no`/`off` enables it |

## MCP Resources

Expand Down
8 changes: 8 additions & 0 deletions src/@types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export interface McpConfig {
maxRetries: number;
cacheTtlMs: number;
analyticsEnabled: boolean;
// Required (not optional): the compliant surface is a security gate, so every
// McpConfig must choose explicitly — an omitted field must not default to the
// fuller/prohibited surface.
// Named for the tool-surface policy it gates, deliberately NOT `webBotAuth`:
// Web Bot Auth (the IETF HTTP-Message-Signatures bot-identity scheme) is a
// separate, future mechanism — keeping the names distinct avoids a collision
// when it lands.
complianceMode: boolean;
Comment thread
Xrazik1 marked this conversation as resolved.
sqsQueueUrl?: string;
sqsRegion: string;
oauthEnabled: boolean;
Expand Down
25 changes: 25 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ const DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS = [
'https://eu1.make.celonis.com/oauth/cb/mcp', // Make.com Celonis-hosted (enterprise) — EU region
];

// Fail closed: any SET value except an explicit opt-out (false/0/no/off) enables
// the compliant surface, so a fumbled flag can't leak the full one to a directory.
const COMPLIANCE_OPT_OUT = new Set(['false', '0', 'no', 'off']);
const COMPLIANCE_OPT_IN = new Set(['true', '1', 'yes', 'on']);
function parseComplianceMode(raw: string | undefined): boolean {
if (raw === undefined) return false;
return !COMPLIANCE_OPT_OUT.has(raw.trim().toLowerCase());
}

// Classify the raw flag for boot logging: `unrecognized` still resolves to
// compliant (fail-closed) but is warned, so a typo isn't read as intentional opt-in.
export function classifyComplianceInput(
raw: string | undefined,
): 'unset' | 'opt-out' | 'opt-in' | 'unrecognized' {
if (raw === undefined) return 'unset';
const v = raw.trim().toLowerCase();
if (COMPLIANCE_OPT_OUT.has(v)) return 'opt-out';
if (COMPLIANCE_OPT_IN.has(v)) return 'opt-in';
return 'unrecognized';
}

export function getConfig(): McpConfig {
return {
browserlessToken: process.env.BROWSERLESS_TOKEN,
Expand All @@ -31,6 +52,10 @@ export function getConfig(): McpConfig {
maxRetries: parseInt(process.env.BROWSERLESS_MAX_RETRIES ?? '3', 10),
cacheTtlMs: parseInt(process.env.BROWSERLESS_CACHE_TTL ?? '60000', 10),
analyticsEnabled: process.env.ANALYTICS_ENABLED === 'true',
// Per-process toggle for the compliant surface used by the OpenAI/Anthropic
// directory listings: registers fewer tools and de-fangs the agent (see
// tools/compliance.ts). Fails closed — see parseComplianceMode.
complianceMode: parseComplianceMode(process.env.MCP_COMPLIANCE_MODE),
sqsQueueUrl: process.env.SQS_QUEUE_URL,
sqsRegion: process.env.SQS_REGION ?? 'us-west-2',
// OAuth (Supabase)
Expand Down
47 changes: 22 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,12 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FastMCP, OAuthProvider } from 'fastmcp';
import { OAuthProxy } from 'fastmcp/auth';
import { getConfig } from './config.js';
import { getConfig, classifyComplianceInput } from './config.js';
import type { BrowserlessSession } from './@types/types.js';
import { registerSmartScraperTool } from './tools/smartscraper.js';
import { registerFunctionTool } from './tools/function.js';
import { registerExportTool } from './tools/export.js';
import { registerAgentTools } from './tools/agent.js';
import { registerSearchTool } from './tools/search.js';
import { registerMapTool } from './tools/map.js';
import { registerCrawlTool } from './tools/crawl.js';
import { registerPerformanceTool } from './tools/performance.js';
import { registerApiDocsResource } from './resources/api-docs.js';
import { registerStatusResource } from './resources/status.js';
import { registerSurface } from './tools/register.js';
import { registerUploadRoute } from './resources/upload-route.js';
import { registerDownloadRoute } from './resources/download-route.js';
import { clearSession } from './lib/download-store.js';
import { registerScrapeUrlPrompt } from './prompts/scrape-url.js';
import { registerExtractContentPrompt } from './prompts/extract-content.js';
import { AnalyticsHelper } from './lib/analytics.js';
import { installSupabaseTokenTtlPatch } from './lib/account-resolver.js';
import { resolveBrowserlessAuth } from './lib/http-auth.js';
Expand Down Expand Up @@ -130,18 +119,26 @@ const server = new FastMCP<BrowserlessSession>({
authenticate: hybridAuthenticate,
});

registerSmartScraperTool(server, config, analytics);
registerFunctionTool(server, config, analytics);
registerExportTool(server, config, analytics);
registerAgentTools(server, config, analytics);
registerSearchTool(server, config, analytics);
registerMapTool(server, config, analytics);
registerCrawlTool(server, config, analytics);
registerPerformanceTool(server, config, analytics);
registerApiDocsResource(server, config);
registerStatusResource(server, config);
registerScrapeUrlPrompt(server);
registerExtractContentPrompt(server);
registerSurface(server, config, analytics);
// Log the active surface (both transports) so it's visible in the boot logs.
// Fail-closed value lands on compliant; distinguish "unset" (dropped/wrong-scoped
// on a directory deploy) from opt-out, and warn on an unrecognized value (typo).
const complianceInput = classifyComplianceInput(
process.env.MCP_COMPLIANCE_MODE,
);
if (complianceInput === 'unrecognized') {
console.error(
`[browserless-mcp] WARNING: MCP_COMPLIANCE_MODE="${process.env.MCP_COMPLIANCE_MODE}" ` +
'is not a recognized value; defaulting to the compliant (reduced) surface. ' +
'Set "true" for compliant or "false" for the full surface.',
);
}
const complianceSurface = config.complianceMode
? 'compliant (reduced)'
: complianceInput === 'unset'
? 'full (MCP_COMPLIANCE_MODE unset — set it to "true" for the compliant surface)'
: 'full (explicit opt-out)';
console.error(`[browserless-mcp] Tool surface: ${complianceSurface}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

server.on('connect', (event) => {
const id = event.session.sessionId ?? 'stdio';
Expand Down
51 changes: 37 additions & 14 deletions src/lib/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export const buildAgentWsUrl = (
proxy?: ProxyOptions,
profile?: string,
sessionId?: string,
compliant = false,
): string => {
const base = apiUrl.replace(/^http/i, 'ws').replace(/\/+$/, '');
const url = new URL(base + '/chromium/agent');
Expand All @@ -267,18 +268,24 @@ export const buildAgentWsUrl = (
url.searchParams.set('sessionId', sessionId);
return url.toString();
}
if (proxy?.proxy) url.searchParams.set('proxy', proxy.proxy);
if (proxy?.proxyCountry)
url.searchParams.set('proxyCountry', proxy.proxyCountry);
if (proxy?.proxyState) url.searchParams.set('proxyState', proxy.proxyState);
if (proxy?.proxyCity) url.searchParams.set('proxyCity', proxy.proxyCity);
if (proxy?.proxySticky) url.searchParams.set('proxySticky', 'true');
if (proxy?.proxyLocaleMatch) url.searchParams.set('proxyLocaleMatch', 'true');
if (proxy?.proxyPreset)
url.searchParams.set('proxyPreset', proxy.proxyPreset);
if (proxy?.externalProxyServer)
url.searchParams.set('externalProxyServer', proxy.externalProxyServer);
if (profile) url.searchParams.set('profile', profile);
// Compliant surface exposes no proxy/profile — the schema and run()-layer
// guards already reject them, but hard-drop here too so no caller path can
// put them on the wire (last line of defense before the upstream connect).
if (!compliant) {
if (proxy?.proxy) url.searchParams.set('proxy', proxy.proxy);
if (proxy?.proxyCountry)
url.searchParams.set('proxyCountry', proxy.proxyCountry);
if (proxy?.proxyState) url.searchParams.set('proxyState', proxy.proxyState);
if (proxy?.proxyCity) url.searchParams.set('proxyCity', proxy.proxyCity);
if (proxy?.proxySticky) url.searchParams.set('proxySticky', 'true');
if (proxy?.proxyLocaleMatch)
url.searchParams.set('proxyLocaleMatch', 'true');
if (proxy?.proxyPreset)
url.searchParams.set('proxyPreset', proxy.proxyPreset);
if (proxy?.externalProxyServer)
url.searchParams.set('externalProxyServer', proxy.externalProxyServer);
if (profile) url.searchParams.set('profile', profile);
}
return url.toString();
};

Expand Down Expand Up @@ -478,9 +485,17 @@ const connect = (
proxy?: ProxyOptions,
profile?: string,
sessionId?: string,
compliant = false,
): Promise<WebSocket> =>
new Promise((resolve, reject) => {
const wsUrl = buildAgentWsUrl(apiUrl, token, proxy, profile, sessionId);
const wsUrl = buildAgentWsUrl(
apiUrl,
token,
proxy,
profile,
sessionId,
compliant,
);
const ws = new WebSocket(wsUrl);
let settled = false;

Expand Down Expand Up @@ -605,6 +620,7 @@ export const getOrCreateSession = async (
profile?: string,
createProfile?: CreateProfileParams,
attachSessionId?: string,
compliant = false,
): Promise<ActiveSession> => {
sweepSessions();
const key = getSessionKey(
Expand Down Expand Up @@ -650,7 +666,14 @@ export const getOrCreateSession = async (
await postCreateProfile(apiUrl, token, createProfile)
).id;
}
const ws = await connect(apiUrl, token, proxy, profile, creationSessionId);
const ws = await connect(
apiUrl,
token,
proxy,
profile,
creationSessionId,
compliant,
);
const session: ActiveSession = {
ws,
msgId: 0,
Expand Down
6 changes: 5 additions & 1 deletion src/resources/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function registerStatusResource(
text: JSON.stringify(
{
apiUrl: config.browserlessApiUrl,
surface: config.complianceMode ? 'compliant' : 'full',
ok: false,
message:
'No BROWSERLESS_TOKEN configured. For HTTP: pass Authorization header.',
Expand All @@ -33,8 +34,11 @@ export function registerStatusResource(
return {
text: JSON.stringify(
{
apiUrl: config.browserlessApiUrl,
// Spread status first so apiUrl/surface stay authoritative even if
// the status payload ever grows an overlapping key.
...status,
apiUrl: config.browserlessApiUrl,
surface: config.complianceMode ? 'compliant' : 'full',
timestamp: new Date().toISOString(),
},
null,
Expand Down
6 changes: 6 additions & 0 deletions src/skills/cookie-consent.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ No match → fallback to attribute-based deep selectors: `< button[aria-label*="
## Don't

- Click `Accept all` reflexively. Sites track aggressively and may serve different content. Prefer reject when both present

<!-- compliant-omit -->

- Dismiss via `evaluate` removing banner element. Consent state server-side/cookies; hiding banner doesn't grant access, leaves event handlers blocking clicks

<!-- /compliant-omit -->

- Continue with selectors from pre-dismiss snapshot. Always re-snapshot after close

## Batching
Expand Down
5 changes: 5 additions & 0 deletions src/skills/dynamic-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@

## Avoid

<!-- compliant-omit -->

- `evaluate` with setTimeout/Promise (returns before timer completes)

<!-- /compliant-omit -->

- Multiple `waitForTimeout` stacked (use specific wait methods)
- Tight snapshot loop without wait (burns tokens, races page)
62 changes: 58 additions & 4 deletions src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,15 +266,69 @@ export const markFired = (
}
};

export const renderSkill = (id: SkillId): string => {
// One file, two surfaces: <!-- compliant-omit -->…(full-only) and
// <!-- compliant-only -->…(compliant replacement). Each render drops the other's blocks + strips markers.
const COMPLIANT_OMIT_BLOCK =
/<!-- compliant-omit -->[\s\S]*?<!-- \/compliant-omit -->\n*/g;
const COMPLIANT_ONLY_BLOCK =
/<!-- compliant-only -->[\s\S]*?<!-- \/compliant-only -->\n*/g;
const MARKER_LINE = /[ \t]*<!-- \/?compliant-(?:omit|only) -->\n?/g;

// The strippers match only exact, balanced markers; a malformed one would
// silently leave a prohibited block in the compliant render. Validate at load, fail closed.
const VALID_MARKER = /^<!-- \/?compliant-(?:omit|only) -->$/;
const SUSPECT_MARKER = /<!--[^>]*compliant[^>]*-->/gi;
const MARKER_TOKEN = /<!-- (\/?)compliant-(omit|only) -->/g;

export const validateMarkers = (body: string, path: string): void => {
// Any comment mentioning "compliant" must be an exact marker — catches typos
// and stray spacing the strippers would silently skip over.
for (const m of body.match(SUSPECT_MARKER) ?? []) {
if (!VALID_MARKER.test(m)) {
throw new Error(
`skill ${path}: malformed compliant marker ${JSON.stringify(m)}`,
);
}
}
// Markers must be balanced, matched by kind, and never nested.
const open: string[] = [];
for (const [, slash, kind] of body.matchAll(MARKER_TOKEN)) {
if (slash === '') {
if (open.length) {
throw new Error(`skill ${path}: nested compliant-${kind} marker`);
}
open.push(kind);
} else if (open.pop() !== kind) {
throw new Error(`skill ${path}: unbalanced compliant-${kind} close`);
}
}
if (open.length) {
throw new Error(`skill ${path}: unclosed compliant-${open[0]} marker`);
}
};

// Fail closed at boot rather than leak a mis-marked block per render.
skills.forEach((skill) => validateMarkers(skill.body, skill.path));

export const renderSkill = (id: SkillId, compliant: boolean): string => {
const skill = skills.find((s) => s.id === id);
if (!skill) return '';
const body = skill.body
.replace(compliant ? COMPLIANT_OMIT_BLOCK : COMPLIANT_ONLY_BLOCK, '')
.replace(MARKER_LINE, '')
.trimEnd();
return [
`--- SKILL: ${skill.id} (${skill.path}) ---`,
skill.body.trimEnd(),
body,
'--- END SKILL ---',
].join('\n');
};

export const renderSkills = (ids: ReadonlyArray<SkillId>): string =>
ids.map(renderSkill).filter(Boolean).join('\n\n');
export const renderSkills = (
ids: ReadonlyArray<SkillId>,
compliant: boolean,
): string =>
ids
.map((id) => renderSkill(id, compliant))
.filter(Boolean)
.join('\n\n');
9 changes: 9 additions & 0 deletions src/skills/modals.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Snapshot shows `role: dialog` or `role: alertdialog` — modal is open, traps fo
{ "method": "click", "params": { "selector": "[aria-label='Close']" } }
```

<!-- compliant-omit -->

3. **Escape key:**

```json
Expand All @@ -28,6 +30,8 @@ Snapshot shows `role: dialog` or `role: alertdialog` — modal is open, traps fo
}
```

<!-- /compliant-omit -->

4. **Click backdrop:**

```json
Expand All @@ -52,5 +56,10 @@ Critical confirmations ("Delete?"). Don't auto-dismiss. Find explicit button (`C

## Avoid

<!-- compliant-omit -->

- Removing modal DOM via evaluate (SPAs remount it)

<!-- /compliant-omit -->

- Interacting with page behind without closing first (pointer events captured)
Loading