diff --git a/README.md b/README.md index a563f6c..983b3a6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index 3d10f49..c34a5b3 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -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; sqsQueueUrl?: string; sqsRegion: string; oauthEnabled: boolean; diff --git a/src/config.ts b/src/config.ts index 6ac6451..051a16d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, @@ -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) diff --git a/src/index.ts b/src/index.ts index 50f0583..2adfde4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -130,18 +119,26 @@ const server = new FastMCP({ 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}`); server.on('connect', (event) => { const id = event.session.sessionId ?? 'stdio'; diff --git a/src/lib/agent-client.ts b/src/lib/agent-client.ts index c4db472..5b7b303 100644 --- a/src/lib/agent-client.ts +++ b/src/lib/agent-client.ts @@ -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'); @@ -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(); }; @@ -478,9 +485,17 @@ const connect = ( proxy?: ProxyOptions, profile?: string, sessionId?: string, + compliant = false, ): Promise => 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; @@ -605,6 +620,7 @@ export const getOrCreateSession = async ( profile?: string, createProfile?: CreateProfileParams, attachSessionId?: string, + compliant = false, ): Promise => { sweepSessions(); const key = getSessionKey( @@ -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, diff --git a/src/resources/status.ts b/src/resources/status.ts index d552902..80b7783 100644 --- a/src/resources/status.ts +++ b/src/resources/status.ts @@ -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.', @@ -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, diff --git a/src/skills/cookie-consent.md b/src/skills/cookie-consent.md index 563e8d6..efbef2b 100644 --- a/src/skills/cookie-consent.md +++ b/src/skills/cookie-consent.md @@ -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 + + + - Dismiss via `evaluate` removing banner element. Consent state server-side/cookies; hiding banner doesn't grant access, leaves event handlers blocking clicks + + + - Continue with selectors from pre-dismiss snapshot. Always re-snapshot after close ## Batching diff --git a/src/skills/dynamic-content.md b/src/skills/dynamic-content.md index 5ec4738..89cef78 100644 --- a/src/skills/dynamic-content.md +++ b/src/skills/dynamic-content.md @@ -67,6 +67,11 @@ ## Avoid + + - `evaluate` with setTimeout/Promise (returns before timer completes) + + + - Multiple `waitForTimeout` stacked (use specific wait methods) - Tight snapshot loop without wait (burns tokens, races page) diff --git a/src/skills/index.ts b/src/skills/index.ts index 8a161e6..73924a1 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -266,15 +266,69 @@ export const markFired = ( } }; -export const renderSkill = (id: SkillId): string => { +// One file, two surfaces: …(full-only) and +// …(compliant replacement). Each render drops the other's blocks + strips markers. +const COMPLIANT_OMIT_BLOCK = + /[\s\S]*?\n*/g; +const COMPLIANT_ONLY_BLOCK = + /[\s\S]*?\n*/g; +const MARKER_LINE = /[ \t]*\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 = /^$/; +const SUSPECT_MARKER = //gi; +const MARKER_TOKEN = //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): string => - ids.map(renderSkill).filter(Boolean).join('\n\n'); +export const renderSkills = ( + ids: ReadonlyArray, + compliant: boolean, +): string => + ids + .map((id) => renderSkill(id, compliant)) + .filter(Boolean) + .join('\n\n'); diff --git a/src/skills/modals.md b/src/skills/modals.md index ff81a7e..8247bd8 100644 --- a/src/skills/modals.md +++ b/src/skills/modals.md @@ -17,6 +17,8 @@ Snapshot shows `role: dialog` or `role: alertdialog` — modal is open, traps fo { "method": "click", "params": { "selector": "[aria-label='Close']" } } ``` + + 3. **Escape key:** ```json @@ -28,6 +30,8 @@ Snapshot shows `role: dialog` or `role: alertdialog` — modal is open, traps fo } ``` + + 4. **Click backdrop:** ```json @@ -52,5 +56,10 @@ Critical confirmations ("Delete?"). Don't auto-dismiss. Find explicit button (`C ## Avoid + + - Removing modal DOM via evaluate (SPAs remount it) + + + - Interacting with page behind without closing first (pointer events captured) diff --git a/src/skills/screenshots.md b/src/skills/screenshots.md index 0a9e9d8..aa8d761 100644 --- a/src/skills/screenshots.md +++ b/src/skills/screenshots.md @@ -42,11 +42,16 @@ now, add **`toDisk: true`**: ``` You will **not** see the image. The response gives a reusable handle — a local -path (stdio) or a single-use GET URL (HTTP) — exactly like a download. Reuse it -with `uploadFile`, or hand the path/URL to the user. See the **file-transfers** -skill for the handle/path/URL rules and TTL. Note: to actually _look_ at a -disk-saved shot you'd have to load it back into context — so only use `toDisk` -when you don't need to view it. +path (stdio) or a single-use GET URL (HTTP) — exactly like a download. Hand the +path/URL to the user. + + +Reuse it with `uploadFile`, or see the **file-transfers** skill for the +handle/path/URL rules and TTL. + + +Note: to actually _look_ at a disk-saved shot you'd have to load it back into +context — so only use `toDisk` when you don't need to view it. ## Pattern: capture-after-action @@ -65,7 +70,12 @@ when you don't need to view it. ## Avoid + + - OCR via evaluate (you have vision input) - Screenshotting for structured data (use snapshot/evaluate) + + + - Full-page screenshots by default (pick scope) - Multiple screenshots of same state (one is enough) diff --git a/src/skills/shadow-dom.md b/src/skills/shadow-dom.md index 4de70ed..735dd3a 100644 --- a/src/skills/shadow-dom.md +++ b/src/skills/shadow-dom.md @@ -27,6 +27,8 @@ When snapshot lists `deep-ref=< button#deny`, pass to `click` / `type` / `hover` { "method": "click", "params": { "selector": "< button#deny" } } ``` + + ## 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: @@ -38,12 +40,22 @@ Fallback only — most cross-origin iframes are now in the snapshot (see above). URL pattern is glob — `*` matches any substring. + + ## 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. + + +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. + + + + + To read content from shadow root or iframe, use `evaluate` with explicit traversal: ```json @@ -66,6 +78,8 @@ For shadow DOM: } ``` + + ## Recovery when regular selector fails 1. Retry same selector with `< ` prefix (MCP suggests automatically) diff --git a/src/skills/snapshot-misses.md b/src/skills/snapshot-misses.md index d79e614..e24afbd 100644 --- a/src/skills/snapshot-misses.md +++ b/src/skills/snapshot-misses.md @@ -17,6 +17,8 @@ Snapshot at element limit (truncated) or empty. What you need may not be in it. { "method": "snapshot", "params": { "maxElements": 1000 } } ``` + + 2. **If element has no accessible name**, use `evaluate` to read directly: ```json @@ -50,6 +52,24 @@ Snapshot at element limit (truncated) or empty. What you need may not be in it. } ``` + + + + +2. **If a control has no accessible name** (icon-only button, image without `alt`), `screenshot` to see it, then act via a nearby labeled element from the snapshot — or read a region with `html` / `text` on a container selector: + + ```json + { "method": "html", "params": { "selector": "main" } } + ``` + +3. **For image-rendered results** (WolframAlpha, LaTeX, charts, image search), `screenshot` and read the answer visually — the model has vision, so a single `` whose meaning isn't in the DOM is still readable: + + ```json + { "method": "screenshot" } + ``` + + + 4. **For very long lists**, scroll and re-snapshot rather than raising `maxElements` — snapshot pagination more reliable than one giant pull: ```json @@ -64,4 +84,9 @@ Snapshot at element limit (truncated) or empty. What you need may not be in it. ## Don't - Raise `maxElements` past ~2000 — model spends more on snapshot reading than task gains. Scroll and paginate instead + + + - `evaluate` to crawl `document.body.innerHTML` for general extraction. Snapshot structured; raw HTML floods context with markup. Use `evaluate` only for _specific_ attributes snapshot can't surface + + diff --git a/src/skills/system-prompt.ts b/src/skills/system-prompt.ts index 755045e..20119b4 100644 --- a/src/skills/system-prompt.ts +++ b/src/skills/system-prompt.ts @@ -133,6 +133,110 @@ Never retry same failed action without re-snapshot. `; +// The full ReAct loop de-fanged for the directory listing: keeps what makes the +// agent effective, strips evaluate/proxy/auth/CAPTCHA/site-recipes/file-I/O. Sync with AGENT_SYSTEM_PROMPT. +export const COMPLIANT_AGENT_SYSTEM_PROMPT = `Drive a browser to complete a user-directed task on a page the user specifies. Use only for content the user is authorized to access. Do not use to bypass access controls or bot protection, solve CAPTCHAs, evade detection, route around IP/geo restrictions, or access content in violation of a site's terms of service. + +## Core Loop (ReAct: Reason → Act → Observe) +1. **goto** — waits "domcontentloaded" +2. **snapshot** — returns interactive + informational elements (button, link, textbox, combobox, checkbox, heading, img+alt) with ref= selectors +3. **Plan** all actions from snapshot +4. **Batch** execute +5. **Re-snapshot** only if page changed +6. Repeat → **close** when done + +## Terminal-Goal Check +Before declaring done, restate the user's terminal deliverable in one line and verify your evidence *directly* supports it — not a sibling question. +**Empty-state substitution.** An empty/zero/null result from a resource that normally needs scope or filter context is evidence the *precondition* wasn't met — not evidence the question is answered (e.g. zero results behind an unmet filter). Fix the precondition; don't return the empty result as the answer. +**Multi-step preconditions.** When the task names multiple steps ("go to X, then Y, report Z"), assess preconditions for the *full chain* before treating any step as optional. A blocker on step N blocks the whole task even if step 1 returned data. + +## Skills (auto-injected) +SKILL blocks auto-inject between \`--- SKILL: ---\` markers when the page/error needs special handling. Read carefully. +Load manually via **browserless_skill** if suspected but not injected: +- \`shadow-dom\` — deep selectors, iframe targeting +- \`cookie-consent\` — vendor-specific dismiss recipes +- \`modals\` — closing dialogs and alertdialogs +- \`snapshot-misses\` — truncated/empty snapshots, image-rendered content +- \`dynamic-content\` — choosing the right \`wait*\` method +- \`screenshots\` — when to screenshot vs. snapshot, scope and format choices +- \`tabs\` — multi-tab workflows, peek-without-switching + +## Snapshot Rules +- Until you snapshot a page, you CANNOT click/type/interact — snapshot first, no exceptions +- NEVER guess, assume, or infer selectors — CSS selectors from your training data are wrong. ONLY use ref= / deep-ref= from latest snapshot +- Snapshot STALE after: click, goto, select, navigation +- Snapshot VALID after: type, hover, scroll +- Expect new content? → re-snapshot +- Element roles in snapshot (link, button, textbox, combobox, checkbox, heading) tell you what each does +- Snapshots after the first return a **diff** vs. your previous snapshot: only \`+\` new / \`~\` changed / \`-\` removed elements, plus a count of unchanged ones omitted. Unchanged elements stay valid — keep using their refs from the earlier snapshot. If that earlier snapshot is no longer in your context, request \`snapshot { full: true }\` for the complete element list again. + +## Selectors +- Use **ref=** (CSS) or **deep-ref=** (starts \`< \`) exactly as shown in snapshot +- Example: \`[3] button "Sign In" ref=button#submit\` → \`"button#submit"\` +- deep-ref for shadow DOM / iframes — see \`shadow-dom\` skill + +## Iframes +Snapshots include a \`Frames\` list (cross-origin iframes) when present. Elements inside a frame are tagged \`[frame#N]\` and carry a \`deep-ref=< *url* css\` selector that already pierces the frame — pass it as-is to \`click\`/\`type\`/\`hover\`/\`checkbox\`. No frame switching needed. \`shadow-dom\` skill auto-loads when frames present. + +## Tabs +Snapshots include \`tabs\` + \`activeTargetId\` — no getTabs needed. Multi-tab / \`snapshot { targetId }\` in \`tabs\` skill (auto-loads when >1 tab). + +## Links +**Prefer goto over click** for links with href — immune to layout shifts, overlays, misclicks. +Example: \`[5] a "About" ref=a[href='/about']\` → \`goto { url: "https://ex.com/about" }\` +Only click when href is \`javascript:\` / \`#\` / missing. + +## Content Extraction +1. Check in-memory snapshot (text/values already there) +2. **text** { selector } — from specific element +3. **html** { selector } — raw HTML + +## Batching — Maximize Per Call +Plan ALL actions from snapshot before next snapshot. + +**Process:** +1. Classify actions: **safe** (type, hover, scroll, select, checkbox) vs. **page-changing** (click, goto) +2. Batch: safe FIRST → page-changing LAST +3. For forms: if submit button is in snapshot, batch type + click in one call +4. Don't batch across navigations + +**Example form:** +\`\`\`json +{ "commands": [ + { "method": "type", "params": { "selector": "input#email", "text": "j@d.com" } }, + { "method": "click", "params": { "selector": "button#submit" } } +] } +\`\`\` + +## Async +After async triggers (search, submit), use \`wait*\` before snapshot — \`waitForResponse\` best when the API URL is known. \`dynamic-content\` skill auto-loads on timeout. + +## Error Recovery +Errors tagged \`Category: \`: +- **SELECTOR_MISS** — re-snapshot; retry \`< selector\` if not already deep-ref +- **SESSION_LOST** — a fresh session was opened automatically; re-goto + snapshot (prior state gone) +- **UNAUTHORIZED** / **FORBIDDEN** — pick a different path +- **NOT_FOUND** — different URL +- **SERVER_ERROR** — backoff, retry once +- **NAVIGATION_FAILED** — verify URL +- **TIMEOUT** — longer wait or different signal +- **INVALID_PARAMS** — fix params (schema authoritative) +- **UNKNOWN** — re-snapshot + re-plan + +\`! NOTICE: URL changed cross-origin\` = prior plan/refs invalid, re-plan. +Never retry the same failed action without re-snapshot. + +## Methods (non-obvious) +- **goto** { url, waitUntil? } — default "domcontentloaded"; prefer over click for links +- **snapshot** { maxElements?, targetId? } — cap 500; targetId peeks non-active tab +- **waitForSelector** { selector, timeout? } — set 5000-10000ms +- **waitForResponse** { url?, statuses?, timeout? } — url is glob \`"*api/results*"\` +- **createTab** { url?, activate?, waitUntil? } — default activate: true; false = background +- **close** — own call, NOT batched; only when task complete (premature close discards page state) +- See schema for: screenshot, back, forward, reload, click, type, select, checkbox, hover, scroll, text, html, waitForNavigation, waitForTimeout, waitForRequest, liveURL, getTabs, switchTab, closeTab + +Provide \`commands\` as a sequential batch; only the final result is returned.`; + // Transport-specific file-transfer guidance, appended to the agent tool // description so the model knows its mode UP FRONT — instead of guessing (and // base64-ing files it should pass by path). The server knows the transport; the diff --git a/src/tools/agent.ts b/src/tools/agent.ts index cc428d5..fa7c4c9 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -27,10 +27,10 @@ import { classifyAgentError } from '../lib/error-classifier.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import { defineTool } from '../lib/define-tool.js'; import { - detectSkills, markFired, renderSkill, renderSkills, + skillsRegistry, } from '../skills/index.js'; import { loadSiteSkill, @@ -38,8 +38,17 @@ import { siteRecipeNotice, } from '../skills/sites.js'; import { AgentParamsSchema } from './schemas.js'; +import { + isCompliant, + detectVisibleSkills, + COMPLIANT_SKILLS, + COMPLIANT_SKILL_TOOL_DESCRIPTION, + COMPLIANT_AGENT_METHODS, + CompliantAgentParamsSchema, +} from './compliance.js'; import { AGENT_SYSTEM_PROMPT, + COMPLIANT_AGENT_SYSTEM_PROMPT, SKILL_TOOL_DESCRIPTION, fileTransferModeNote, } from '../skills/system-prompt.js'; @@ -66,11 +75,28 @@ export { const SNAPSHOT_METHOD = 'snapshot'; const FATAL_CODES = new Set(['BROWSER_CRASHED']); -const appendSkills = (base: string, ids: ReadonlyArray): string => { +const appendSkills = ( + base: string, + ids: ReadonlyArray, + compliant: boolean, +): string => { if (ids.length === 0) return base; - return `${base}\n\n${renderSkills(ids)}`; + return `${base}\n\n${renderSkills(ids, compliant)}`; }; +// Reply extras: auto-injected skill bodies (de-fanged when compliant) + the +// site-recipe pointer (suppressed when compliant). Pure/exported so both branches' +// compliant threading is testable without a live session. +export const buildSurfaceExtras = ( + compliant: boolean, + triggered: ReadonlyArray, + currentUrl: string | undefined, + sitesSurfaced: Set, +): { skills: string; siteNotice: string } => ({ + skills: triggered.length > 0 ? renderSkills(triggered, compliant) : '', + siteNotice: compliant ? '' : siteRecipeNotice(currentUrl, sitesSurfaced), +}); + const SCREENSHOT_MIME: Record = { jpeg: 'image/jpeg', webp: 'image/webp', @@ -387,15 +413,49 @@ const SkillToolParamsSchema = z 'Provide either `id` (load a skill) or `site` (list site recipes).', }); +// Boundary type for both schemas: method/params are the full-only single-command +// passthrough (absent when compliant), so optional here and read undefined-safely. +type AgentToolParams = Omit & { + method?: string; + params?: Record; +}; + export function registerAgentTools( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); + + // Compliant: drop circumvention/autologin recipes from the enum + omit the + // `site` lookup (arbitrary recipes can prescribe proxy/evaluate/login) — vetted skills only. + const compliantSkillIds = skillsRegistry + .map((s) => s.id) + .filter((id) => COMPLIANT_SKILLS.has(id)); + // z.enum([]) doesn't throw (all-rejecting schema) — fail loudly at boot instead. + // The destructure also gives z.enum a non-empty tuple, dropping the cast. + if (compliantSkillIds.length === 0) { + throw new Error('Compliant surface has no allowlisted skills configured.'); + } + const [firstSkillId, ...restSkillIds] = compliantSkillIds; + const compliantSkillParamsSchema = z + .object({ + id: z + .enum([firstSkillId, ...restSkillIds]) + .describe( + 'The skill to load (see tool description for the full list).', + ), + }) + .strict(); + defineTool(server, config, analytics, { name: 'browserless_skill', - description: SKILL_TOOL_DESCRIPTION, - parameters: SkillToolParamsSchema, + description: compliant + ? COMPLIANT_SKILL_TOOL_DESCRIPTION + : SKILL_TOOL_DESCRIPTION, + parameters: compliant + ? (compliantSkillParamsSchema as z.ZodType) + : SkillToolParamsSchema, annotations: { title: 'Load Browserless Skill', readOnlyHint: true, @@ -404,10 +464,16 @@ export function registerAgentTools( }, run: async ({ params, analytics, token, apiUrl }) => { const id = params.id ?? ''; - const body = - params.site !== undefined + // Compliant: no `site` recipes; only allowlisted in-house skills. The enum + // already blocks other ids — this guard is defense-in-depth for a bypass. + if (compliant && !COMPLIANT_SKILLS.has(id as SkillId)) { + throw new UserError(`Skill "${id}" is not available on this endpoint.`); + } + const body = compliant + ? renderSkill(id as SkillId, true) + : params.site !== undefined ? renderSiteSkillList(params.site) - : renderSkill(id as SkillId) || loadSiteSkill(id) || ''; + : renderSkill(id as SkillId, false) || loadSiteSkill(id) || ''; analytics?.fireSkill(token, { ...buildSkillEventProps(params, body), api_url: apiUrl, @@ -428,12 +494,17 @@ export function registerAgentTools( }, }); - defineTool(server, config, analytics, { + defineTool(server, config, analytics, { name: 'browserless_agent', - description: - AGENT_SYSTEM_PROMPT + - fileTransferModeNote(config.transport, config.mcpBaseUrl), - parameters: AgentParamsSchema, + description: compliant + ? COMPLIANT_AGENT_SYSTEM_PROMPT + : AGENT_SYSTEM_PROMPT + + fileTransferModeNote(config.transport, config.mcpBaseUrl), + // Cast: Zod's generic is invariant, so the ternary needs it. AgentToolParams + // supertypes both schemas; FastMCP's runtime schema + compliance spec are the real guards. + parameters: (compliant + ? CompliantAgentParamsSchema + : AgentParamsSchema) as z.ZodType, annotations: { title: 'Browserless Agent', readOnlyHint: false, @@ -458,7 +529,32 @@ export function registerAgentTools( method: c.method, params: c.params ?? {}, })) - : [{ method: params.method, params: params.params ?? {} }]; + : [{ method: params.method ?? '', params: params.params ?? {} }]; + + // Defense-in-depth: even if the schema were mis-built, compliant never + // forwards a non-allowlisted method, auth-profile, or proxy arg to the backend. + if (compliant) { + for (const c of commands) { + if (!COMPLIANT_AGENT_METHODS.has(c.method)) { + throw new UserError( + `Command "${c.method}" is not available on this endpoint.`, + ); + } + } + if ( + params.profile !== undefined || + params.createProfile !== undefined + ) { + throw new UserError( + 'Authentication profiles are not available on this endpoint.', + ); + } + if (params.proxy !== undefined) { + throw new UserError( + 'Proxy configuration is not available on this endpoint.', + ); + } + } const proxy = params.proxy; const profile = params.profile; @@ -519,6 +615,7 @@ export function registerAgentTools( profile, createProfile, attachSessionId, + compliant, ); } catch (connErr: unknown) { sendAnalytics(false); @@ -542,6 +639,7 @@ export function registerAgentTools( profile, createProfile, attachSessionId, + compliant, ); } catch (connErr: unknown) { // No retry when the server gave a definitive 4xx — re-attempting @@ -678,13 +776,14 @@ export function registerAgentTools( : undefined, }); - const triggered = detectSkills( + const triggered = detectVisibleSkills( { snapshot: err.snapshot, error: err, cmd, apiUrl }, agentSession.skillState, + compliant, ); markFired(agentSession.skillState, triggered); - throw new UserError(appendSkills(body, triggered)); + throw new UserError(appendSkills(body, triggered, compliant)); } // Capture the first URL we observe in the batch as a fallback @@ -720,7 +819,7 @@ export function registerAgentTools( ? (lastResult as unknown as SnapshotResult) : undefined; - const triggered = detectSkills( + const triggered = detectVisibleSkills( { snapshot: lastSnapshot, cmd: lastCmd, @@ -728,6 +827,7 @@ export function registerAgentTools( apiUrl, }, agentSession.skillState, + compliant, ); markFired(agentSession.skillState, triggered); @@ -752,12 +852,15 @@ export function registerAgentTools( } // Surface a site-recipe pointer for the URL this batch landed on — the - // tool-description prose gate gets skipped/clipped, so push it as a result. + // tool-description prose gate gets skipped/clipped, so push it as a + // result. Suppressed on the compliant surface (no site recipes there). const currentUrl = lastSnapshot?.url ?? (lastResult as { url?: string } | undefined)?.url ?? crossOriginBaseline; - const siteNotice = siteRecipeNotice( + const { skills: renderedSkills, siteNotice } = buildSurfaceExtras( + compliant, + triggered, currentUrl, agentSession.skillState.sitesSurfaced, ); @@ -776,10 +879,7 @@ export function registerAgentTools( // malformed URL — nothing to report } } - const extraText = [ - triggered.length > 0 ? renderSkills(triggered) : '', - siteNotice, - ] + const extraText = [renderedSkills, siteNotice] .filter(Boolean) .join('\n\n'); const appendExtra = (base: string): string => diff --git a/src/tools/compliance.ts b/src/tools/compliance.ts new file mode 100644 index 0000000..ca378fc --- /dev/null +++ b/src/tools/compliance.ts @@ -0,0 +1,66 @@ +import type { McpConfig, SkillId } from '../@types/types.js'; +import { detectSkills } from '../skills/index.js'; + +// Compliance policy for the MCP_COMPLIANCE_MODE surface, which the OpenAI / +// Anthropic directories accept but the full one they reject: the isCompliant +// gate, COMPLIANT_SKILLS allowlist + visibleSkills filter, compliant descriptions. +export { + CompliantAgentParamsSchema, + COMPLIANT_AGENT_METHODS, +} from './schemas.js'; + +export const isCompliant = (config: McpConfig): boolean => + config.complianceMode === true; + +// Fail-closed allowlist — a new registry skill does NOT auto-appear, add it +// deliberately. Omits captchas (circumvention), autonomous-login/auth-profile +// (login), file-transfers (file I/O). Typed so a bad id breaks the build. +export const COMPLIANT_SKILLS: ReadonlySet = new Set([ + 'shadow-dom', + 'cookie-consent', + 'modals', + 'snapshot-misses', + 'dynamic-content', + 'screenshots', + 'tabs', +]); + +// Auto-injection filter: in compliant mode only allowlisted skills survive, so a +// restricted or unvetted recipe never auto-injects. The enum uses the same set. +export const visibleSkills = ( + ids: ReadonlyArray, + compliant: boolean, +): SkillId[] => + compliant ? ids.filter((id) => COMPLIANT_SKILLS.has(id)) : ids.slice(); + +// detectSkills + visibleSkills, composed here (not agent.ts) so an auto-inject +// site imports only the filtered wrapper — raw detectSkills won't resolve there. +export const detectVisibleSkills = ( + ctx: Parameters[0], + state: Parameters[1], + compliant: boolean, +): SkillId[] => visibleSkills(detectSkills(ctx, state), compliant); + +// The compliant agent description is COMPLIANT_AGENT_SYSTEM_PROMPT (system-prompt.ts). +export const COMPLIANT_SEARCH_DESCRIPTION = + 'Search the web using Browserless. Performs web searches via SearXNG ' + + 'and returns results from web, news, or images. Useful for research, ' + + 'gathering information, and finding relevant web pages.'; + +export const COMPLIANT_EXPORT_DESCRIPTION = + 'Export a webpage from a URL via the Browserless /export API. ' + + 'Fetches the URL and returns its content in the native format ' + + '(HTML, PDF, image, etc.). Automatically detects the content type.'; + +export const COMPLIANT_SKILL_TOOL_DESCRIPTION = `Load a Browserless agent skill on demand. + +Use this when you suspect the page exhibits a non-trivial mechanic but no SKILL block was auto-injected into a previous response. The auto-injection heuristics are conservative; calling this tool is the explicit fallback. + +Available skills: +- **shadow-dom** — deep selectors, iframe URL-pattern syntax, what works through deep-ref +- **cookie-consent** — vendor-specific dismiss recipes (OneTrust, Cookiebot, Didomi, etc.) +- **modals** — close-button heuristics, ESC handling, alertdialog vs. dialog +- **snapshot-misses** — truncated/empty snapshots, image-rendered content +- **dynamic-content** — choosing the right \`wait*\` method after async triggers +- **screenshots** — when to screenshot vs. snapshot, scope and format choices +- **tabs** — multi-tab workflows, peek-without-switching`; diff --git a/src/tools/export.ts b/src/tools/export.ts index 2ddb28a..a2a0872 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -3,6 +3,7 @@ import type { Content } from 'fastmcp'; import { z } from 'zod'; import { defineTool, validateHttpUrl } from '../lib/define-tool.js'; import { profileField } from './schemas.js'; +import { isCompliant, COMPLIANT_EXPORT_DESCRIPTION } from './compliance.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import type { ExportParams, @@ -61,20 +62,37 @@ export const ExportParamsSchema = z.object({ profile: profileField('before the page is exported'), }); +// Fail-closed param allowlist (.pick, not .omit) — a new full-schema field stays +// off until allowed. Drops includeResources (bulk ZIP) + profile (auth); .strict() rejects extras. +const CompliantExportParamsSchema = ExportParamsSchema.pick({ + url: true, + gotoOptions: true, + bestAttempt: true, + waitForTimeout: true, + timeout: true, +}).strict(); + export function registerExportTool( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); + defineTool(server, config, analytics, { name: 'browserless_export', - description: - 'Export a webpage from a URL via the Browserless /export API. ' + - 'Fetches the URL and returns its content in the native format ' + - '(HTML, PDF, image, etc.). Automatically detects the content type. ' + - 'Set includeResources=true to bundle all page assets (CSS, JS, images) ' + - 'into a ZIP archive for offline use.', - parameters: ExportParamsSchema, + description: compliant + ? COMPLIANT_EXPORT_DESCRIPTION + : 'Export a webpage from a URL via the Browserless /export API. ' + + 'Fetches the URL and returns its content in the native format ' + + '(HTML, PDF, image, etc.). Automatically detects the content type. ' + + 'Set includeResources=true to bundle all page assets (CSS, JS, images) ' + + 'into a ZIP archive for offline use.', + // Cast: Zod's generic is invariant, so the ternary needs it. The compliant .pick + // schema is a structural subtype; FastMCP's runtime schema + compliance spec are the real guards. + parameters: (compliant + ? CompliantExportParamsSchema + : ExportParamsSchema) as z.ZodType, annotations: { title: 'Browserless Export', readOnlyHint: true, @@ -88,6 +106,19 @@ export function registerExportTool( `live session first, or omit the profile parameter to export ` + `the page anonymously.`, run: async ({ client, params, log }) => { + // Defense-in-depth (parity with the agent allowlist): the `.pick().strict()` + // schema already rejects `includeResources`, but guard run() too so a future + // schema regression can't forward bulk asset capture to the backend. + if (compliant && params.includeResources !== undefined) { + throw new UserError( + 'includeResources is not available on this endpoint.', + ); + } + if (compliant && params.profile !== undefined) { + throw new UserError( + 'Authentication profiles are not available on this endpoint.', + ); + } const response = await client.exportPage({ url: params.url, gotoOptions: params.gotoOptions, diff --git a/src/tools/performance.ts b/src/tools/performance.ts index cc22f44..81f9bdf 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -1,8 +1,9 @@ -import { FastMCP } from 'fastmcp'; +import { FastMCP, UserError } from 'fastmcp'; import type { Content } from 'fastmcp'; import { z } from 'zod'; import { defineTool, validateHttpUrl } from '../lib/define-tool.js'; import { profileField } from './schemas.js'; +import { isCompliant } from './compliance.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import type { McpConfig, @@ -43,11 +44,21 @@ export const PerformanceParamsSchema = z.object({ profile: profileField('before the Lighthouse audit runs'), }); +// Excludes `profile` (auth-session hydration) — no authentication-profile +// capability on the compliant surface (parity with agent/export/search). +const CompliantPerformanceParamsSchema = PerformanceParamsSchema.pick({ + url: true, + categories: true, + budgets: true, + timeout: true, +}).strict(); + export function registerPerformanceTool( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); defineTool( server, config, @@ -59,7 +70,9 @@ export function registerPerformanceTool( 'Returns scores and metrics for accessibility, best practices, performance, PWA, and SEO. ' + 'Optionally filter by category or supply performance budgets. ' + 'Note: audits can take 30s–120s depending on the site.', - parameters: PerformanceParamsSchema, + parameters: compliant + ? (CompliantPerformanceParamsSchema as z.ZodType) + : PerformanceParamsSchema, annotations: { title: 'Browserless Lighthouse Performance Audit', readOnlyHint: true, @@ -73,6 +86,11 @@ export function registerPerformanceTool( `live session first, or omit the profile parameter to audit ` + `the page anonymously.`, run: async ({ client, params, log }) => { + if (compliant && params.profile !== undefined) { + throw new UserError( + 'Authentication profiles are not available on this endpoint.', + ); + } const response = await client.performance({ url: params.url, categories: params.categories, diff --git a/src/tools/register.ts b/src/tools/register.ts new file mode 100644 index 0000000..93d4b78 --- /dev/null +++ b/src/tools/register.ts @@ -0,0 +1,81 @@ +import { FastMCP } from 'fastmcp'; +import type { McpConfig } from '../@types/types.js'; +import { AnalyticsHelper } from '../lib/analytics.js'; +import { registerSmartScraperTool } from './smartscraper.js'; +import { registerExportTool } from './export.js'; +import { registerAgentTools } from './agent.js'; +import { registerSearchTool } from './search.js'; +import { registerPerformanceTool } from './performance.js'; +import { registerFunctionTool } from './function.js'; +import { registerMapTool } from './map.js'; +import { registerCrawlTool } from './crawl.js'; +import { isCompliant } from './compliance.js'; +import { registerApiDocsResource } from '../resources/api-docs.js'; +import { registerStatusResource } from '../resources/status.js'; +import { registerScrapeUrlPrompt } from '../prompts/scrape-url.js'; +import { registerExtractContentPrompt } from '../prompts/extract-content.js'; + +// Registers the whole surface (tools/resources/prompts) so the compliance gate +// covers all a reviewer lists. Each item declares 'both'|'full' — no positional +// "unconditional" block a new tool could ship on the directory by accident. +type Surface = 'both' | 'full'; + +export function registerSurface( + server: FastMCP, + config: McpConfig, + analytics?: AnalyticsHelper, +): void { + const registrations: ReadonlyArray<{ + surface: Surface; + register: () => void; + }> = [ + { + surface: 'both', + register: () => registerExportTool(server, config, analytics), + }, + // agent + skill tools + { + surface: 'both', + register: () => registerAgentTools(server, config, analytics), + }, + { + surface: 'both', + register: () => registerSearchTool(server, config, analytics), + }, + { + surface: 'both', + register: () => registerPerformanceTool(server, config, analytics), + }, + // Generic service status — safe on both (it reports the active surface). + { surface: 'both', register: () => registerStatusResource(server, config) }, + // Full only: scraping/code tools + the api-docs resource and scrape prompts + // (they document proxy/captcha and steer the model to smartscraper). + { + surface: 'full', + register: () => registerSmartScraperTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerFunctionTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerMapTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerCrawlTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerApiDocsResource(server, config), + }, + { surface: 'full', register: () => registerScrapeUrlPrompt(server) }, + { surface: 'full', register: () => registerExtractContentPrompt(server) }, + ]; + + const compliant = isCompliant(config); + for (const { surface, register } of registrations) { + if (surface === 'both' || !compliant) register(); + } +} diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index bb7de62..d4983eb 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -740,6 +740,67 @@ export const AgentParamsSchema = z 'one) cannot both be set', }); +// ── Compliant surface variant (see ./compliance.ts) ────────────────────────── +// De-fanged agent surface: drops prohibited commands/config (CAPTCHA, JS, proxy, +// stealth, autologin) + raw-BQL passthrough. `.strict()` rejects removed keys server-side. +const compliantCommandSchemas = [ + GotoCommandSchema, + BackCommandSchema, + ForwardCommandSchema, + ReloadCommandSchema, + SnapshotCommandSchema, + GetTabsCommandSchema, + SwitchTabCommandSchema, + CreateTabCommandSchema, + CloseTabCommandSchema, + ClickCommandSchema, + TypeCommandSchema, + SelectCommandSchema, + CheckboxCommandSchema, + HoverCommandSchema, + ScrollCommandSchema, + TextCommandSchema, + HtmlCommandSchema, + WaitForSelectorCommandSchema, + WaitForNavigationCommandSchema, + WaitForTimeoutCommandSchema, + WaitForRequestCommandSchema, + WaitForResponseCommandSchema, + LiveURLCommandSchema, + ScreenshotCommandSchema, + // No uploadFile/getDownloads: upload impersonates a human write (vendor-TOS), + // download is the paired file-I/O — a compliant web agent reads, doesn't move files. + CloseCommandSchema, +] as const; + +/** Method names the compliant agent permits — defense-in-depth for run(). */ +export const COMPLIANT_AGENT_METHODS: ReadonlySet = new Set( + compliantCommandSchemas.map((s) => s.shape.method.value), +); + +// No profile/createProfile: zero auth-profile capability. profile hydrates a +// saved session (see profileField) — belongs with the hidden autonomous-login skills. +export const CompliantAgentParamsSchema = z + .object({ + commands: z + .array(z.discriminatedUnion('method', compliantCommandSchemas)) + .min(1) + .describe( + 'Batch of browser navigation, read, and interaction commands ' + + '(click, type, scroll, etc.) executed sequentially against the page ' + + 'the user specifies. Only the final result is returned.', + ), + rationale: z + .string() + .optional() + .describe( + 'Short user-facing reason for this call (<=50 chars, present-continuous).', + ), + }) + .strict(); + +export type CompliantAgentParams = z.infer; + /** A single validated agent command. */ export type AgentCommand = z.infer; /** The full `browserless_agent` tool params (single command, batch, proxy, profile). */ diff --git a/src/tools/search.ts b/src/tools/search.ts index e1c7b21..97840e2 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -2,6 +2,7 @@ import { FastMCP, UserError } from 'fastmcp'; import type { Content } from 'fastmcp'; import { z } from 'zod'; import { defineTool } from '../lib/define-tool.js'; +import { isCompliant, COMPLIANT_SEARCH_DESCRIPTION } from './compliance.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import type { McpConfig, @@ -80,19 +81,40 @@ export const SearchParamsSchema = z.object({ .describe('Request timeout in milliseconds'), }); +// Fail-closed param allowlist (.pick, not .omit) — a new full-schema field stays +// off until allowed. Drops scrapeOptions (per-result bulk scrape); .strict() rejects extras. +const CompliantSearchParamsSchema = SearchParamsSchema.pick({ + query: true, + limit: true, + lang: true, + country: true, + location: true, + tbs: true, + sources: true, + categories: true, + timeout: true, +}).strict(); + export function registerSearchTool( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); + defineTool(server, config, analytics, { name: 'browserless_search', - description: - 'Search the web using Browserless and optionally scrape each result. ' + - 'Performs web searches via SearXNG and can return results from web, news, or images. ' + - 'Optionally scrape each result URL to get markdown, HTML, links, or screenshots. ' + - 'Useful for research, gathering information, and finding relevant web pages.', - parameters: SearchParamsSchema, + description: compliant + ? COMPLIANT_SEARCH_DESCRIPTION + : 'Search the web using Browserless and optionally scrape each result. ' + + 'Performs web searches via SearXNG and can return results from web, news, or images. ' + + 'Optionally scrape each result URL to get markdown, HTML, links, or screenshots. ' + + 'Useful for research, gathering information, and finding relevant web pages.', + // Cast: Zod's generic is invariant, so the ternary needs it. The compliant .pick + // schema is a structural subtype; FastMCP's runtime schema + compliance spec are the real guards. + parameters: (compliant + ? CompliantSearchParamsSchema + : SearchParamsSchema) as z.ZodType, annotations: { title: 'Browserless Search', readOnlyHint: true, @@ -100,6 +122,12 @@ export function registerSearchTool( openWorldHint: true, }, run: async ({ client, params, log }) => { + // Defense-in-depth (parity with the agent allowlist): the `.pick().strict()` + // schema already rejects `scrapeOptions`, but guard run() too so a future + // schema regression can't forward per-result scraping to the backend. + if (compliant && params.scrapeOptions !== undefined) { + throw new UserError('scrapeOptions is not available on this endpoint.'); + } const response = await client.search({ query: params.query, limit: params.limit, diff --git a/test/integration/server.spec.ts b/test/integration/server.spec.ts index 3362728..519aaaa 100644 --- a/test/integration/server.spec.ts +++ b/test/integration/server.spec.ts @@ -17,6 +17,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/lib/api-client.spec.ts b/test/lib/api-client.spec.ts index 031e7d7..1113604 100644 --- a/test/lib/api-client.spec.ts +++ b/test/lib/api-client.spec.ts @@ -16,6 +16,7 @@ const mockConfig: McpConfig & { browserlessToken: string } = { maxRetries: 0, cacheTtlMs: 60000, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/lib/config.spec.ts b/test/lib/config.spec.ts index 09132ae..115cfb8 100644 --- a/test/lib/config.spec.ts +++ b/test/lib/config.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { getConfig } from '../../src/config.js'; +import { getConfig, classifyComplianceInput } from '../../src/config.js'; const ENV_KEY = 'OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS'; const BASELINE_PATTERNS = [ @@ -66,3 +66,59 @@ describe('config.oauthAllowedRedirectUriPatterns', () => { expect(patterns).to.have.lengthOf(BASELINE_PATTERNS.length); }); }); + +describe('config.complianceMode', () => { + const COMPLIANCE_KEY = 'MCP_COMPLIANCE_MODE'; + let original: string | undefined; + + beforeEach(() => { + original = process.env[COMPLIANCE_KEY]; + delete process.env[COMPLIANCE_KEY]; + }); + + afterEach(() => { + if (original === undefined) delete process.env[COMPLIANCE_KEY]; + else process.env[COMPLIANCE_KEY] = original; + }); + + it('defaults to false (full surface) when the env var is unset', () => { + expect(getConfig().complianceMode).to.equal(false); + }); + + it('is true for "true"', () => { + process.env[COMPLIANCE_KEY] = 'true'; + expect(getConfig().complianceMode).to.equal(true); + }); + + it('fails closed: any set value that is not an explicit opt-out enables compliant mode', () => { + // '' (set-but-empty) is included: a set var, even empty, must not fall + // through to the full surface. + for (const v of ['1', 'yes', 'TRUE', 'on', ' true ', 'garbage', '']) { + process.env[COMPLIANCE_KEY] = v; + expect(getConfig().complianceMode, `value ${JSON.stringify(v)}`).to.equal( + true, + ); + } + }); + + it('serves the full surface only for unset or an explicit opt-out token', () => { + for (const v of ['false', '0', 'no', 'off', 'FALSE', ' off ']) { + process.env[COMPLIANCE_KEY] = v; + expect(getConfig().complianceMode, `value ${JSON.stringify(v)}`).to.equal( + false, + ); + } + }); + + it('classifyComplianceInput distinguishes unset / opt-out / opt-in / unrecognized', () => { + expect(classifyComplianceInput(undefined)).to.equal('unset'); + for (const v of ['false', '0', 'no', 'off', 'FALSE', ' off ']) + expect(classifyComplianceInput(v), v).to.equal('opt-out'); + for (const v of ['true', '1', 'yes', 'on', 'TRUE', ' true ']) + expect(classifyComplianceInput(v), v).to.equal('opt-in'); + // Fumbled values still parse to compliant (fail-closed) but classify as + // unrecognized so the boot log warns instead of reading them as opt-in. + for (const v of ['ture', 'compliant', 'garbage', '']) + expect(classifyComplianceInput(v), v).to.equal('unrecognized'); + }); +}); diff --git a/test/resources/resources.spec.ts b/test/resources/resources.spec.ts index 397d336..594cf6e 100644 --- a/test/resources/resources.spec.ts +++ b/test/resources/resources.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/skills/skills.spec.ts b/test/skills/skills.spec.ts index 6bef5fe..7e1502f 100644 --- a/test/skills/skills.spec.ts +++ b/test/skills/skills.spec.ts @@ -61,7 +61,7 @@ describe('skills/registry', () => { }); it('renderSkill wraps body with markers and the file path', () => { - const out = renderSkill('shadow-dom'); + const out = renderSkill('shadow-dom', false); expect(out).to.match( /^--- SKILL: shadow-dom \(src\/skills\/shadow-dom\.md\) ---/, ); @@ -69,7 +69,7 @@ describe('skills/registry', () => { }); it('renderSkills joins multiple', () => { - const out = renderSkills(['shadow-dom', 'modals']); + const out = renderSkills(['shadow-dom', 'modals'], false); expect(out).to.include('SKILL: shadow-dom'); expect(out).to.include('SKILL: modals'); }); diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index c643d29..9eb389f 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -40,6 +40,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/annotations.spec.ts b/test/tools/annotations.spec.ts index a921cd1..5c6118c 100644 --- a/test/tools/annotations.spec.ts +++ b/test/tools/annotations.spec.ts @@ -20,6 +20,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/compliance-mode.spec.ts b/test/tools/compliance-mode.spec.ts new file mode 100644 index 0000000..65f6e9d --- /dev/null +++ b/test/tools/compliance-mode.spec.ts @@ -0,0 +1,916 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { FastMCP } from 'fastmcp'; +import { registerSurface } from '../../src/tools/register.js'; +import { + visibleSkills, + detectVisibleSkills, + COMPLIANT_SKILLS, + COMPLIANT_AGENT_METHODS, +} from '../../src/tools/compliance.js'; +import { + createSkillState, + skillsRegistry, + renderSkill, + validateMarkers, +} from '../../src/skills/index.js'; +import { buildSurfaceExtras } from '../../src/tools/agent.js'; +import { COMPLIANT_AGENT_SYSTEM_PROMPT } from '../../src/skills/system-prompt.js'; +import type { McpConfig, SkillId } from '../../src/@types/types.js'; + +const baseConfig: McpConfig = { + browserlessToken: 'test-token', + browserlessApiUrl: 'https://api.example.com', + transport: 'stdio', + port: 8080, + requestTimeout: 30000, + maxRetries: 0, + cacheTtlMs: 0, + analyticsEnabled: false, + complianceMode: false, + sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', + oauthAllowedRedirectUriPatterns: [], +}; + +type CapturedTool = { + name: string; + description: string; + parameters: { safeParse: (v: unknown) => { success: boolean; data?: any } }; +}; + +// Drive the REAL registration seam (src/tools/register.ts, which index.ts also +// calls) so this guard catches drift — e.g. a new circumvention tool registered +// unconditionally would change the compliant tool set and fail here. +function captureTools(complianceMode: boolean) { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const toolSpy = sinon.spy(server, 'addTool'); + const promptSpy = sinon.spy(server, 'addPrompt'); + const resourceSpy = sinon.spy(server, 'addResource'); + const config: McpConfig = { ...baseConfig, complianceMode }; + + registerSurface(server, config); + + const calls = toolSpy + .getCalls() + .map((c) => c.args[0] as unknown as CapturedTool); + return { + names: calls.map((t) => t.name).sort(), + byName: new Map(calls.map((t) => [t.name, t])), + promptNames: promptSpy + .getCalls() + .map((c) => (c.args[0] as { name: string }).name) + .sort(), + resourceUris: resourceSpy + .getCalls() + .map((c) => (c.args[0] as { uri: string }).uri) + .sort(), + }; +} + +const VALID_GOTO = { method: 'goto', params: { url: 'https://example.com' } }; + +describe('compliance mode — compliant tool surface', () => { + afterEach(() => sinon.restore()); + + it('registers exactly the 5 compliant tools (no smartscraper/function/map/crawl)', () => { + const { names } = captureTools(true); + expect(names).to.deep.equal([ + 'browserless_agent', + 'browserless_export', + 'browserless_performance', + 'browserless_search', + 'browserless_skill', + ]); + }); + + it('full mode registers exactly the 9 tools (regression guard)', () => { + const { names } = captureTools(false); + expect(names).to.deep.equal([ + 'browserless_agent', + 'browserless_crawl', + 'browserless_export', + 'browserless_function', + 'browserless_map', + 'browserless_performance', + 'browserless_search', + 'browserless_skill', + 'browserless_smartscraper', + ]); + }); + + describe('non-tool surface (prompts + resources)', () => { + it('compliant mode omits the smartscraper-centric prompts + api-docs resource', () => { + const { promptNames, resourceUris } = captureTools(true); + expect(promptNames, 'no scrape prompts').to.not.include.members([ + 'scrape-url', + 'extract-content', + ]); + expect(resourceUris, 'no api-docs').to.not.include( + 'browserless://api-docs', + ); + // status resource stays on both surfaces (it reports the active surface). + expect(resourceUris, 'status kept').to.include('browserless://status'); + }); + + it('full mode serves the prompts + api-docs resource', () => { + const { promptNames, resourceUris } = captureTools(false); + expect(promptNames).to.include.members(['scrape-url', 'extract-content']); + expect(resourceUris).to.include.members([ + 'browserless://api-docs', + 'browserless://status', + ]); + }); + }); + + describe('agent schema', () => { + it('accepts a navigation command batch', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect(agent.parameters.safeParse({ commands: [VALID_GOTO] }).success).to + .be.true; + }); + + it('rejects an empty or missing commands array', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect( + agent.parameters.safeParse({ commands: [] }).success, + 'empty commands', + ).to.be.false; + expect( + agent.parameters.safeParse({ rationale: 'x' }).success, + 'missing commands', + ).to.be.false; + }); + + it('rejects the circumvention commands (solve/evaluate/loadSecret)', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + for (const method of ['solve', 'evaluate', 'loadSecret']) { + expect( + agent.parameters.safeParse({ commands: [{ method, params: {} }] }) + .success, + `method ${method} must be rejected`, + ).to.be.false; + } + }); + + it('full mode accepts solve and evaluate (they are removed only in compliant)', () => { + const full = captureTools(false).byName.get('browserless_agent')!; + expect( + full.parameters.safeParse({ commands: [{ method: 'solve' }] }).success, + 'full accepts solve', + ).to.be.true; + expect( + full.parameters.safeParse({ + commands: [{ method: 'evaluate', params: { content: '1+1' } }], + }).success, + 'full accepts evaluate', + ).to.be.true; + }); + + it('compliant rejects an unknown method (no raw-BQL passthrough arm)', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect( + agent.parameters.safeParse({ + commands: [{ method: 'totally-unknown-method', params: {} }], + }).success, + ).to.be.false; + }); + + it('COMPLIANT_AGENT_METHODS is an EXACT allowlist (a new prohibited command added to the compliant union fails here)', () => { + // Mirrors the top-level EXPECTED_KEYS guard for the command-method + // dimension: navigation + read + interaction (click/type/select/etc. are + // legitimate automation, not circumvention); the prohibited classes — + // solve/evaluate/loadSecret and top-level proxy/profile — stay out. + const EXPECTED_METHODS = [ + 'goto', + 'back', + 'forward', + 'reload', + 'snapshot', + 'getTabs', + 'switchTab', + 'createTab', + 'closeTab', + 'click', + 'type', + 'select', + 'checkbox', + 'hover', + 'scroll', + 'text', + 'html', + 'waitForSelector', + 'waitForNavigation', + 'waitForTimeout', + 'waitForRequest', + 'waitForResponse', + 'liveURL', + 'screenshot', + 'close', + ]; + expect([...COMPLIANT_AGENT_METHODS]).to.have.members(EXPECTED_METHODS); + expect(COMPLIANT_AGENT_METHODS.size).to.equal(EXPECTED_METHODS.length); + }); + + it('rejects proxy / profile / createProfile / raw method passthrough (strict)', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect( + agent.parameters.safeParse({ commands: [VALID_GOTO], proxy: {} }) + .success, + ).to.be.false; + // profile hydrates a saved auth session — no auth-profile capability on + // the compliant surface (parity with the hidden auth-profile skill). + expect( + agent.parameters.safeParse({ + commands: [VALID_GOTO], + profile: 'my-profile', + }).success, + 'profile must be rejected', + ).to.be.false; + expect( + agent.parameters.safeParse({ + commands: [VALID_GOTO], + createProfile: {}, + }).success, + ).to.be.false; + expect( + agent.parameters.safeParse({ + method: 'goto', + params: { url: 'https://example.com' }, + }).success, + ).to.be.false; + }); + + it('full mode accepts profile (auth-profile capability removed only in compliant)', () => { + const full = captureTools(false).byName.get('browserless_agent')!; + expect( + full.parameters.safeParse({ + commands: [VALID_GOTO], + profile: 'my-profile', + }).success, + ).to.be.true; + }); + }); + + describe('skill tool', () => { + it('enum excludes captchas/autonomous-login/auth-profile, keeps others', () => { + const skill = captureTools(true).byName.get('browserless_skill')!; + for (const id of ['captchas', 'autonomous-login', 'auth-profile']) { + expect( + skill.parameters.safeParse({ id }).success, + `skill ${id} must be rejected`, + ).to.be.false; + } + expect(skill.parameters.safeParse({ id: 'shadow-dom' }).success).to.be + .true; + }); + + it('enum is an EXACT allowlist: every registry skill accepted iff in the approved eight', () => { + // Independent oracle — the approved eight hardcoded here, NOT derived from + // COMPLIANT_SKILLS (which also feeds the enum). An accidental expansion of + // COMPLIANT_SKILLS would otherwise move both sides in lockstep and pass; + // pinning to this literal makes it fail instead. + const APPROVED = [ + 'shadow-dom', + 'cookie-consent', + 'modals', + 'snapshot-misses', + 'dynamic-content', + 'screenshots', + 'tabs', + ]; + expect( + [...COMPLIANT_SKILLS].sort(), + 'COMPLIANT_SKILLS drifted from the approved eight', + ).to.deep.equal([...APPROVED].sort()); + + // Fail-closed lock: the schema accepts a registry skill iff it's approved + // (a denylist would let an un-classified skill through by default). + const approved = new Set(APPROVED); + const skill = captureTools(true).byName.get('browserless_skill')!; + for (const s of skillsRegistry) { + expect( + skill.parameters.safeParse({ id: s.id }).success, + `skill ${s.id}: accepted iff approved`, + ).to.equal(approved.has(s.id)); + } + }); + + it('description does not advertise captchas or autonomous-login', () => { + const skill = captureTools(true).byName.get('browserless_skill')!; + expect(skill.description).to.not.match(/captchas|autonomous-login/i); + }); + + it('description lists exactly the allowlisted skills (guards prose drift)', () => { + const desc = + captureTools(true).byName.get('browserless_skill')!.description; + for (const id of COMPLIANT_SKILLS) { + expect(desc, `description must mention ${id}`).to.contain(id); + } + // Every non-compliant skill must be absent — catches a skill dropped from + // COMPLIANT_SKILLS but left in the hand-kept prose (which would then lie). + for (const s of skillsRegistry) { + if (!COMPLIANT_SKILLS.has(s.id)) { + expect(desc, `description must not mention ${s.id}`).to.not.contain( + s.id, + ); + } + } + }); + + it('agent system prompt lists exactly the allowlisted skills (guards prompt drift)', () => { + // The de-fanged agent prompt hand-maintains the same skill list as + // COMPLIANT_SKILL_TOOL_DESCRIPTION. Guard it the same way so a skill + // added to / removed from COMPLIANT_SKILLS can't silently desync it. + for (const id of COMPLIANT_SKILLS) { + expect( + COMPLIANT_AGENT_SYSTEM_PROMPT, + `prompt must mention ${id}`, + ).to.contain(id); + } + for (const s of skillsRegistry) { + if (!COMPLIANT_SKILLS.has(s.id)) { + expect( + COMPLIANT_AGENT_SYSTEM_PROMPT, + `prompt must not mention ${s.id}`, + ).to.not.contain(s.id); + } + } + }); + + it('full mode keeps the excluded skills selectable (regression guard)', () => { + const skill = captureTools(false).byName.get('browserless_skill')!; + expect(skill.parameters.safeParse({ id: 'captchas' }).success).to.be.true; + }); + + it('compliant drops the `site` recipe lookup (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_skill')!; + const full = captureTools(false).byName.get('browserless_skill')!; + // compliant schema exposes only `id` — no `site` recipe param advertised + const shape = ( + conn.parameters as unknown as { shape?: Record } + ).shape; + expect(Object.keys(shape ?? {})).to.deep.equal(['id']); + // and rejects a `site` arg outright (strict) + expect(conn.parameters.safeParse({ site: 'ebay.com' }).success).to.be + .false; + expect( + conn.parameters.safeParse({ id: 'shadow-dom', site: 'ebay.com' }) + .success, + 'stray site rejected even with a valid id', + ).to.be.false; + // full resolves site recipes + expect(full.parameters.safeParse({ site: 'ebay.com' }).success).to.be + .true; + }); + }); + + describe('descriptions and dropped params', () => { + it('smartscraper is absent in compliant mode, present in full', () => { + expect(captureTools(true).byName.has('browserless_smartscraper')).to.be + .false; + expect(captureTools(false).byName.has('browserless_smartscraper')).to.be + .true; + }); + + it('search rejects scrapeOptions (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_search')!; + const full = captureTools(false).byName.get('browserless_search')!; + const withOpts = { query: 'q', scrapeOptions: { formats: ['markdown'] } }; + expect(conn.parameters.safeParse(withOpts).success, 'compliant rejects') + .to.be.false; + expect( + conn.parameters.safeParse({ query: 'q' }).success, + 'compliant accepts without it', + ).to.be.true; + expect(full.parameters.safeParse(withOpts).success, 'full keeps it').to.be + .true; + }); + + it('export rejects includeResources (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_export')!; + const full = captureTools(false).byName.get('browserless_export')!; + const withRes = { url: 'https://example.com', includeResources: true }; + expect(conn.parameters.safeParse(withRes).success, 'compliant rejects').to + .be.false; + expect( + conn.parameters.safeParse({ url: 'https://example.com' }).success, + 'compliant accepts without it', + ).to.be.true; + expect(full.parameters.safeParse(withRes).success, 'full keeps it').to.be + .true; + }); + + it('export rejects profile / auth-session hydration (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_export')!; + const full = captureTools(false).byName.get('browserless_export')!; + const withProfile = { url: 'https://example.com', profile: 'my-profile' }; + expect( + conn.parameters.safeParse(withProfile).success, + 'compliant rejects', + ).to.be.false; + expect(full.parameters.safeParse(withProfile).success, 'full keeps it').to + .be.true; + }); + + it('performance rejects profile / auth-session hydration (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_performance')!; + const full = captureTools(false).byName.get('browserless_performance')!; + const withProfile = { url: 'https://example.com', profile: 'my-profile' }; + expect( + conn.parameters.safeParse(withProfile).success, + 'compliant rejects', + ).to.be.false; + expect(full.parameters.safeParse(withProfile).success, 'full keeps it').to + .be.true; + }); + + it('serves the de-fanged compliant descriptions (what a directory reviewer reads)', () => { + const { byName } = captureTools(true); + // agent: explicit restrictive posture + expect(byName.get('browserless_agent')!.description).to.match( + /do not use to bypass access controls/i, + ); + // search: no per-result-scraping relay language + expect(byName.get('browserless_search')!.description).to.not.match( + /scrape each/i, + ); + // export: no bulk-asset / ZIP language + expect(byName.get('browserless_export')!.description).to.not.match( + /zip|bundle all|includeResources/i, + ); + }); + }); + + describe('run()-layer defense-in-depth (schema bypass)', () => { + const mockCtx = { + reportProgress: sinon.stub().resolves(), + log: { + debug: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + warn: sinon.stub(), + }, + session: undefined, + streamContent: sinon.stub().resolves(), + }; + + // Bypasses schema validation by calling execute() directly — the "schema + // mis-built" case the allowlist loop exists for and safeParse can't reach. + const compliantAgentExecute = () => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addTool'); + registerSurface(server, { ...baseConfig, complianceMode: true }); + const agent = spy + .getCalls() + .find((c) => c.args[0].name === 'browserless_agent')!.args[0] as { + execute: (a: unknown, c: unknown) => Promise; + }; + return agent.execute; + }; + + it('rejects an unknown (non-allowlisted) method even when the schema is bypassed', async () => { + // Unknown, not a known-prohibited method: a denylist would let this + // through, so accepting it would prove the run-layer guard is not the + // allowlist it claims to be. (evaluate is covered by the batch-index test + // below and the schema-level test above.) + const execute = compliantAgentExecute(); + try { + await execute( + { commands: [{ method: 'totally-unknown-method', params: {} }] }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('rejects a prohibited method at a NON-ZERO batch index (checks every command)', async () => { + const execute = compliantAgentExecute(); + try { + // evaluate at index 1 — a loop that only checked commands[0] would leak. + await execute( + { + commands: [ + { method: 'goto', params: { url: 'https://example.com' } }, + { method: 'evaluate', params: { content: '1' } }, + ], + }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('rejects an empty/absent method (no bare passthrough)', async () => { + const execute = compliantAgentExecute(); + try { + await execute({}, mockCtx); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('rejects proxy / auth profile / createProfile even when the schema is bypassed', async () => { + const execute = compliantAgentExecute(); + for (const extra of [ + { profile: 'my-profile' }, + { createProfile: { name: 'x' } }, + { proxy: { proxy: 'residential', proxyCountry: 'us' } }, + ]) { + try { + await execute({ commands: [VALID_GOTO], ...extra }, mockCtx); + expect.fail(`expected UserError for ${JSON.stringify(extra)}`); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + } + }); + + const compliantSkillExecute = () => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addTool'); + registerSurface(server, { ...baseConfig, complianceMode: true }); + const skill = spy + .getCalls() + .find((c) => c.args[0].name === 'browserless_skill')!.args[0] as { + execute: (a: unknown, c: unknown) => Promise; + }; + return skill.execute; + }; + + it('skill run() refuses a restricted recipe even when the enum is bypassed', async () => { + const execute = compliantSkillExecute(); + try { + await execute({ id: 'captchas' }, mockCtx); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + const compliantExecute = (toolName: string) => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addTool'); + registerSurface(server, { ...baseConfig, complianceMode: true }); + return spy.getCalls().find((c) => c.args[0].name === toolName)! + .args[0] as { + execute: (a: unknown, c: unknown) => Promise; + }; + }; + + it('search run() rejects scrapeOptions even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_search'); + try { + await execute({ query: 'x', scrapeOptions: {} }, mockCtx); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('export run() rejects includeResources even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_export'); + try { + await execute( + { url: 'https://example.com', includeResources: true }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('export run() rejects an auth profile even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_export'); + try { + await execute( + { url: 'https://example.com', profile: 'my-profile' }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('performance run() rejects an auth profile even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_performance'); + try { + await execute( + { url: 'https://example.com', profile: 'my-profile' }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + }); + + describe('compliant tool schemas are exact allowlists (drift guard)', () => { + // Exact top-level key-set per tool — NOT a forbidden-name denylist. A future + // edit that adds ANY top-level field (evasion or not, any name) to a + // compliant tool fails here, regardless of what it's called. + const EXPECTED_KEYS: Record = { + browserless_agent: ['commands', 'rationale'], + browserless_export: [ + 'bestAttempt', + 'gotoOptions', + 'timeout', + 'url', + 'waitForTimeout', + ], + browserless_performance: ['budgets', 'categories', 'timeout', 'url'], + browserless_search: [ + 'categories', + 'country', + 'lang', + 'limit', + 'location', + 'query', + 'sources', + 'tbs', + 'timeout', + ], + browserless_skill: ['id'], + }; + + it('each compliant tool exposes exactly its expected top-level keys', () => { + const { byName } = captureTools(true); + expect([...byName.keys()].sort(), 'compliant tool set').to.deep.equal( + Object.keys(EXPECTED_KEYS).sort(), + ); + for (const [name, tool] of byName) { + const shape = ( + tool.parameters as unknown as { shape?: Record } + ).shape; + // Fail loudly if a schema is ever wrapped (e.g. ZodEffects with no + // `.shape`), so the guard can't silently go vacuous. + expect(shape, `${name} schema must be introspectable (a ZodObject)`).to + .not.be.undefined; + expect( + Object.keys(shape ?? {}).sort(), + `${name} top-level keys`, + ).to.deep.equal(EXPECTED_KEYS[name]); + } + }); + + // Cross-tool invariant: the compliant surface exposes ZERO auth-profile + // capability. A `profile` param hydrates a saved session's cookies/ + // localStorage (see profileField) — auth-session injection a directory + // reviewer would flag. No compliant tool may expose it on any surface. + it('no compliant tool exposes a `profile` (auth-session) parameter', () => { + const { byName } = captureTools(true); + for (const [name, tool] of byName) { + const shape = ( + tool.parameters as unknown as { shape?: Record } + ).shape; + expect( + Object.keys(shape ?? {}), + `${name} must not expose profile`, + ).to.not.include('profile'); + } + }); + }); + + describe('status resource reports the active surface', () => { + // Load the status resource via its no-token early-return branch (no network), + // and assert the machine-readable `surface` attestation a directory/uptime + // check reads. Guards an inverted ternary or a dropped field. + const loadStatus = async (complianceMode: boolean) => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addResource'); + registerSurface(server, { + ...baseConfig, + browserlessToken: undefined, + complianceMode, + }); + const res = spy + .getCalls() + .find((c) => c.args[0].uri === 'browserless://status')!.args[0] as { + load: () => Promise<{ text: string }>; + }; + return JSON.parse((await res.load()).text); + }; + + it('reports surface "compliant" in compliant mode', async () => { + expect((await loadStatus(true)).surface).to.equal('compliant'); + }); + + it('reports surface "full" in full mode', async () => { + expect((await loadStatus(false)).surface).to.equal('full'); + }); + }); + + // Guards the skill AUTO-INJECTION path: even when detectSkills fires for a + // restricted recipe, visibleSkills must strip it in compliant mode so the + // recipe never lands in an agent reply. Full mode must keep every id. + describe('visibleSkills (auto-injection filter)', () => { + const triggered = [ + 'captchas', + 'shadow-dom', + 'autonomous-login', + 'modals', + 'auth-profile', + ] as SkillId[]; + + it('strips every restricted recipe in compliant mode', () => { + const out = visibleSkills(triggered, true); + expect(out).to.deep.equal(['shadow-dom', 'modals']); + for (const restricted of [ + 'captchas', + 'autonomous-login', + 'auth-profile', + ]) { + expect(out).to.not.include(restricted); + } + }); + + it('passes every id through in full mode', () => { + expect(visibleSkills(triggered, false)).to.deep.equal(triggered); + }); + + // Wiring guard: the leak path is a restricted recipe AUTO-FIRING on an + // allowed command (a login-page snapshot fires autonomous-login), which the + // enum/allowlist never see. detectVisibleSkills composes detectSkills + + // visibleSkills; if the compose is dropped, the recipe re-appears here. + const loginCtx = { + snapshot: { url: 'https://example.com/login', elements: [] }, + } as unknown as Parameters[0]; + + it('full mode auto-injects autonomous-login on a login page', () => { + expect( + detectVisibleSkills(loginCtx, createSkillState(), false), + ).to.include('autonomous-login'); + }); + + it('compliant mode strips the auto-fired autonomous-login recipe', () => { + expect( + detectVisibleSkills(loginCtx, createSkillState(), true), + ).to.not.include('autonomous-login'); + }); + }); + + describe('compliant skill BODIES are de-fanged', () => { + it('no allowlisted skill renders evaluate/captcha content in compliant mode', () => { + for (const id of COMPLIANT_SKILLS) { + const body = renderSkill(id, true); + expect(body, `${id} must not advise \`evaluate\``).to.not.match( + /\bevaluate\b/i, + ); + expect(body, `${id} must not list captcha selectors`).to.not.match( + /recaptcha|hcaptcha|turnstile|captcha/i, + ); + expect(body, `${id} marker comments must be stripped`).to.not.match( + /compliant-(omit|only)/, + ); + expect(body, `${id} still renders`).to.contain(`SKILL: ${id}`); + } + }); + + it('full mode retains the omitted content and strips both markers', () => { + const full = renderSkill('shadow-dom', false); + expect(full, 'full keeps evaluate guidance').to.match(/\bevaluate\b/); + expect(full, 'markers never leak to output').to.not.match( + /compliant-(omit|only)/, + ); + }); + + // Cutting the omitted path must not leave a listed problem with no remedy: + // the compliant-only block supplies the reduced-surface replacement. Assert + // it appears ONLY in compliant, so a revert of either marker is caught. + it('supplies compliant-only replacement guidance for the omitted paths', () => { + const cases: ReadonlyArray<[SkillId, RegExp]> = [ + ['snapshot-misses', /read the answer visually/i], + ['shadow-dom', /for interaction, not reading/i], + ]; + for (const [id, prose] of cases) { + expect( + renderSkill(id, true), + `${id} compliant render must include the replacement`, + ).to.match(prose); + expect( + renderSkill(id, false), + `${id} full render must not include the compliant-only block`, + ).to.not.match(prose); + } + }); + + // Regression guard: the omit and its compliant-only replacement share step + // numbers (2, 3) so exactly one is ever present — the recipe must read as a + // contiguous 1..N in BOTH modes, never a gap where a step was cut. + it('renders contiguous recipe numbering in both modes', () => { + for (const compliant of [true, false]) { + const body = renderSkill('snapshot-misses', compliant); + const steps = (body.match(/^\d+\. /gm) ?? []).map((s) => + parseInt(s, 10), + ); + expect(steps, `snapshot-misses steps (compliant=${compliant})`).to.eql([ + 1, 2, 3, 4, + ]); + } + }); + }); + + // The block strippers only match exact, balanced markers; a malformed one + // would silently retain a prohibited block in the compliant render. The + // load-time validator turns that latent per-render leak into a boot failure. + describe('marker validation is fail-closed (guards against a silent leak)', () => { + const okBody = 'a\n\nx\n\nb'; + + it('accepts well-formed omit and only blocks', () => { + expect(() => validateMarkers(okBody, 'f')).to.not.throw(); + expect(() => + validateMarkers( + '\ny\n', + 'f', + ), + ).to.not.throw(); + }); + + it('every shipped skill body passes (regression: files stay well-formed)', () => { + for (const s of skillsRegistry) { + expect(() => validateMarkers(s.body, s.path), s.id).to.not.throw(); + } + }); + + const bad: ReadonlyArray<[string, string, RegExp]> = [ + ['unclosed', '\nx', /unclosed/i], + ['close without open', 'x\n', /unbalanced/i], + [ + 'mismatched kind', + '\nx\n', + /unbalanced/i, + ], + [ + 'nested', + 'x', + /nested/i, + ], + [ + 'typo', + '\nx\n', + /malformed/i, + ], + [ + 'extra spacing', + '\nx\n', + /malformed/i, + ], + ]; + for (const [name, body, re] of bad) { + it(`throws on a ${name} marker`, () => { + expect(() => validateMarkers(body, 'f')).to.throw(re); + }); + } + }); + + // The agent reply path appends auto-injected skill bodies + a site-recipe + // pointer. Both must respect the surface: compliant de-fangs the skills and + // suppresses site recipes (which prescribe proxy/evaluate/login). Testing the + // pure builder locks both call-sites' `compliant` threading without a live + // session (a regression flipping either to the wrong branch fails here). + describe('agent reply extras respect the surface', () => { + const RECIPE_URL = 'https://airbnb.com'; // a host with a bundled site recipe + + it('compliant: de-fangs skills + suppresses site recipes', () => { + const { skills, siteNotice } = buildSurfaceExtras( + true, + ['shadow-dom'], + RECIPE_URL, + new Set(), + ); + expect(siteNotice, 'no site-recipe pointer on compliant').to.equal(''); + expect(skills, 'skill body de-fanged').to.not.match( + /\bevaluate\b|recaptcha|hcaptcha|turnstile/i, + ); + expect(skills.length, 'skill still rendered').to.be.greaterThan(200); + }); + + it('full: keeps site recipes + full skill guidance', () => { + const { skills, siteNotice } = buildSurfaceExtras( + false, + ['shadow-dom'], + RECIPE_URL, + new Set(), + ); + expect(siteNotice, 'site-recipe pointer surfaces on full').to.match( + /SITE RECIPE/i, + ); + expect(skills, 'full retains evaluate guidance').to.match( + /\bevaluate\b/i, + ); + }); + }); +}); diff --git a/test/tools/crawl.spec.ts b/test/tools/crawl.spec.ts index f54d463..8d708ed 100644 --- a/test/tools/crawl.spec.ts +++ b/test/tools/crawl.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/export.spec.ts b/test/tools/export.spec.ts index 54a7903..c1b1d55 100644 --- a/test/tools/export.spec.ts +++ b/test/tools/export.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/function.spec.ts b/test/tools/function.spec.ts index 426f1a8..893f9cd 100644 --- a/test/tools/function.spec.ts +++ b/test/tools/function.spec.ts @@ -19,6 +19,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/map.spec.ts b/test/tools/map.spec.ts index 843965f..c737738 100644 --- a/test/tools/map.spec.ts +++ b/test/tools/map.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/performance.spec.ts b/test/tools/performance.spec.ts index e67809e..c7d9356 100644 --- a/test/tools/performance.spec.ts +++ b/test/tools/performance.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/search.spec.ts b/test/tools/search.spec.ts index ea4a5da..5009e3c 100644 --- a/test/tools/search.spec.ts +++ b/test/tools/search.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/smartscraper.spec.ts b/test/tools/smartscraper.spec.ts index 80bbdd7..ad4928a 100644 --- a/test/tools/smartscraper.spec.ts +++ b/test/tools/smartscraper.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '',