Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 4 additions & 0 deletions src/@types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ 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.
complianceMode: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General question (not a necessary change). Since we are planning to eventually enable WebBot Auth compliance, would it be a good question to rename these references to webBotAuth mode? They are not strictly the same, but just a thought

sqsQueueUrl?: string;
sqsRegion: string;
oauthEnabled: boolean;
Expand Down
31 changes: 31 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ const DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS = [
'https://eu1.make.celonis.com/oauth/cb/mcp', // Make.com Celonis-hosted (enterprise) — EU region
];

// Fail closed on the compliance gate: any SET value that isn't an explicit
// opt-out enables the compliant (restricted) surface. A fumbled flag — "TRUE",
// "1", a trailing space, a typo, even an empty string — must not silently fall
// through to the full, prohibited surface on a directory-listed endpoint. Only
// an UNSET var or an explicit opt-out token (false/0/no/off) serves the full
// surface.
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 MCP_COMPLIANCE_MODE for boot logging. `unrecognized` still
// resolves to compliant (fail-closed, see parseComplianceMode) but is surfaced
// as a warning so a typo'd / mis-scoped value ("ture", "compliant", a stray
// space) is visible instead of silently reading as an 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 +58,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
56 changes: 29 additions & 27 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 @@ -112,10 +101,12 @@ const hybridAuthenticate =
authHeader: request.headers.authorization as string | undefined,
tokenQuery: params.get('token') || undefined,
apiUrlHeader: request.headers['x-browserless-api-url'] as
string | undefined,
| string
| undefined,
browserlessUrlQuery: params.get('browserlessUrl') || undefined,
sessionIdHeader: request.headers['x-browserless-session-id'] as
string | undefined,
| string
| undefined,
sessionIdQuery: params.get('browserlessSessionId') || undefined,
},
config,
Expand All @@ -130,18 +121,29 @@ 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.
// complianceMode fails closed (see parseComplianceMode), so a fumbled *value*
// lands on the compliant surface. Distinguish "unset" (a dropped/wrong-scoped
// env var on a directory deploy) from a deliberate opt-out — both print "full"
// otherwise — and warn on an unrecognized value so a typo isn't read silently
// as an intentional opt-in.
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
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
2 changes: 2 additions & 0 deletions src/skills/cookie-consent.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ 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
3 changes: 3 additions & 0 deletions src/skills/dynamic-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@

## 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)
73 changes: 69 additions & 4 deletions src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,15 +266,80 @@ export const markFired = (
}
};

export const renderSkill = (id: SkillId): string => {
// A skill body carries two surface-specific markers so one file serves both:
// <!-- compliant-omit -->…<!-- /compliant-omit --> full-only (evaluate
// techniques, captcha selectors) — dropped in compliant render.
// <!-- compliant-only -->…<!-- /compliant-only --> compliant-only — the
// replacement guidance for what the omit removed (e.g. `screenshot`/`html`
// instead of `evaluate`), so the reduced recipe stays coherent; dropped in
// full render.
// Each render drops the other surface's blocks and strips its own markers.
// compliance-mode.spec.ts asserts the compliant render of every allowlisted
// skill contains no evaluate/captcha content.
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 block strippers only match EXACT, balanced markers. A malformed one — a
// typo, stray spacing, an unclosed/mismatched/nested pair — silently leaves the
// block in place (prohibited content retained in the compliant render) and the
// MARKER_LINE pass then erases the orphan marker, hiding the evidence. Validate
// at skill-load so any anomaly fails closed at boot rather than leaking later.
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');
7 changes: 7 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,8 @@ 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)
3 changes: 3 additions & 0 deletions src/skills/screenshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ when you don't need to view it.

## Avoid

<!-- compliant-omit -->

- OCR via evaluate (you have vision input)
- Screenshotting for structured data (use snapshot/evaluate)
<!-- /compliant-omit -->
- Full-page screenshots by default (pick scope)
- Multiple screenshots of same state (one is enough)
14 changes: 14 additions & 0 deletions src/skills/shadow-dom.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ When snapshot lists `deep-ref=< button#deny`, pass to `click` / `type` / `hover`
{ "method": "click", "params": { "selector": "< button#deny" } }
```

<!-- compliant-omit -->

## Constructing deep selectors for iframes snapshot didn't surface

Fallback only — most cross-origin iframes are now in the snapshot (see above). Some widgets still have nothing meaningful in the accessibility tree. Build selector by hand:
Expand All @@ -38,12 +40,22 @@ Fallback only — most cross-origin iframes are now in the snapshot (see above).

URL pattern is glob — `*` matches any substring.

<!-- /compliant-omit -->

## What works and what doesn't

Coordinate-based actions work through deep selectors: **`click`, `type`, `hover`, `checkbox`**.

DOM-read actions **don't** work, fail or return null: **`text`, `html`, `waitForSelector`** with deep selectors.

<!-- compliant-only -->

To read content inside a frame or shadow root, use the snapshot — iframes are surfaced above with ready `deep-ref=` selectors — or `screenshot` the page and read it visually. Deep selectors are for interaction, not reading.

<!-- /compliant-only -->

<!-- compliant-omit -->

To read content from shadow root or iframe, use `evaluate` with explicit traversal:

```json
Expand All @@ -66,6 +78,8 @@ For shadow DOM:
}
```

<!-- /compliant-omit -->

## Recovery when regular selector fails

1. Retry same selector with `< ` prefix (MCP suggests automatically)
Expand Down
Loading