From dc7b8d8a3527726fe2aecb6a57282bd775a63629 Mon Sep 17 00:00:00 2001 From: Erik Engervall Date: Mon, 5 Oct 2026 20:42:41 -0400 Subject: [PATCH 1/2] fix(mcp): load hosted scrape and search profiles without saving Hosted firecrawl_scrape and firecrawl_search are annotated readOnlyHint: true, but a named browser profile still saved its changes: the API defaults profile.saveChanges to true, and both tools forwarded saveChanges as given. The scrape description also still said browser actions can change the live page, though the hosted schema has no actions. In safe mode, the scrape profile and the search scrapeOptions profile now take only a name, and the server always sends saveChanges: false. A call shaped by an older tool definition that still sends saveChanges loads the profile and saves nothing. Both scrape descriptions say a profile loads without saving. Local mode, crawl and interact keep their existing profile contract, and interact remains the way to save browser state. Co-Authored-By: Claude --- README.md | 3 ++- docs/search-profile.md | 3 ++- src/index.ts | 33 +++++++++++++++++++++++------ tests/mcp-read-only-scrape.test.mjs | 16 ++++++++++---- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2e1f0bdc..b0103b18 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ Scrape content from a single URL with advanced options. **Branding format:** Extracts comprehensive brand identity (colors, fonts, typography, spacing, logo, UI components) for design analysis or style replication. **Privacy:** Set `redactPII: true` to return content with personally identifiable information redacted. -**Hosted server:** On the hosted server (`CLOUD_SERVICE=true`) scrape is read-only. It takes no browser `actions` and cannot accept provider terms. A named `profile` loads saved browser state without saving changes to it. An organization admin accepts terms in the dashboard. +**Hosted server:** On the hosted server (`CLOUD_SERVICE=true`) scrape is read-only. It takes no browser `actions` and cannot accept provider terms. A named `profile` loads saved browser state without saving changes to it, and `firecrawl_search` treats `scrapeOptions.profile` the same way. To save browser state to a profile, open the page with `firecrawl_interact` (see below). An organization admin accepts terms in the dashboard. **Returns:** @@ -867,6 +867,7 @@ Interact with a fresh URL or with a page that was already opened by `firecrawl_s - Pass `url` to scrape and open a page for interaction in one MCP call. - Pass `scrapeId` to continue interacting with an existing scraped page. +- To save browser state (cookies, localStorage) to a named profile, pass `url` with `scrapeOptions: { "profile": { "name": "my-profile", "saveChanges": true } }`. The state is saved when `firecrawl_interact_stop` ends the session. - Pass exactly one of `url` or `scrapeId`, plus either `prompt` or `code`. **Usage Example:** diff --git a/docs/search-profile.md b/docs/search-profile.md index f93b8478..b4c8f272 100644 --- a/docs/search-profile.md +++ b/docs/search-profile.md @@ -70,7 +70,8 @@ same way. Alexandria results on this surface carry no `feedbackTool` pointer, since `firecrawl_feedback` is not registered here. `firecrawl_scrape` is read-only here (`readOnlyHint: true`): the surface runs in -hosted safe mode, so it takes no browser `actions`. Provider terms can be read +hosted safe mode, so it takes no browser `actions`, and a named `profile` loads +saved browser state without saving changes to it. Provider terms can be read with the nested `terms/show` capability. As on the full surface, an organization admin accepts them in the dashboard: `firecrawl_scrape` refuses every other `terms/*` capability, and terms errors link to `requiresAction.url` or diff --git a/src/index.ts b/src/index.ts index 04302253..d4f65d66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2268,6 +2268,23 @@ const scrapeParamsSchema = z.object({ .optional(), }); +// In safe mode firecrawl_scrape and firecrawl_search are read-only, so a named +// profile they open loads saved browser state without writing it back. The API +// saves profile changes unless told otherwise, so saveChanges: false is sent +// explicitly. firecrawl_interact (url with scrapeOptions.profile) saves them. +const readOnlyProfileSchema = z + .object({ name: z.string() }) + .describe('Loads a saved browser profile without saving changes to it.'); + +function withReadOnlyProfile( + options: Record +): Record { + const profile = options.profile as { name: string } | undefined; + return SAFE_MODE && profile + ? { ...options, profile: { name: profile.name, saveChanges: false } } + : options; +} + // firecrawl_scrape accepts either a page URL or an Exchange batch. The base // schema stays url-required because search, crawl, and monitor reuse it for // nested scrapeOptions, where `alexandria` has no meaning. @@ -2275,6 +2292,7 @@ const ALEXANDRIA_IGNORED_SCRAPE_OPTIONS = new Set(['toolDetail', 'domainTools']) const scrapeToolParamsSchema = scrapeParamsSchema .extend({ + ...(SAFE_MODE ? { profile: readOnlyProfileSchema.optional() } : {}), url: z.string().url().optional(), timeout: z.number().int().positive().optional().describe("Execution timeout in milliseconds."), requestId: z @@ -2720,8 +2738,8 @@ const scrapeTool: RegisteredTool = { name: 'firecrawl_scrape', annotations: { title: 'Firecrawl scrape', - // Hosted scrape omits browser actions and refuses provider terms writes - // before execution. + // Hosted scrape omits browser actions, loads profiles without saving, + // and refuses provider terms writes before execution. readOnlyHint: SAFE_MODE, openWorldHint: true, // Accepts any user-supplied URL on the public web. destructiveHint: false, // Does not modify, delete, or write to external websites. @@ -2729,7 +2747,7 @@ const scrapeTool: RegisteredTool = { description: ` Scrape one URL and return its content: markdown by default, or HTML, links, screenshots, branding data, a targeted answer, or JSON matching a supplied schema. Use it when the request identifies a page and needs its content or defined fields. Use \`firecrawl_search\` when additional web sources are needed; on an authenticated session, \`firecrawl_map\` lists a site's URLs and \`firecrawl_crawl\` collects a set of pages. -Firecrawl may serve recently indexed content; set \`maxAge: 0\` for a live fetch or a smaller \`maxAge\` to bound staleness. A successful response does not by itself confirm the page is still current. Browser actions can change the live page when interactive actions are enabled. Authenticated responses can include a \`metadata.scrapeId\` for optional scrape feedback. +Firecrawl may serve recently indexed content; set \`maxAge: 0\` for a live fetch or a smaller \`maxAge\` to bound staleness. A successful response does not by itself confirm the page is still current. ${SAFE_MODE ? 'A named browser profile loads saved session data without saving changes to it.' : 'Browser actions can change the live page when interactive actions are enabled.'} Authenticated responses can include a \`metadata.scrapeId\` for optional scrape feedback. On an authenticated session with Alexandria access, \`firecrawl_search\` with \`sources\` unset and \`firecrawl_find_tools\` can discover providers for the same fields across several pages; a matching provider returns typed records in one call. Keyless sessions have no provider matches. @@ -2764,7 +2782,7 @@ Alexandria mode, on an authenticated session with Alexandria access: \`alexandri const transformed = transformScrapeParams( options as Record ); - const cleaned = removeEmptyTopLevel(transformed); + const cleaned = withReadOnlyProfile(removeEmptyTopLevel(transformed)); if (cleaned.lockdown) { log.info('Scraping URL (lockdown)'); } else { @@ -2868,6 +2886,7 @@ For a programming question, add \`categories: ["developer"]\`; its hits return i ...searchToolBaseFields, scrapeOptions: scrapeParamsSchema .omit({ url: true }) + .extend(SAFE_MODE ? { profile: readOnlyProfileSchema } : {}) .partial() .optional() .describe('Attach page content for web results in the same call. These fetches ignore maxAge, so use firecrawl_scrape when you need a live fetch. scrapeOptions fetches web pages, never Alexandria provider tools.'), @@ -2890,8 +2909,8 @@ For a programming question, add \`categories: ["developer"]\`; its hits return i searchOpts.toolDetail ??= 'compact'; if (searchOpts.scrapeOptions) { - searchOpts.scrapeOptions = transformScrapeParams( - searchOpts.scrapeOptions as Record + searchOpts.scrapeOptions = withReadOnlyProfile( + transformScrapeParams(searchOpts.scrapeOptions as Record) ); } @@ -3035,7 +3054,7 @@ server.addTool(findToolsTool); // (no crawl, map, interact, monitor, parse or feedback references). Registered // on the search surface in place of the module-level tools above. const SEARCH_SURFACE_SCRAPE_DESCRIPTION = ` -Scrape one URL and return its content, or execute catalogued Alexandria capabilities. URL mode returns markdown by default, or HTML, links, screenshots, branding data, a targeted answer, or JSON matching a supplied schema, plus page metadata. Firecrawl may serve recently indexed content; set \`maxAge: 0\` for a live fetch. A successful response does not by itself confirm the page is still current. A named browser profile loads saved session data. +Scrape one URL and return its content, or execute catalogued Alexandria capabilities. URL mode returns markdown by default, or HTML, links, screenshots, branding data, a targeted answer, or JSON matching a supplied schema, plus page metadata. Firecrawl may serve recently indexed content; set \`maxAge: 0\` for a live fetch. A successful response does not by itself confirm the page is still current. ${SAFE_MODE ? 'A named browser profile loads saved session data without saving changes to it.' : 'Browser actions can change the live page, and a named browser profile can load saved session data and overwrite its stored state.'} \`firecrawl_search\` with \`sources\` unset and \`firecrawl_find_tools\` can discover providers for the same fields across several pages; a matching Alexandria provider returns typed records in one call. diff --git a/tests/mcp-read-only-scrape.test.mjs b/tests/mcp-read-only-scrape.test.mjs index f1ea9f7a..558b936d 100644 --- a/tests/mcp-read-only-scrape.test.mjs +++ b/tests/mcp-read-only-scrape.test.mjs @@ -66,7 +66,7 @@ async function startHosted(t) { return { api, port, searchPort }; } -test('hosted scrape is read-only and preserves existing profile and search options', async (t) => { +test('hosted scrape and search are read-only and load profiles without saving', async (t) => { const { api, port, searchPort } = await startHosted(t); const headers = { 'x-api-key': 'fc-hosted-test' }; for (const [label, endpoint, surfacePort] of [ @@ -78,13 +78,20 @@ test('hosted scrape is read-only and preserves existing profile and search optio assert.equal(scrape.annotations.readOnlyHint, true, label); assert.equal(scrape.annotations.destructiveHint, false, label); assert.equal(scrape.inputSchema.properties.actions, undefined, `${label}: no browser actions`); - assert.doesNotMatch(scrape.description, /overwrite its stored state/, label); + assert.doesNotMatch(scrape.description, /overwrite its stored state|Browser actions/, label); + assert.match(scrape.description, /without saving changes/, label); + assert.deepEqual(Object.keys(scrape.inputSchema.properties.profile.properties), ['name'], `${label}: profile takes only a name`); } const { tools } = await httpSession(port, '/v2/mcp', headers); const byName = new Map(tools.map((tool) => [tool.name, tool])); assert.equal(byName.get('firecrawl_agent').annotations.readOnlyHint, false); assert.equal(byName.get('firecrawl_search').annotations.readOnlyHint, true); + assert.deepEqual( + Object.keys(byName.get('firecrawl_search').inputSchema.properties.scrapeOptions.properties.profile.properties), + ['name'], + 'search scrapeOptions profile takes only a name' + ); assert.equal(tools.some((tool) => /terms/.test(tool.name)), false, 'no tool accepts terms'); for (const [surfacePort, endpoint] of [[port, '/v2/mcp'], [searchPort, '/v2/mcp-search']]) { @@ -99,7 +106,8 @@ test('hosted scrape is read-only and preserves existing profile and search optio headers, }); assert.notEqual(scraped.isError, true, JSON.stringify(scraped)); - assert.deepEqual(api.requests.at(-1).body.profile, profile, 'profile options remain unchanged'); + // A call shaped by an older tool definition may still send saveChanges. + assert.deepEqual(api.requests.at(-1).body.profile, { name: 'saved-login', saveChanges: false }, 'profile never saves'); assert.equal(api.requests.at(-1).body.actions, undefined); } } @@ -114,7 +122,7 @@ test('hosted scrape is read-only and preserves existing profile and search optio headers, }); assert.notEqual(searched.isError, true, JSON.stringify(searched)); - assert.deepEqual(api.requests.at(-1).body.scrapeOptions.profile, { name: 'saved-login', saveChanges: true }); + assert.deepEqual(api.requests.at(-1).body.scrapeOptions.profile, { name: 'saved-login', saveChanges: false }); assert.equal(api.requests.at(-1).body.scrapeOptions.actions, undefined); const { client } = await startStdio(t, { CLOUD_SERVICE: 'false', FIRECRAWL_API_KEY: 'fc-test', FIRECRAWL_API_URL: api.url }); From c61b02ca0282b73c3d7c5a2a011c87a2362e3e5b Mon Sep 17 00:00:00 2001 From: Erik Engervall Date: Mon, 5 Oct 2026 21:28:47 -0400 Subject: [PATCH 2/2] docs(readme): scope the search profile note to the full endpoint Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0103b18..752951ed 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ Scrape content from a single URL with advanced options. **Branding format:** Extracts comprehensive brand identity (colors, fonts, typography, spacing, logo, UI components) for design analysis or style replication. **Privacy:** Set `redactPII: true` to return content with personally identifiable information redacted. -**Hosted server:** On the hosted server (`CLOUD_SERVICE=true`) scrape is read-only. It takes no browser `actions` and cannot accept provider terms. A named `profile` loads saved browser state without saving changes to it, and `firecrawl_search` treats `scrapeOptions.profile` the same way. To save browser state to a profile, open the page with `firecrawl_interact` (see below). An organization admin accepts terms in the dashboard. +**Hosted server:** On the hosted server (`CLOUD_SERVICE=true`) scrape is read-only. It takes no browser `actions` and cannot accept provider terms. A named `profile` loads saved browser state without saving changes to it, and the full endpoint's `firecrawl_search` treats `scrapeOptions.profile` the same way. To save browser state to a profile, open the page with `firecrawl_interact` (see below). An organization admin accepts terms in the dashboard. **Returns:**