From 6dbe34176801276f7c546d36fa2316996596958e Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Fri, 25 Sep 2026 17:59:06 +1000 Subject: [PATCH 1/8] feat(agent): add onTermsRequired to firecrawl_agent The agent service now only calls Alexandria providers whose data terms the team has accepted, and reports the rest. Let MCP callers choose what happens ("skip", "ask" or "fail"), forwarded as exchange.onTermsRequired. Describe exchange.skippedProviders and exchange.requiresAction in the tool description, and tell calling agents they must get the user's explicit consent before calling terms/accept. Keep exchange, pendingApproval and message in firecrawl_agent_status structured content so Codex-style clients that read structuredContent do not lose them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 6 ++ src/index.ts | 10 ++++ src/tool-output.ts | 7 +++ tests/mcp-smoke.test.mjs | 121 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43da41e4..19e7f739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. +- `firecrawl_agent` accepts `onTermsRequired` (`"skip"`, `"ask"` or `"fail"`), forwarded as `exchange.onTermsRequired`. The agent only calls Alexandria providers whose data terms the team has accepted; `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders`, `requiresAction` and `error`), `pendingApproval` and `message` in its structured content. There is no auto-accept: `terms/accept` still needs the user's explicit consent. ### Changed diff --git a/README.md b/README.md index 98d268c4..c6c375a6 100644 --- a/README.md +++ b/README.md @@ -735,6 +735,12 @@ The agent performs web searches, follows links, reads pages, and gathers data au - `prompt`: Natural language description of the data you want (required, max 10,000 characters) - `urls`: Optional array of URLs to focus the agent on specific pages - `schema`: Optional JSON schema for structured output +- `onTermsRequired`: Optional. What to do when an Alexandria provider the agent would use needs data terms your team has not accepted. Gated providers are never called in any mode. + - `"skip"` (default): answer with accepted providers only. `exchange.skippedProviders` on the status result lists the gated providers that would have helped. + - `"ask"`: the same, plus `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. + - `"fail"`: stop making calls once a gated provider is needed, and set `exchange.error` to `THIRD_PARTY_DATA_TERMS_REQUIRED`. + +**Provider terms:** there is no auto-accept mode. Only call `terms/accept` (through `firecrawl_scrape` with `alexandria`) after the user has explicitly agreed to that provider's terms; a data request is not consent. Once accepted, start `firecrawl_agent` again and the provider becomes available. **Prompt Example:** diff --git a/src/index.ts b/src/index.ts index 4378a9eb..e0894137 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3527,6 +3527,8 @@ server.addTool({ Run web research that returns structured data when the URLs are not known or the answer spans several sites. Describe the fields you need in \`prompt\`, optionally pass a JSON \`schema\` and seed \`urls\`, and the research agent searches, navigates, reads pages, and returns JSON assembled across sources. Use it to research an entity plus its fields (founders, pricing, contact details), to build lists and datasets (companies, people, products, jobs, papers), and for pages that need navigation or interaction to reach the data. Optional \`effort\` sets the reasoning budget, \`maxCredits\` caps spend, and \`strictConstrainToURLs\` keeps the agent to the supplied \`urls\`. This call returns only a job ID, not the research result. Read the job with \`firecrawl_agent_status\` until it reaches \`completed\` or \`failed\`; a typical research run takes one to three minutes. For one known URL use \`firecrawl_scrape\` (with formats: ["json"] for structured output); for a plain lookup that a results page answers, use \`firecrawl_search\`. + +The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped, and with \`onTermsRequired\` "ask" or "fail", \`exchange.requiresAction\` holds the exact terms/show and terms/accept calls. Never call terms/accept without the user's explicit consent to that provider's terms; a data request is not consent. After they agree, run the accept call through \`firecrawl_scrape\` and start \`firecrawl_agent\` again. `, outputSchema: agentOutputSchema, parameters: z.object({ @@ -3551,6 +3553,12 @@ This call returns only a job ID, not the research result. Read the job with \`fi .describe( 'If true, agent will only visit URLs provided in the urls array.' ), + onTermsRequired: z + .enum(['skip', 'ask', 'fail']) + .optional() + .describe( + 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus exchange.requiresAction with the terms/show and terms/accept calls. "fail": stop making calls once a gated provider is needed and set exchange.error (THIRD_PARTY_DATA_TERMS_REQUIRED). There is no auto-accept.' + ), }), execute: async ( args: unknown, @@ -3562,6 +3570,7 @@ This call returns only a job ID, not the research result. Read the job with \`fi prompt: (a.prompt as string).substring(0, 100), urlCount: Array.isArray(a.urls) ? a.urls.length : 0, }); + const onTermsRequired = a.onTermsRequired as 'skip' | 'ask' | 'fail' | undefined; const agentBody = removeEmptyTopLevel({ prompt: a.prompt as string, urls: a.urls as string[] | undefined, @@ -3569,6 +3578,7 @@ This call returns only a job ID, not the research result. Read the job with \`fi effort: a.effort as 'low' | 'medium' | 'high' | undefined, maxCredits: a.maxCredits as number | undefined, strictConstrainToURLs: a.strictConstrainToURLs as boolean | undefined, + exchange: onTermsRequired ? { onTermsRequired } : undefined, }); const res = await (client as any).startAgent({ ...agentBody, diff --git a/src/tool-output.ts b/src/tool-output.ts index ce99a886..12c14d39 100644 --- a/src/tool-output.ts +++ b/src/tool-output.ts @@ -234,6 +234,13 @@ export const agentStatusOutputSchema = z mode: str('Agent mode the job ran in.'), threadId: str('Research thread this job belongs to.'), threadTurn: num('Turn number of this job within its thread.'), + message: unknown('The agent\'s reply, including what it could not answer.'), + exchange: unknown( + 'What the job did with Alexandria providers: onTermsRequired, paidCalls, creditsUsed, skippedProviders (gated providers that would have helped), requiresAction (terms/show and terms/accept calls; call accept only with the user\'s explicit consent) and error (THIRD_PARTY_DATA_TERMS_REQUIRED in "fail" mode).' + ), + pendingApproval: unknown( + 'Set when the job ended waiting on the caller; kind "terms" lists providers whose data terms need accepting.' + ), }) .describe('Progress or final result of a research agent job.'); diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 74a9c611..85ba7a0d 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -268,6 +268,65 @@ async function startFakeFirecrawlApi() { return; } + if (req.method === 'GET' && req.url === '/v2/agent/00000000-0000-4000-8000-000000000032') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + data: null, + expiresAt: '2026-10-01T00:00:00.000Z', + message: 'Apollo could add verified work emails.', + mode: 'chat', + model: 'spark-2', + status: 'completed', + success: true, + exchange: { + enabled: true, + onTermsRequired: 'ask', + paidCalls: 0, + creditsUsed: null, + skippedProviders: [ + { + provider: 'apollo', + name: 'Apollo', + capability: 'people/search', + reason: 'terms_required', + version: 'F-1.0.0', + termsUrl: 'https://www.firecrawl.dev/app/alexandria/apollo', + }, + ], + requiresAction: { + type: 'accept_terms', + approvalId: '00000000-0000-4000-8000-000000000033', + providers: [ + { + id: 'apollo', + provider: 'apollo', + name: 'Apollo', + version: 'F-1.0.0', + url: 'https://www.firecrawl.dev/app/alexandria/apollo', + show: { provider: 'firecrawl', capability: 'terms/show', options: { provider: 'apollo' } }, + accept: { + provider: 'firecrawl', + capability: 'terms/accept', + options: { provider: 'apollo', version: 'F-1.0.0', digest: null, confirmed: true }, + }, + }, + ], + }, + }, + pendingApproval: { + id: '00000000-0000-4000-8000-000000000033', + kind: 'terms', + reason: 'Apollo could add verified work emails.', + calls: [], + terms: [{ id: 'apollo', provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', url: 'https://www.firecrawl.dev/app/alexandria/apollo' }], + resolution: null, + }, + }) + ); + return; + } + if (req.method === 'POST' && req.url === '/v2/map') { res.writeHead(200, { 'content-type': 'application/json' }); res.end( @@ -3895,3 +3954,65 @@ test('firecrawl_agent forwards effort, maxCredits and strictConstrainToURLs to / ); assert.equal(fakeApi.requests.filter((request) => request.url === '/v2/agent').length, sentBefore); }); + +test('firecrawl_agent forwards onTermsRequired and status keeps the terms-required fields', async (t) => { + const fakeApi = await startFakeFirecrawlApi(); + t.after(() => fakeApi.close()); + + const child = spawnServer({ + FIRECRAWL_API_KEY: 'fc-test', + FIRECRAWL_API_URL: fakeApi.url, + }); + t.after(() => stopChild(child)); + + const client = new StdioMcpClient(child); + await client.request('initialize', { + capabilities: {}, + clientInfo: { name: 'firecrawl-mcp-terms-required', version: '0.0.0' }, + protocolVersion: '2025-06-18', + }); + client.notify('notifications/initialized'); + + const { tools } = await client.request('tools/list'); + const agentTool = tools.find((tool) => tool.name === 'firecrawl_agent'); + assert.deepEqual(agentTool.inputSchema.properties.onTermsRequired.enum, ['skip', 'ask', 'fail']); + assert.match(agentTool.description, /exchange\.skippedProviders/); + assert.match(agentTool.description, /exchange\.requiresAction/); + assert.match(agentTool.description, /Never call terms\/accept without the user's explicit consent/); + + const asked = await client.request('tools/call', { + arguments: { prompt: 'Find the key business contact at exa.ai', onTermsRequired: 'ask' }, + name: 'firecrawl_agent', + }); + assert.notEqual(asked.isError, true); + const plain = await client.request('tools/call', { + arguments: { prompt: 'Find the example domain owner' }, + name: 'firecrawl_agent', + }); + assert.notEqual(plain.isError, true); + const bodies = fakeApi.requests + .filter((request) => request.method === 'POST' && request.url === '/v2/agent') + .map((request) => request.body); + assert.deepEqual(bodies[0].exchange, { onTermsRequired: 'ask' }); + assert.equal('exchange' in bodies[1], false); + + // There is no auto-accept mode: any other value fails parameter validation. + await assert.rejects( + client.request('tools/call', { + arguments: { prompt: 'Find the key business contact at exa.ai', onTermsRequired: 'accept' }, + name: 'firecrawl_agent', + }), + /onTermsRequired/ + ); + + const status = await client.request('tools/call', { + arguments: { id: '00000000-0000-4000-8000-000000000032' }, + name: 'firecrawl_agent_status', + }); + assert.notEqual(status.isError, true); + const structured = status.structuredContent; + assert.equal(structured.exchange.skippedProviders[0].reason, 'terms_required'); + assert.equal(structured.exchange.requiresAction.providers[0].accept.capability, 'terms/accept'); + assert.equal(structured.pendingApproval.kind, 'terms'); + assert.equal(structured.message, 'Apollo could add verified work emails.'); +}); From e32eda1733ac8898f38c1291739c9acbd9ca7958 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sat, 26 Sep 2026 01:47:05 +1000 Subject: [PATCH 2/8] feat(agent): cut fail mode from onTermsRequired; digest is string | null Matches the extract-v3#182 scope cut: onTermsRequired is skip or ask, and exchange.error is gone. Describe each requiresAction provider digest as string | null and always present. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 3 +-- src/index.ts | 8 ++++---- src/tool-output.ts | 2 +- tests/mcp-smoke.test.mjs | 7 ++++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e7f739..b1029e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. -- `firecrawl_agent` accepts `onTermsRequired` (`"skip"`, `"ask"` or `"fail"`), forwarded as `exchange.onTermsRequired`. The agent only calls Alexandria providers whose data terms the team has accepted; `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders`, `requiresAction` and `error`), `pendingApproval` and `message` in its structured content. There is no auto-accept: `terms/accept` still needs the user's explicit consent. +- `firecrawl_agent` accepts `onTermsRequired` (`"skip"` or `"ask"`), forwarded as `exchange.onTermsRequired`. The agent only calls Alexandria providers whose data terms the team has accepted; `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval` and `message` in its structured content. There is no auto-accept: `terms/accept` still needs the user's explicit consent. ### Changed diff --git a/README.md b/README.md index c6c375a6..c623dd41 100644 --- a/README.md +++ b/README.md @@ -737,8 +737,7 @@ The agent performs web searches, follows links, reads pages, and gathers data au - `schema`: Optional JSON schema for structured output - `onTermsRequired`: Optional. What to do when an Alexandria provider the agent would use needs data terms your team has not accepted. Gated providers are never called in any mode. - `"skip"` (default): answer with accepted providers only. `exchange.skippedProviders` on the status result lists the gated providers that would have helped. - - `"ask"`: the same, plus `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. - - `"fail"`: stop making calls once a gated provider is needed, and set `exchange.error` to `THIRD_PARTY_DATA_TERMS_REQUIRED`. + - `"ask"`: the same, plus `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. Each provider's `digest` is always present and is `string | null`; when it is `null`, `terms/show` returns the current digest to send. **Provider terms:** there is no auto-accept mode. Only call `terms/accept` (through `firecrawl_scrape` with `alexandria`) after the user has explicitly agreed to that provider's terms; a data request is not consent. Once accepted, start `firecrawl_agent` again and the provider becomes available. diff --git a/src/index.ts b/src/index.ts index e0894137..71035b2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3528,7 +3528,7 @@ Run web research that returns structured data when the URLs are not known or the This call returns only a job ID, not the research result. Read the job with \`firecrawl_agent_status\` until it reaches \`completed\` or \`failed\`; a typical research run takes one to three minutes. For one known URL use \`firecrawl_scrape\` (with formats: ["json"] for structured output); for a plain lookup that a results page answers, use \`firecrawl_search\`. -The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped, and with \`onTermsRequired\` "ask" or "fail", \`exchange.requiresAction\` holds the exact terms/show and terms/accept calls. Never call terms/accept without the user's explicit consent to that provider's terms; a data request is not consent. After they agree, run the accept call through \`firecrawl_scrape\` and start \`firecrawl_agent\` again. +The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped, and with \`onTermsRequired\` "ask", \`exchange.requiresAction\` holds the exact terms/show and terms/accept calls. Never call terms/accept without the user's explicit consent to that provider's terms; a data request is not consent. After they agree, run the accept call through \`firecrawl_scrape\` and start \`firecrawl_agent\` again. `, outputSchema: agentOutputSchema, parameters: z.object({ @@ -3554,10 +3554,10 @@ The agent only calls Alexandria providers whose data terms the team has accepted 'If true, agent will only visit URLs provided in the urls array.' ), onTermsRequired: z - .enum(['skip', 'ask', 'fail']) + .enum(['skip', 'ask']) .optional() .describe( - 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus exchange.requiresAction with the terms/show and terms/accept calls. "fail": stop making calls once a gated provider is needed and set exchange.error (THIRD_PARTY_DATA_TERMS_REQUIRED). There is no auto-accept.' + 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus exchange.requiresAction with the terms/show and terms/accept calls. Each provider digest is string | null and always present; when null, terms/show returns it. There is no auto-accept.' ), }), execute: async ( @@ -3570,7 +3570,7 @@ The agent only calls Alexandria providers whose data terms the team has accepted prompt: (a.prompt as string).substring(0, 100), urlCount: Array.isArray(a.urls) ? a.urls.length : 0, }); - const onTermsRequired = a.onTermsRequired as 'skip' | 'ask' | 'fail' | undefined; + const onTermsRequired = a.onTermsRequired as 'skip' | 'ask' | undefined; const agentBody = removeEmptyTopLevel({ prompt: a.prompt as string, urls: a.urls as string[] | undefined, diff --git a/src/tool-output.ts b/src/tool-output.ts index 12c14d39..663387b7 100644 --- a/src/tool-output.ts +++ b/src/tool-output.ts @@ -236,7 +236,7 @@ export const agentStatusOutputSchema = z threadTurn: num('Turn number of this job within its thread.'), message: unknown('The agent\'s reply, including what it could not answer.'), exchange: unknown( - 'What the job did with Alexandria providers: onTermsRequired, paidCalls, creditsUsed, skippedProviders (gated providers that would have helped), requiresAction (terms/show and terms/accept calls; call accept only with the user\'s explicit consent) and error (THIRD_PARTY_DATA_TERMS_REQUIRED in "fail" mode).' + 'What the job did with Alexandria providers: onTermsRequired, paidCalls, creditsUsed, skippedProviders (gated providers that would have helped), and requiresAction (terms/show and terms/accept calls, each provider digest string | null and always present; call accept only with the user\'s explicit consent).' ), pendingApproval: unknown( 'Set when the job ended waiting on the caller; kind "terms" lists providers whose data terms need accepting.' diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 85ba7a0d..3229b47a 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -303,6 +303,7 @@ async function startFakeFirecrawlApi() { provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', + digest: null, url: 'https://www.firecrawl.dev/app/alexandria/apollo', show: { provider: 'firecrawl', capability: 'terms/show', options: { provider: 'apollo' } }, accept: { @@ -319,7 +320,7 @@ async function startFakeFirecrawlApi() { kind: 'terms', reason: 'Apollo could add verified work emails.', calls: [], - terms: [{ id: 'apollo', provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', url: 'https://www.firecrawl.dev/app/alexandria/apollo' }], + terms: [{ id: 'apollo', provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', digest: null, url: 'https://www.firecrawl.dev/app/alexandria/apollo' }], resolution: null, }, }) @@ -3975,7 +3976,7 @@ test('firecrawl_agent forwards onTermsRequired and status keeps the terms-requir const { tools } = await client.request('tools/list'); const agentTool = tools.find((tool) => tool.name === 'firecrawl_agent'); - assert.deepEqual(agentTool.inputSchema.properties.onTermsRequired.enum, ['skip', 'ask', 'fail']); + assert.deepEqual(agentTool.inputSchema.properties.onTermsRequired.enum, ['skip', 'ask']); assert.match(agentTool.description, /exchange\.skippedProviders/); assert.match(agentTool.description, /exchange\.requiresAction/); assert.match(agentTool.description, /Never call terms\/accept without the user's explicit consent/); @@ -3999,7 +4000,7 @@ test('firecrawl_agent forwards onTermsRequired and status keeps the terms-requir // There is no auto-accept mode: any other value fails parameter validation. await assert.rejects( client.request('tools/call', { - arguments: { prompt: 'Find the key business contact at exa.ai', onTermsRequired: 'accept' }, + arguments: { prompt: 'Find the key business contact at exa.ai', onTermsRequired: 'fail' }, name: 'firecrawl_agent', }), /onTermsRequired/ From f51b01031b90b8eb27836b9d202c26b11a99b443 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sat, 26 Sep 2026 01:51:34 +1000 Subject: [PATCH 3/8] test(agent): drop the provider id the final backend contract no longer sends Co-Authored-By: Claude Opus 5.5 --- tests/mcp-smoke.test.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 3229b47a..62d23b9f 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -299,7 +299,6 @@ async function startFakeFirecrawlApi() { approvalId: '00000000-0000-4000-8000-000000000033', providers: [ { - id: 'apollo', provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', @@ -320,7 +319,7 @@ async function startFakeFirecrawlApi() { kind: 'terms', reason: 'Apollo could add verified work emails.', calls: [], - terms: [{ id: 'apollo', provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', digest: null, url: 'https://www.firecrawl.dev/app/alexandria/apollo' }], + terms: [{ provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', digest: null, url: 'https://www.firecrawl.dev/app/alexandria/apollo' }], resolution: null, }, }) From 7c997a0e705249ecb58afa2dba516eefed527df5 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sat, 26 Sep 2026 03:16:39 +1000 Subject: [PATCH 4/8] feat(agent): continue a thread and answer a pending approval from firecrawl_agent firecrawl_agent now takes threadId, mode and an exchange object that mirrors the gateway's agentExchangeSchema (enabled, toolkits, maxCalls, requireApproval, approve, decline, onTermsRequired). After an ask-mode terms offer and the user's explicit consent to terms/accept, a caller continues the same thread with exchange.approve: {approvalId} (or decline) instead of starting over. MCP-side guards: approve/decline need threadId, cannot be sent together, and the top-level onTermsRequired cannot disagree with exchange.onTermsRequired. Status structured content also keeps suggestions. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 32 +++++- src/index.ts | 116 +++++++++++++++++++-- src/tool-output.ts | 5 +- tests/mcp-smoke.test.mjs | 217 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 360 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1029e42..fb6e94fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. - `firecrawl_agent` accepts `onTermsRequired` (`"skip"` or `"ask"`), forwarded as `exchange.onTermsRequired`. The agent only calls Alexandria providers whose data terms the team has accepted; `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval` and `message` in its structured content. There is no auto-accept: `terms/accept` still needs the user's explicit consent. +- `firecrawl_agent` can continue a thread: it accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. `firecrawl_agent_status` also keeps `suggestions` in its structured content. ### Changed diff --git a/README.md b/README.md index c623dd41..72c535aa 100644 --- a/README.md +++ b/README.md @@ -739,7 +739,22 @@ The agent performs web searches, follows links, reads pages, and gathers data au - `"skip"` (default): answer with accepted providers only. `exchange.skippedProviders` on the status result lists the gated providers that would have helped. - `"ask"`: the same, plus `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. Each provider's `digest` is always present and is `string | null`; when it is `null`, `terms/show` returns the current digest to send. -**Provider terms:** there is no auto-accept mode. Only call `terms/accept` (through `firecrawl_scrape` with `alexandria`) after the user has explicitly agreed to that provider's terms; a data request is not consent. Once accepted, start `firecrawl_agent` again and the provider becomes available. +- `threadId`: Optional. Continue an existing thread: the `threadId` from an earlier `firecrawl_agent` or `firecrawl_agent_status` result. Omit to start a new thread. On a follow-up, omitted `mode`, `urls`, `schema` and `exchange` settings carry over from the previous turn. +- `mode`: Optional. `"extract"` (default) returns the complete structured result every turn. `"chat"` lets a follow-up that asks for no new data get a short reply in `message` instead of a re-run; `exchange.requireApproval` needs it. +- `exchange`: Optional. Alexandria provider settings for this turn, forwarded as-is to `POST /v2/agent`: + - `enabled`, `toolkits` (provider slugs), `maxCalls` (1 to 30), `requireApproval` (paid calls end the turn with a `pendingApproval`; needs `mode: "chat"`), `onTermsRequired` (same as the top-level argument; send one or the other) + - `approve`: `{ approvalId, callIds?, always? }` answers yes to the `pendingApproval` the previous turn ended on. `callIds` and `always` apply to paid-call approvals only. + - `decline`: `{ approvalId }` answers no. A declined terms offer keeps those providers out of the rest of the thread. + - `approve` and `decline` need `threadId`, and only one of them can be sent. + +**Provider terms (ask mode):** there is no auto-accept mode. When a turn ends on a terms offer, the status result carries `pendingApproval` (`kind: "terms"`) and `exchange.requiresAction` with the `approvalId` and the exact `terms/show` and `terms/accept` calls. To use the provider: + +1. Show the user the terms (`terms/show` through `firecrawl_scrape` with `alexandria`). +2. Get the user's explicit consent to that provider's terms. A data request is not consent. +3. Run the `terms/accept` call through `firecrawl_scrape`. +4. Continue the same thread: call `firecrawl_agent` with the same `threadId` and `exchange.approve: { "approvalId": "..." }`. + +If the user says no, call `firecrawl_agent` with the same `threadId` and `exchange.decline: { "approvalId": "..." }` instead. **Prompt Example:** @@ -786,9 +801,22 @@ Then poll with `firecrawl_agent_status` using the returned job ID. } ``` +**Usage Example (continue the thread after the user accepted a provider's terms):** + +```json +{ + "name": "firecrawl_agent", + "arguments": { + "prompt": "I accepted the Apollo terms. Continue.", + "threadId": "0199a1b2-0000-7000-8000-000000000031", + "exchange": { "approve": { "approvalId": "0199a1b2-0000-7000-8000-000000000033" } } + } +} +``` + **Returns:** -- Job ID for status checking. Use `firecrawl_agent_status` to poll for results. +- Job ID for status checking, plus `threadId` and `threadTurn`. Use `firecrawl_agent_status` to poll for results. ### 9. Check Agent Status (`firecrawl_agent_status`) diff --git a/src/index.ts b/src/index.ts index 71035b2f..1ea984b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3515,6 +3515,57 @@ Deprecated compatibility entry point. Use firecrawl_scrape once per known URL wi }, }); +// Mirrors agentExchangeSchema in firecrawl/firecrawl +// apps/api/src/controllers/v2/types.ts: the gateway forwards it verbatim to +// the agent service, which owns every default and the per-thread inheritance. +const agentOnTermsRequiredSchema = z.enum(['skip', 'ask']).optional(); +const agentExchangeSchema = z + .strictObject({ + enabled: z + .boolean() + .optional() + .describe('Let the agent call Alexandria providers. On by default.'), + toolkits: z + .array(z.string()) + .optional() + .describe('Pin the providers the agent may use, by slug. Omitted means the whole catalog.'), + maxCalls: z + .number() + .int() + .min(1) + .max(30) + .optional() + .describe('Most provider calls the agent may make in this turn.'), + requireApproval: z + .boolean() + .optional() + .describe( + 'End the turn with a paid-call pendingApproval before any paid provider call. Requires mode "chat".' + ), + approve: z + .strictObject({ + approvalId: z.string().uuid(), + callIds: z.array(z.string()).optional(), + always: z.boolean().optional(), + }) + .optional() + .describe( + 'Answer yes to the pendingApproval the previous turn of this thread ended on (its id, also exchange.requiresAction.approvalId). Needs threadId. For a terms offer, send it only after the user explicitly agreed and terms/accept succeeded; callIds and always are ignored on terms offers. For paid calls, callIds picks a subset (default all) and always stops asking for the rest of the thread.' + ), + decline: z + .strictObject({ approvalId: z.string().uuid() }) + .optional() + .describe( + 'Answer no to that pendingApproval. Needs threadId. A declined terms offer keeps those providers out of the rest of the thread.' + ), + onTermsRequired: agentOnTermsRequiredSchema.describe( + 'Same as the top-level onTermsRequired.' + ), + }) + .describe( + 'Alexandria provider settings for this turn, forwarded as the request\'s exchange object.' + ); + server.addTool({ name: 'firecrawl_agent', annotations: { @@ -3528,7 +3579,9 @@ Run web research that returns structured data when the URLs are not known or the This call returns only a job ID, not the research result. Read the job with \`firecrawl_agent_status\` until it reaches \`completed\` or \`failed\`; a typical research run takes one to three minutes. For one known URL use \`firecrawl_scrape\` (with formats: ["json"] for structured output); for a plain lookup that a results page answers, use \`firecrawl_search\`. -The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped, and with \`onTermsRequired\` "ask", \`exchange.requiresAction\` holds the exact terms/show and terms/accept calls. Never call terms/accept without the user's explicit consent to that provider's terms; a data request is not consent. After they agree, run the accept call through \`firecrawl_scrape\` and start \`firecrawl_agent\` again. +The job also returns a \`threadId\`. To continue that thread, pass it with a follow-up \`prompt\`; omitted \`mode\`, \`urls\`, \`schema\` and exchange settings carry over from the previous turn. + +The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped. With \`onTermsRequired\` "ask", a terms offer ends the turn: \`pendingApproval\` (kind "terms") and \`exchange.requiresAction\` carry the \`approvalId\` and the exact terms/show and terms/accept calls. Show the user the terms, get their EXPLICIT consent, run terms/accept through \`firecrawl_scrape\`, then call \`firecrawl_agent\` with the same \`threadId\` and \`exchange.approve: {approvalId}\`. If they decline, send \`exchange.decline: {approvalId}\` instead. Never call terms/accept without that consent; a data request is not consent. `, outputSchema: agentOutputSchema, parameters: z.object({ @@ -3553,13 +3606,53 @@ The agent only calls Alexandria providers whose data terms the team has accepted .describe( 'If true, agent will only visit URLs provided in the urls array.' ), - onTermsRequired: z - .enum(['skip', 'ask']) + onTermsRequired: agentOnTermsRequiredSchema.describe( + 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus exchange.requiresAction with the terms/show and terms/accept calls. Each provider digest is string | null and always present; when null, terms/show returns it. There is no auto-accept. Same as exchange.onTermsRequired; omitted on a follow-up keeps the previous turn\'s value.' + ), + threadId: z + .string() + .uuid() .optional() .describe( - 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus exchange.requiresAction with the terms/show and terms/accept calls. Each provider digest is string | null and always present; when null, terms/show returns it. There is no auto-accept.' + 'Continue this thread: the threadId from an earlier firecrawl_agent or firecrawl_agent_status result. Omit to start a new thread.' ), - }), + mode: z + .enum(['extract', 'chat']) + .optional() + .describe( + '"extract" (default) returns the complete structured result every turn. "chat" lets a follow-up that asks no new data get a short reply in message instead of a re-run; required for exchange.requireApproval. Omitted on a follow-up keeps the previous turn\'s mode.' + ), + exchange: agentExchangeSchema.optional(), + }) + .refine( + (data) => !(data.exchange?.approve && data.exchange?.decline), + { + message: + 'Send exchange.approve or exchange.decline, not both: each answers the pending approval one way.', + path: ['exchange'], + } + ) + .refine( + (data) => + !(data.exchange?.approve || data.exchange?.decline) || + Boolean(data.threadId), + { + message: + 'exchange.approve and exchange.decline answer a pending approval on an existing thread: pass that thread\'s threadId.', + path: ['threadId'], + } + ) + .refine( + (data) => + !data.onTermsRequired || + !data.exchange?.onTermsRequired || + data.onTermsRequired === data.exchange.onTermsRequired, + { + message: + 'onTermsRequired and exchange.onTermsRequired disagree; send one of them.', + path: ['onTermsRequired'], + } + ), execute: async ( args: unknown, { session, log, client: mcpClient } @@ -3569,8 +3662,17 @@ The agent only calls Alexandria providers whose data terms the team has accepted log.info('Starting agent', { prompt: (a.prompt as string).substring(0, 100), urlCount: Array.isArray(a.urls) ? a.urls.length : 0, + threadId: (a.threadId as string | undefined) ?? null, }); const onTermsRequired = a.onTermsRequired as 'skip' | 'ask' | undefined; + // The top-level onTermsRequired is shorthand for exchange.onTermsRequired; + // the refine above rejects the two disagreeing. Everything else in + // exchange is forwarded verbatim: the agent service owns the defaults and + // the per-thread inheritance. + const exchange = { + ...((a.exchange as Record | undefined) ?? {}), + ...(onTermsRequired ? { onTermsRequired } : {}), + }; const agentBody = removeEmptyTopLevel({ prompt: a.prompt as string, urls: a.urls as string[] | undefined, @@ -3578,7 +3680,9 @@ The agent only calls Alexandria providers whose data terms the team has accepted effort: a.effort as 'low' | 'medium' | 'high' | undefined, maxCredits: a.maxCredits as number | undefined, strictConstrainToURLs: a.strictConstrainToURLs as boolean | undefined, - exchange: onTermsRequired ? { onTermsRequired } : undefined, + threadId: a.threadId as string | undefined, + mode: a.mode as 'extract' | 'chat' | undefined, + exchange, }); const res = await (client as any).startAgent({ ...agentBody, diff --git a/src/tool-output.ts b/src/tool-output.ts index 663387b7..a8ba4031 100644 --- a/src/tool-output.ts +++ b/src/tool-output.ts @@ -235,11 +235,12 @@ export const agentStatusOutputSchema = z threadId: str('Research thread this job belongs to.'), threadTurn: num('Turn number of this job within its thread.'), message: unknown('The agent\'s reply, including what it could not answer.'), + suggestions: unknown('Follow-ups the agent offers; send one as the prompt of the next turn with this threadId.'), exchange: unknown( - 'What the job did with Alexandria providers: onTermsRequired, paidCalls, creditsUsed, skippedProviders (gated providers that would have helped), and requiresAction (terms/show and terms/accept calls, each provider digest string | null and always present; call accept only with the user\'s explicit consent).' + 'What the job did with Alexandria providers: onTermsRequired, paidCalls, creditsUsed, skippedProviders (gated providers that would have helped), and requiresAction (approvalId plus the terms/show and terms/accept calls, each provider digest string | null and always present; call accept only with the user\'s explicit consent, then continue the thread with exchange.approve: {approvalId}).' ), pendingApproval: unknown( - 'Set when the job ended waiting on the caller; kind "terms" lists providers whose data terms need accepting.' + 'Set when the job ended waiting on the caller. Answer it by calling firecrawl_agent with this threadId and exchange.approve or exchange.decline carrying its id. kind "terms" lists providers whose data terms need accepting; otherwise calls lists paid calls waiting for approval.' ), }) .describe('Progress or final result of a research agent job.'); diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 62d23b9f..f3404ac6 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -327,6 +327,41 @@ async function startFakeFirecrawlApi() { return; } + if (req.method === 'GET' && req.url === '/v2/agent/00000000-0000-4000-8000-000000000034') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + data: null, + expiresAt: '2026-10-01T00:00:00.000Z', + message: 'Apollo can return verified work emails for 3 credits.', + mode: 'chat', + model: 'spark-2', + status: 'completed', + success: true, + threadId: '00000000-0000-4000-8000-000000000031', + threadTurn: 2, + suggestions: [{ label: 'Only founders', prompt: 'Only keep the founders' }], + exchange: { enabled: true, requireApproval: true, paidCalls: 0, creditsUsed: null }, + pendingApproval: { + id: '00000000-0000-4000-8000-000000000035', + kind: 'calls', + reason: 'Apollo can return verified work emails.', + calls: [ + { + id: 'call-1', + provider: 'apollo', + capability: 'people/search', + input: { domain: 'exa.ai' }, + creditsEstimate: 3, + }, + ], + resolution: null, + }, + }) + ); + return; + } + if (req.method === 'POST' && req.url === '/v2/map') { res.writeHead(200, { 'content-type': 'application/json' }); res.end( @@ -3978,7 +4013,7 @@ test('firecrawl_agent forwards onTermsRequired and status keeps the terms-requir assert.deepEqual(agentTool.inputSchema.properties.onTermsRequired.enum, ['skip', 'ask']); assert.match(agentTool.description, /exchange\.skippedProviders/); assert.match(agentTool.description, /exchange\.requiresAction/); - assert.match(agentTool.description, /Never call terms\/accept without the user's explicit consent/); + assert.match(agentTool.description, /get their EXPLICIT consent.*Never call terms\/accept without that consent/); const asked = await client.request('tools/call', { arguments: { prompt: 'Find the key business contact at exa.ai', onTermsRequired: 'ask' }, @@ -4016,3 +4051,183 @@ test('firecrawl_agent forwards onTermsRequired and status keeps the terms-requir assert.equal(structured.pendingApproval.kind, 'terms'); assert.equal(structured.message, 'Apollo could add verified work emails.'); }); + +test('firecrawl_agent continues a thread and answers a pending approval', async (t) => { + const fakeApi = await startFakeFirecrawlApi(); + t.after(() => fakeApi.close()); + + const child = spawnServer({ + FIRECRAWL_API_KEY: 'fc-test', + FIRECRAWL_API_URL: fakeApi.url, + }); + t.after(() => stopChild(child)); + + const client = new StdioMcpClient(child); + await client.request('initialize', { + capabilities: {}, + clientInfo: { name: 'firecrawl-mcp-agent-thread', version: '0.0.0' }, + protocolVersion: '2025-06-18', + }); + client.notify('notifications/initialized'); + + const threadId = '00000000-0000-4000-8000-000000000031'; + const approvalId = '00000000-0000-4000-8000-000000000033'; + + const { tools } = await client.request('tools/list'); + const agentTool = tools.find((tool) => tool.name === 'firecrawl_agent'); + const props = agentTool.inputSchema.properties; + assert.equal(props.threadId.format, 'uuid'); + assert.deepEqual(props.mode.enum, ['extract', 'chat']); + // The exchange object mirrors the gateway's agentExchangeSchema key for key. + assert.deepEqual(Object.keys(props.exchange.properties).sort(), [ + 'approve', + 'decline', + 'enabled', + 'maxCalls', + 'onTermsRequired', + 'requireApproval', + 'toolkits', + ]); + assert.equal(props.exchange.additionalProperties, false); + assert.deepEqual(Object.keys(props.exchange.properties.approve.properties).sort(), [ + 'always', + 'approvalId', + 'callIds', + ]); + assert.deepEqual(props.exchange.properties.approve.required, ['approvalId']); + assert.deepEqual(Object.keys(props.exchange.properties.decline.properties), ['approvalId']); + assert.equal(props.exchange.properties.maxCalls.minimum, 1); + assert.equal(props.exchange.properties.maxCalls.maximum, 30); + assert.equal('model' in props, false); + assert.ok(agentTool.description.length <= 2048, `description is ${agentTool.description.length} chars`); + assert.match(agentTool.description, /same `threadId` and `exchange\.approve: \{approvalId\}`/); + assert.match(agentTool.description, /`exchange\.decline: \{approvalId\}`/); + assert.match(agentTool.description, /EXPLICIT consent/); + assert.match(agentTool.description, /Never call terms\/accept without that consent/); + + const call = (args) => client.request('tools/call', { arguments: args, name: 'firecrawl_agent' }); + + // 1. A follow-up turn on the same thread. + const followUp = await call({ prompt: 'Only keep the founders', threadId, mode: 'chat' }); + assert.notEqual(followUp.isError, true); + assert.equal(followUp.structuredContent.threadId, threadId); + assert.equal(followUp.structuredContent.threadTurn, 1); + + // 2. Accepting a terms offer after terms/accept, and declining one. + const approved = await call({ + prompt: 'I accepted the Apollo terms. Continue.', + threadId, + exchange: { approve: { approvalId } }, + }); + assert.notEqual(approved.isError, true); + const declined = await call({ + prompt: 'Do not use Apollo.', + threadId, + exchange: { decline: { approvalId } }, + }); + assert.notEqual(declined.isError, true); + + // A paid-call approval with a subset, plus the other exchange settings. + const paid = await call({ + prompt: 'Run only the first call.', + threadId, + exchange: { + approve: { approvalId, callIds: ['call-1'], always: true }, + toolkits: ['apollo'], + maxCalls: 4, + requireApproval: true, + enabled: true, + }, + }); + assert.notEqual(paid.isError, true); + + // The top-level shorthand merges into exchange. + const merged = await call({ + prompt: 'Keep asking about terms.', + threadId, + onTermsRequired: 'ask', + exchange: { maxCalls: 2 }, + }); + assert.notEqual(merged.isError, true); + + const bodies = fakeApi.requests + .filter((request) => request.method === 'POST' && request.url === '/v2/agent') + .map(({ body }) => { + const { origin, ...rest } = body; + assert.equal(typeof origin, 'string'); + return rest; + }); + assert.deepEqual(bodies, [ + { prompt: 'Only keep the founders', threadId, mode: 'chat' }, + { prompt: 'I accepted the Apollo terms. Continue.', threadId, exchange: { approve: { approvalId } } }, + { prompt: 'Do not use Apollo.', threadId, exchange: { decline: { approvalId } } }, + { + prompt: 'Run only the first call.', + threadId, + exchange: { + approve: { approvalId, callIds: ['call-1'], always: true }, + toolkits: ['apollo'], + maxCalls: 4, + requireApproval: true, + enabled: true, + }, + }, + { prompt: 'Keep asking about terms.', threadId, exchange: { maxCalls: 2, onTermsRequired: 'ask' } }, + ]); + // Nothing invents a model: the gateway runs every request on spark-2. + for (const body of bodies) assert.equal('model' in body, false); + + // Schema validation: every rejection happens before any request is sent. + const sent = bodies.length; + const rejects = [ + [{ prompt: 'x', threadId: 'not-a-uuid' }, /threadId/], + [{ prompt: 'x', mode: 'research' }, /mode/], + [{ prompt: 'x', threadId, exchange: { approve: { approvalId: 'nope' } } }, /approvalId/], + [{ prompt: 'x', threadId, exchange: { approve: {} } }, /approvalId/], + [{ prompt: 'x', threadId, exchange: { approve: { approvalId, autoAccept: true } } }, /autoAccept/], + [{ prompt: 'x', threadId, exchange: { acceptTerms: true } }, /acceptTerms/], + [{ prompt: 'x', threadId, exchange: { maxCalls: 31 } }, /maxCalls/], + [{ prompt: 'x', threadId, exchange: { maxCalls: 0 } }, /maxCalls/], + [{ prompt: 'x', threadId, exchange: { onTermsRequired: 'accept' } }, /onTermsRequired/], + [{ prompt: 'x', exchange: { approve: { approvalId } } }, /threadId/], + [{ prompt: 'x', exchange: { decline: { approvalId } } }, /threadId/], + [ + { prompt: 'x', threadId, exchange: { approve: { approvalId }, decline: { approvalId } } }, + /not both/, + ], + [ + { prompt: 'x', threadId, onTermsRequired: 'skip', exchange: { onTermsRequired: 'ask' } }, + /disagree/, + ], + ]; + for (const [args, pattern] of rejects) { + await assert.rejects(call(args), pattern, JSON.stringify(args)); + } + assert.equal( + fakeApi.requests.filter((request) => request.method === 'POST' && request.url === '/v2/agent').length, + sent + ); + + // 3. Status keeps the thread and both pendingApproval shapes in structuredContent. + const status = async (id) => { + const result = await client.request('tools/call', { arguments: { id }, name: 'firecrawl_agent_status' }); + assert.notEqual(result.isError, true); + return result.structuredContent; + }; + const terms = await status('00000000-0000-4000-8000-000000000032'); + assert.equal(terms.pendingApproval.kind, 'terms'); + assert.deepEqual(terms.pendingApproval.calls, []); + assert.equal(terms.pendingApproval.terms[0].provider, 'apollo'); + assert.equal(terms.exchange.requiresAction.type, 'accept_terms'); + assert.equal(terms.exchange.requiresAction.approvalId, terms.pendingApproval.id); + assert.equal(terms.exchange.requiresAction.providers[0].show.capability, 'terms/show'); + + const paidStatus = await status('00000000-0000-4000-8000-000000000034'); + assert.equal(paidStatus.threadId, threadId); + assert.equal(paidStatus.threadTurn, 2); + assert.equal(paidStatus.mode, 'chat'); + assert.equal(paidStatus.pendingApproval.kind, 'calls'); + assert.equal(paidStatus.pendingApproval.calls[0].id, 'call-1'); + assert.equal(paidStatus.pendingApproval.calls[0].creditsEstimate, 3); + assert.deepEqual(paidStatus.suggestions, [{ label: 'Only founders', prompt: 'Only keep the founders' }]); +}); From 402264f8ef9d135b8c2c0ba18de785cd437d6f28 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sun, 27 Sep 2026 01:42:43 +1000 Subject: [PATCH 5/8] refactor(agent): onTermsRequired lives only in exchange Drop the top-level onTermsRequired shorthand and its merge logic; callers set exchange.onTermsRequired, exactly as the gateway's agentExchangeSchema defines it. Nothing has been released with the shorthand. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +-- README.md | 9 ++++----- src/index.ts | 39 ++++++++++----------------------------- tests/mcp-smoke.test.mjs | 19 +++++++------------ 4 files changed, 22 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb6e94fe..ce88ebe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,7 @@ ### Added - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. -- `firecrawl_agent` accepts `onTermsRequired` (`"skip"` or `"ask"`), forwarded as `exchange.onTermsRequired`. The agent only calls Alexandria providers whose data terms the team has accepted; `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval` and `message` in its structured content. There is no auto-accept: `terms/accept` still needs the user's explicit consent. -- `firecrawl_agent` can continue a thread: it accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. `firecrawl_agent_status` also keeps `suggestions` in its structured content. +- `firecrawl_agent` can continue a thread and answer a pending approval. It accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. `exchange.onTermsRequired` (`"skip"` or `"ask"`) controls Alexandria providers whose data terms the team has not accepted; they are never called. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. There is no auto-accept. `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval`, `message` and `suggestions` in its structured content. ### Changed diff --git a/README.md b/README.md index 72c535aa..c471ea1e 100644 --- a/README.md +++ b/README.md @@ -735,14 +735,13 @@ The agent performs web searches, follows links, reads pages, and gathers data au - `prompt`: Natural language description of the data you want (required, max 10,000 characters) - `urls`: Optional array of URLs to focus the agent on specific pages - `schema`: Optional JSON schema for structured output -- `onTermsRequired`: Optional. What to do when an Alexandria provider the agent would use needs data terms your team has not accepted. Gated providers are never called in any mode. - - `"skip"` (default): answer with accepted providers only. `exchange.skippedProviders` on the status result lists the gated providers that would have helped. - - `"ask"`: the same, plus `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. Each provider's `digest` is always present and is `string | null`; when it is `null`, `terms/show` returns the current digest to send. - - `threadId`: Optional. Continue an existing thread: the `threadId` from an earlier `firecrawl_agent` or `firecrawl_agent_status` result. Omit to start a new thread. On a follow-up, omitted `mode`, `urls`, `schema` and `exchange` settings carry over from the previous turn. - `mode`: Optional. `"extract"` (default) returns the complete structured result every turn. `"chat"` lets a follow-up that asks for no new data get a short reply in `message` instead of a re-run; `exchange.requireApproval` needs it. - `exchange`: Optional. Alexandria provider settings for this turn, forwarded as-is to `POST /v2/agent`: - - `enabled`, `toolkits` (provider slugs), `maxCalls` (1 to 30), `requireApproval` (paid calls end the turn with a `pendingApproval`; needs `mode: "chat"`), `onTermsRequired` (same as the top-level argument; send one or the other) + - `enabled`, `toolkits` (provider slugs), `maxCalls` (1 to 30), `requireApproval` (paid calls end the turn with a `pendingApproval`; needs `mode: "chat"`) + - `onTermsRequired`: what to do when an Alexandria provider the agent would use needs data terms your team has not accepted. Gated providers are never called in any mode. Omitted on a follow-up keeps the previous turn's value. + - `"skip"` (default): answer with accepted providers only. `exchange.skippedProviders` on the status result lists the gated providers that would have helped. + - `"ask"`: the same, plus a terms `pendingApproval` and `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. Each provider's `digest` is always present and is `string | null`; when it is `null`, `terms/show` returns the current digest to send. - `approve`: `{ approvalId, callIds?, always? }` answers yes to the `pendingApproval` the previous turn ended on. `callIds` and `always` apply to paid-call approvals only. - `decline`: `{ approvalId }` answers no. A declined terms offer keeps those providers out of the rest of the thread. - `approve` and `decline` need `threadId`, and only one of them can be sent. diff --git a/src/index.ts b/src/index.ts index 1ea984b5..95a7ecaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3518,7 +3518,6 @@ Deprecated compatibility entry point. Use firecrawl_scrape once per known URL wi // Mirrors agentExchangeSchema in firecrawl/firecrawl // apps/api/src/controllers/v2/types.ts: the gateway forwards it verbatim to // the agent service, which owns every default and the per-thread inheritance. -const agentOnTermsRequiredSchema = z.enum(['skip', 'ask']).optional(); const agentExchangeSchema = z .strictObject({ enabled: z @@ -3558,9 +3557,12 @@ const agentExchangeSchema = z .describe( 'Answer no to that pendingApproval. Needs threadId. A declined terms offer keeps those providers out of the rest of the thread.' ), - onTermsRequired: agentOnTermsRequiredSchema.describe( - 'Same as the top-level onTermsRequired.' - ), + onTermsRequired: z + .enum(['skip', 'ask']) + .optional() + .describe( + 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus a terms pendingApproval and exchange.requiresAction with the terms/show and terms/accept calls. Each provider digest is string | null and always present; when null, terms/show returns it. There is no auto-accept. Omitted on a follow-up keeps the previous turn\'s value.' + ), }) .describe( 'Alexandria provider settings for this turn, forwarded as the request\'s exchange object.' @@ -3581,7 +3583,7 @@ This call returns only a job ID, not the research result. Read the job with \`fi The job also returns a \`threadId\`. To continue that thread, pass it with a follow-up \`prompt\`; omitted \`mode\`, \`urls\`, \`schema\` and exchange settings carry over from the previous turn. -The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped. With \`onTermsRequired\` "ask", a terms offer ends the turn: \`pendingApproval\` (kind "terms") and \`exchange.requiresAction\` carry the \`approvalId\` and the exact terms/show and terms/accept calls. Show the user the terms, get their EXPLICIT consent, run terms/accept through \`firecrawl_scrape\`, then call \`firecrawl_agent\` with the same \`threadId\` and \`exchange.approve: {approvalId}\`. If they decline, send \`exchange.decline: {approvalId}\` instead. Never call terms/accept without that consent; a data request is not consent. +The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped. With \`exchange.onTermsRequired\` "ask", a terms offer ends the turn: \`pendingApproval\` (kind "terms") and \`exchange.requiresAction\` carry the \`approvalId\` and the exact terms/show and terms/accept calls. Show the user the terms, get their EXPLICIT consent, run terms/accept through \`firecrawl_scrape\`, then call \`firecrawl_agent\` with the same \`threadId\` and \`exchange.approve: {approvalId}\`. If they decline, send \`exchange.decline: {approvalId}\` instead. Never call terms/accept without that consent; a data request is not consent. `, outputSchema: agentOutputSchema, parameters: z.object({ @@ -3606,9 +3608,6 @@ The agent only calls Alexandria providers whose data terms the team has accepted .describe( 'If true, agent will only visit URLs provided in the urls array.' ), - onTermsRequired: agentOnTermsRequiredSchema.describe( - 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus exchange.requiresAction with the terms/show and terms/accept calls. Each provider digest is string | null and always present; when null, terms/show returns it. There is no auto-accept. Same as exchange.onTermsRequired; omitted on a follow-up keeps the previous turn\'s value.' - ), threadId: z .string() .uuid() @@ -3641,17 +3640,6 @@ The agent only calls Alexandria providers whose data terms the team has accepted 'exchange.approve and exchange.decline answer a pending approval on an existing thread: pass that thread\'s threadId.', path: ['threadId'], } - ) - .refine( - (data) => - !data.onTermsRequired || - !data.exchange?.onTermsRequired || - data.onTermsRequired === data.exchange.onTermsRequired, - { - message: - 'onTermsRequired and exchange.onTermsRequired disagree; send one of them.', - path: ['onTermsRequired'], - } ), execute: async ( args: unknown, @@ -3664,15 +3652,6 @@ The agent only calls Alexandria providers whose data terms the team has accepted urlCount: Array.isArray(a.urls) ? a.urls.length : 0, threadId: (a.threadId as string | undefined) ?? null, }); - const onTermsRequired = a.onTermsRequired as 'skip' | 'ask' | undefined; - // The top-level onTermsRequired is shorthand for exchange.onTermsRequired; - // the refine above rejects the two disagreeing. Everything else in - // exchange is forwarded verbatim: the agent service owns the defaults and - // the per-thread inheritance. - const exchange = { - ...((a.exchange as Record | undefined) ?? {}), - ...(onTermsRequired ? { onTermsRequired } : {}), - }; const agentBody = removeEmptyTopLevel({ prompt: a.prompt as string, urls: a.urls as string[] | undefined, @@ -3682,7 +3661,9 @@ The agent only calls Alexandria providers whose data terms the team has accepted strictConstrainToURLs: a.strictConstrainToURLs as boolean | undefined, threadId: a.threadId as string | undefined, mode: a.mode as 'extract' | 'chat' | undefined, - exchange, + // Forwarded verbatim: the agent service owns the defaults and the + // per-thread inheritance. + exchange: a.exchange as Record | undefined, }); const res = await (client as any).startAgent({ ...agentBody, diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index f3404ac6..e0b11c12 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -4010,13 +4010,14 @@ test('firecrawl_agent forwards onTermsRequired and status keeps the terms-requir const { tools } = await client.request('tools/list'); const agentTool = tools.find((tool) => tool.name === 'firecrawl_agent'); - assert.deepEqual(agentTool.inputSchema.properties.onTermsRequired.enum, ['skip', 'ask']); + assert.equal('onTermsRequired' in agentTool.inputSchema.properties, false); + assert.deepEqual(agentTool.inputSchema.properties.exchange.properties.onTermsRequired.enum, ['skip', 'ask']); assert.match(agentTool.description, /exchange\.skippedProviders/); assert.match(agentTool.description, /exchange\.requiresAction/); assert.match(agentTool.description, /get their EXPLICIT consent.*Never call terms\/accept without that consent/); const asked = await client.request('tools/call', { - arguments: { prompt: 'Find the key business contact at exa.ai', onTermsRequired: 'ask' }, + arguments: { prompt: 'Find the key business contact at exa.ai', exchange: { onTermsRequired: 'ask' } }, name: 'firecrawl_agent', }); assert.notEqual(asked.isError, true); @@ -4034,7 +4035,7 @@ test('firecrawl_agent forwards onTermsRequired and status keeps the terms-requir // There is no auto-accept mode: any other value fails parameter validation. await assert.rejects( client.request('tools/call', { - arguments: { prompt: 'Find the key business contact at exa.ai', onTermsRequired: 'fail' }, + arguments: { prompt: 'Find the key business contact at exa.ai', exchange: { onTermsRequired: 'fail' } }, name: 'firecrawl_agent', }), /onTermsRequired/ @@ -4141,14 +4142,12 @@ test('firecrawl_agent continues a thread and answers a pending approval', async }); assert.notEqual(paid.isError, true); - // The top-level shorthand merges into exchange. - const merged = await call({ + const asked = await call({ prompt: 'Keep asking about terms.', threadId, - onTermsRequired: 'ask', - exchange: { maxCalls: 2 }, + exchange: { maxCalls: 2, onTermsRequired: 'ask' }, }); - assert.notEqual(merged.isError, true); + assert.notEqual(asked.isError, true); const bodies = fakeApi.requests .filter((request) => request.method === 'POST' && request.url === '/v2/agent') @@ -4195,10 +4194,6 @@ test('firecrawl_agent continues a thread and answers a pending approval', async { prompt: 'x', threadId, exchange: { approve: { approvalId }, decline: { approvalId } } }, /not both/, ], - [ - { prompt: 'x', threadId, onTermsRequired: 'skip', exchange: { onTermsRequired: 'ask' } }, - /disagree/, - ], ]; for (const [args, pattern] of rejects) { await assert.rejects(call(args), pattern, JSON.stringify(args)); From 8651d9f09577ae23d899a90e3c6a854e25835d59 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sun, 27 Sep 2026 01:53:49 +1000 Subject: [PATCH 6/8] fix(agent): address review on thread follow-ups and exchange limits - On a follow-up, send `urls: []` and `schema: null` (or `{}`) so they clear what the thread inherits. POST /v2/agent directly because the SDK's startAgent drops a null schema; errors are reported the same way. - `exchange.toolkits` takes at most 5 slugs, the agent service's limit. - `exchange.requireApproval` needs `mode: "chat"` on the same request: the agent service checks the request's mode, not the inherited one, and the gateway turns its 400 into a 500. - README lists effort, maxCredits (API default 2,500) and strictConstrainToURLs; the description-length test uses CLAUDE_CODE_TEXT_CAP. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 9 ++-- src/index.ts | 89 ++++++++++++++++++++++++++++++++++++---- tests/mcp-smoke.test.mjs | 46 ++++++++++++++++++++- 4 files changed, 134 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce88ebe1..b3c1ff4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. -- `firecrawl_agent` can continue a thread and answer a pending approval. It accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. `exchange.onTermsRequired` (`"skip"` or `"ask"`) controls Alexandria providers whose data terms the team has not accepted; they are never called. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. There is no auto-accept. `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval`, `message` and `suggestions` in its structured content. +- `firecrawl_agent` can continue a thread and answer a pending approval. It accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. `exchange.onTermsRequired` (`"skip"` or `"ask"`) controls Alexandria providers whose data terms the team has not accepted; they are never called. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. `exchange.requireApproval` requires `mode: "chat"` on the same call, and `exchange.toolkits` takes at most 5 slugs. On a follow-up, `urls: []` and `schema: null` clear the inherited values instead of being dropped. There is no auto-accept. `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval`, `message` and `suggestions` in its structured content. ### Changed diff --git a/README.md b/README.md index c471ea1e..3d7bcdc0 100644 --- a/README.md +++ b/README.md @@ -733,12 +733,15 @@ The agent performs web searches, follows links, reads pages, and gathers data au **Arguments:** - `prompt`: Natural language description of the data you want (required, max 10,000 characters) -- `urls`: Optional array of URLs to focus the agent on specific pages -- `schema`: Optional JSON schema for structured output +- `urls`: Optional array of URLs to focus the agent on specific pages. On a follow-up, `[]` clears the previous turn's URLs. +- `schema`: Optional JSON schema for structured output. On a follow-up, `null` clears the previous turn's schema. +- `effort`: Optional. `"low"`, `"medium"` or `"high"` reasoning budget for the agent task. +- `maxCredits`: Optional positive integer. Spending limit in credits for this run. The API defaults to 2,500 when omitted, and caps a free request at 2,500. +- `strictConstrainToURLs`: Optional boolean. If `true`, the agent only visits the URLs in `urls`. - `threadId`: Optional. Continue an existing thread: the `threadId` from an earlier `firecrawl_agent` or `firecrawl_agent_status` result. Omit to start a new thread. On a follow-up, omitted `mode`, `urls`, `schema` and `exchange` settings carry over from the previous turn. - `mode`: Optional. `"extract"` (default) returns the complete structured result every turn. `"chat"` lets a follow-up that asks for no new data get a short reply in `message` instead of a re-run; `exchange.requireApproval` needs it. - `exchange`: Optional. Alexandria provider settings for this turn, forwarded as-is to `POST /v2/agent`: - - `enabled`, `toolkits` (provider slugs), `maxCalls` (1 to 30), `requireApproval` (paid calls end the turn with a `pendingApproval`; needs `mode: "chat"`) + - `enabled`, `toolkits` (up to 5 provider slugs), `maxCalls` (1 to 30), `requireApproval` (paid calls end the turn with a `pendingApproval`; needs `mode: "chat"` on the same call, even on a follow-up) - `onTermsRequired`: what to do when an Alexandria provider the agent would use needs data terms your team has not accepted. Gated providers are never called in any mode. Omitted on a follow-up keeps the previous turn's value. - `"skip"` (default): answer with accepted providers only. `exchange.skippedProviders` on the status result lists the gated providers that would have helped. - `"ask"`: the same, plus a terms `pendingApproval` and `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. Each provider's `digest` is always present and is `string | null`; when it is `null`, `terms/show` returns the current digest to send. diff --git a/src/index.ts b/src/index.ts index 95a7ecaa..1799ddd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import { alexandriaSessionFeedbackSchema, withAlexandriaFeedbackHint, } from './alexandria-feedback.js'; -import FirecrawlApp from 'firecrawl'; +import FirecrawlApp, { SdkError } from 'firecrawl'; import dotenv from 'dotenv'; import { type ContentResult, FastMCP, type Logger, UserError } from 'fastmcp'; import type { IncomingHttpHeaders } from 'http'; @@ -3526,8 +3526,9 @@ const agentExchangeSchema = z .describe('Let the agent call Alexandria providers. On by default.'), toolkits: z .array(z.string()) + .max(5) .optional() - .describe('Pin the providers the agent may use, by slug. Omitted means the whole catalog.'), + .describe('Pin up to 5 providers the agent may use, by slug. Omitted means the whole catalog.'), maxCalls: z .number() .int() @@ -3539,7 +3540,7 @@ const agentExchangeSchema = z .boolean() .optional() .describe( - 'End the turn with a paid-call pendingApproval before any paid provider call. Requires mode "chat".' + 'End the turn with a paid-call pendingApproval before any paid provider call. Requires mode "chat" on the same request, even on a follow-up.' ), approve: z .strictObject({ @@ -3568,6 +3569,49 @@ const agentExchangeSchema = z 'Alexandria provider settings for this turn, forwarded as the request\'s exchange object.' ); +function isEmptyPlainObject(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.keys(value).length === 0 + ); +} + +/** + * POST /v2/agent with the body exactly as built. The SDK's startAgent drops a + * null `schema`, which is how a follow-up clears an inherited one, so this + * posts directly and reports errors the way startAgent does. + */ +async function postAgent( + client: unknown, + body: Record +): Promise { + try { + const res = await (client as any).http.post('/v2/agent', body); + if (res.status !== 200) { + const data = res.data || {}; + throw new SdkError( + data.error || data.message || `Request failed (${res.status}) while trying to agent`, + res.status, + data.code, + data.details + ); + } + return res.data; + } catch (err: any) { + if (!err?.isAxiosError) throw err; + const status = err.response?.status; + const data = err.response?.data; + throw new SdkError( + data?.error || err.message || `Request failed${status ? ` (${status})` : ''} while trying to agent`, + status, + data?.code || err.code, + data?.details ?? data + ); + } +} + server.addTool({ name: 'firecrawl_agent', annotations: { @@ -3588,8 +3632,19 @@ The agent only calls Alexandria providers whose data terms the team has accepted outputSchema: agentOutputSchema, parameters: z.object({ prompt: z.string().min(1).max(10000), - urls: z.array(z.string().url()).optional(), - schema: z.record(z.string(), z.any()).optional(), + urls: z + .array(z.string().url()) + .optional() + .describe( + 'Seed URLs. On a follow-up, omitted keeps the previous turn\'s urls and [] clears them.' + ), + schema: z + .record(z.string(), z.any()) + .nullable() + .optional() + .describe( + 'JSON schema for the result. On a follow-up, omitted keeps the previous turn\'s schema and null clears it.' + ), effort: z .enum(['low', 'medium', 'high']) .optional() @@ -3640,6 +3695,16 @@ The agent only calls Alexandria providers whose data terms the team has accepted 'exchange.approve and exchange.decline answer a pending approval on an existing thread: pass that thread\'s threadId.', path: ['threadId'], } + ) + .refine( + (data) => !data.exchange?.requireApproval || data.mode === 'chat', + { + // The agent service checks the mode sent on this request, not the + // thread's inherited one, and the gateway turns its 400 into a 500. + message: + 'exchange.requireApproval needs mode: "chat" on the same request, including on a follow-up.', + path: ['mode'], + } ), execute: async ( args: unknown, @@ -3652,7 +3717,8 @@ The agent only calls Alexandria providers whose data terms the team has accepted urlCount: Array.isArray(a.urls) ? a.urls.length : 0, threadId: (a.threadId as string | undefined) ?? null, }); - const agentBody = removeEmptyTopLevel({ + const threadId = a.threadId as string | undefined; + const agentBody: Record = removeEmptyTopLevel({ prompt: a.prompt as string, urls: a.urls as string[] | undefined, schema: (a.schema as Record) || undefined, @@ -3665,7 +3731,16 @@ The agent only calls Alexandria providers whose data terms the team has accepted // per-thread inheritance. exchange: a.exchange as Record | undefined, }); - const res = await (client as any).startAgent({ + // On a follow-up, `urls: []` and `schema: null` (or `{}`) replace what the + // thread would otherwise inherit, so they are sent as given rather than + // dropped as empty. A new thread has nothing to clear. + if (threadId) { + if (Array.isArray(a.urls) && a.urls.length === 0) agentBody.urls = []; + if (a.schema === null || isEmptyPlainObject(a.schema)) { + agentBody.schema = a.schema; + } + } + const res = await postAgent(client, { ...agentBody, origin: requestOrigin(mcpClient, session), }); diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index e0b11c12..97425e94 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -237,6 +237,23 @@ async function startFakeFirecrawlApi() { return; } + if ( + req.method === 'POST' && + req.url === '/v2/agent' && + parsedBody?.threadId === '00000000-0000-4000-8000-000000000036' + ) { + res.writeHead(409, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + code: 'thread_busy', + error: 'This thread already has a run in progress', + runId: '00000000-0000-4000-8000-000000000037', + }) + ); + return; + } + if (req.method === 'POST' && req.url === '/v2/agent') { res.writeHead(200, { 'content-type': 'application/json' }); res.end( @@ -4100,7 +4117,7 @@ test('firecrawl_agent continues a thread and answers a pending approval', async assert.equal(props.exchange.properties.maxCalls.minimum, 1); assert.equal(props.exchange.properties.maxCalls.maximum, 30); assert.equal('model' in props, false); - assert.ok(agentTool.description.length <= 2048, `description is ${agentTool.description.length} chars`); + assert.ok(agentTool.description.length <= CLAUDE_CODE_TEXT_CAP, `description is ${agentTool.description.length} chars`); assert.match(agentTool.description, /same `threadId` and `exchange\.approve: \{approvalId\}`/); assert.match(agentTool.description, /`exchange\.decline: \{approvalId\}`/); assert.match(agentTool.description, /EXPLICIT consent/); @@ -4139,6 +4156,7 @@ test('firecrawl_agent continues a thread and answers a pending approval', async requireApproval: true, enabled: true, }, + mode: 'chat', }); assert.notEqual(paid.isError, true); @@ -4149,6 +4167,15 @@ test('firecrawl_agent continues a thread and answers a pending approval', async }); assert.notEqual(asked.isError, true); + // On a follow-up, explicit clears replace what the thread would inherit. + const cleared = await call({ prompt: 'Search the whole web now.', threadId, urls: [], schema: null }); + assert.notEqual(cleared.isError, true); + const emptySchema = await call({ prompt: 'Any shape is fine.', threadId, schema: {} }); + assert.notEqual(emptySchema.isError, true); + // A new thread has nothing to clear, so the same values stay off the wire. + const fresh = await call({ prompt: 'Start over.', urls: [], schema: null }); + assert.notEqual(fresh.isError, true); + const bodies = fakeApi.requests .filter((request) => request.method === 'POST' && request.url === '/v2/agent') .map(({ body }) => { @@ -4170,8 +4197,12 @@ test('firecrawl_agent continues a thread and answers a pending approval', async requireApproval: true, enabled: true, }, + mode: 'chat', }, { prompt: 'Keep asking about terms.', threadId, exchange: { maxCalls: 2, onTermsRequired: 'ask' } }, + { prompt: 'Search the whole web now.', threadId, urls: [], schema: null }, + { prompt: 'Any shape is fine.', threadId, schema: {} }, + { prompt: 'Start over.' }, ]); // Nothing invents a model: the gateway runs every request on spark-2. for (const body of bodies) assert.equal('model' in body, false); @@ -4194,6 +4225,12 @@ test('firecrawl_agent continues a thread and answers a pending approval', async { prompt: 'x', threadId, exchange: { approve: { approvalId }, decline: { approvalId } } }, /not both/, ], + [{ prompt: 'x', threadId, exchange: { toolkits: ['a', 'b', 'c', 'd', 'e', 'f'] } }, /toolkits/], + // The agent service checks the mode on the request itself, so an inherited + // chat mode is not enough. + [{ prompt: 'x', exchange: { requireApproval: true } }, /requireApproval needs mode/], + [{ prompt: 'x', mode: 'extract', exchange: { requireApproval: true } }, /requireApproval needs mode/], + [{ prompt: 'x', threadId, exchange: { requireApproval: true } }, /requireApproval needs mode/], ]; for (const [args, pattern] of rejects) { await assert.rejects(call(args), pattern, JSON.stringify(args)); @@ -4203,6 +4240,13 @@ test('firecrawl_agent continues a thread and answers a pending approval', async sent ); + // A thread error from the API reaches the caller with its message. + const busy = await call({ prompt: 'x', threadId: '00000000-0000-4000-8000-000000000036' }).then( + (result) => JSON.stringify(result), + (error) => String(error?.message ?? error) + ); + assert.match(busy, /This thread already has a run in progress/); + // 3. Status keeps the thread and both pendingApproval shapes in structuredContent. const status = async (id) => { const result = await client.request('tools/call', { arguments: { id }, name: 'firecrawl_agent_status' }); From e7539868f4f4b72b1531904df984d1d57ded6533 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sun, 27 Sep 2026 01:58:21 +1000 Subject: [PATCH 7/8] revert(agent): back to the SDK's startAgent; drop explicit follow-up clears The SDK drops a null schema, so clearing inherited urls/schema can't go through it. Keep today's behaviour: empty or null values are dropped and the thread's values are inherited. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 4 +-- src/index.ts | 74 +++------------------------------------- tests/mcp-smoke.test.mjs | 12 ------- 4 files changed, 8 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c1ff4f..93254372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. -- `firecrawl_agent` can continue a thread and answer a pending approval. It accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. `exchange.onTermsRequired` (`"skip"` or `"ask"`) controls Alexandria providers whose data terms the team has not accepted; they are never called. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. `exchange.requireApproval` requires `mode: "chat"` on the same call, and `exchange.toolkits` takes at most 5 slugs. On a follow-up, `urls: []` and `schema: null` clear the inherited values instead of being dropped. There is no auto-accept. `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval`, `message` and `suggestions` in its structured content. +- `firecrawl_agent` can continue a thread and answer a pending approval. It accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. `exchange.onTermsRequired` (`"skip"` or `"ask"`) controls Alexandria providers whose data terms the team has not accepted; they are never called. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. `exchange.requireApproval` requires `mode: "chat"` on the same call, and `exchange.toolkits` takes at most 5 slugs. There is no auto-accept. `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval`, `message` and `suggestions` in its structured content. ### Changed diff --git a/README.md b/README.md index 3d7bcdc0..3264ee10 100644 --- a/README.md +++ b/README.md @@ -733,8 +733,8 @@ The agent performs web searches, follows links, reads pages, and gathers data au **Arguments:** - `prompt`: Natural language description of the data you want (required, max 10,000 characters) -- `urls`: Optional array of URLs to focus the agent on specific pages. On a follow-up, `[]` clears the previous turn's URLs. -- `schema`: Optional JSON schema for structured output. On a follow-up, `null` clears the previous turn's schema. +- `urls`: Optional array of URLs to focus the agent on specific pages +- `schema`: Optional JSON schema for structured output - `effort`: Optional. `"low"`, `"medium"` or `"high"` reasoning budget for the agent task. - `maxCredits`: Optional positive integer. Spending limit in credits for this run. The API defaults to 2,500 when omitted, and caps a free request at 2,500. - `strictConstrainToURLs`: Optional boolean. If `true`, the agent only visits the URLs in `urls`. diff --git a/src/index.ts b/src/index.ts index 1799ddd9..46281112 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import { alexandriaSessionFeedbackSchema, withAlexandriaFeedbackHint, } from './alexandria-feedback.js'; -import FirecrawlApp, { SdkError } from 'firecrawl'; +import FirecrawlApp from 'firecrawl'; import dotenv from 'dotenv'; import { type ContentResult, FastMCP, type Logger, UserError } from 'fastmcp'; import type { IncomingHttpHeaders } from 'http'; @@ -3569,49 +3569,6 @@ const agentExchangeSchema = z 'Alexandria provider settings for this turn, forwarded as the request\'s exchange object.' ); -function isEmptyPlainObject(value: unknown): boolean { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.keys(value).length === 0 - ); -} - -/** - * POST /v2/agent with the body exactly as built. The SDK's startAgent drops a - * null `schema`, which is how a follow-up clears an inherited one, so this - * posts directly and reports errors the way startAgent does. - */ -async function postAgent( - client: unknown, - body: Record -): Promise { - try { - const res = await (client as any).http.post('/v2/agent', body); - if (res.status !== 200) { - const data = res.data || {}; - throw new SdkError( - data.error || data.message || `Request failed (${res.status}) while trying to agent`, - res.status, - data.code, - data.details - ); - } - return res.data; - } catch (err: any) { - if (!err?.isAxiosError) throw err; - const status = err.response?.status; - const data = err.response?.data; - throw new SdkError( - data?.error || err.message || `Request failed${status ? ` (${status})` : ''} while trying to agent`, - status, - data?.code || err.code, - data?.details ?? data - ); - } -} - server.addTool({ name: 'firecrawl_agent', annotations: { @@ -3632,19 +3589,8 @@ The agent only calls Alexandria providers whose data terms the team has accepted outputSchema: agentOutputSchema, parameters: z.object({ prompt: z.string().min(1).max(10000), - urls: z - .array(z.string().url()) - .optional() - .describe( - 'Seed URLs. On a follow-up, omitted keeps the previous turn\'s urls and [] clears them.' - ), - schema: z - .record(z.string(), z.any()) - .nullable() - .optional() - .describe( - 'JSON schema for the result. On a follow-up, omitted keeps the previous turn\'s schema and null clears it.' - ), + urls: z.array(z.string().url()).optional(), + schema: z.record(z.string(), z.any()).optional(), effort: z .enum(['low', 'medium', 'high']) .optional() @@ -3717,8 +3663,7 @@ The agent only calls Alexandria providers whose data terms the team has accepted urlCount: Array.isArray(a.urls) ? a.urls.length : 0, threadId: (a.threadId as string | undefined) ?? null, }); - const threadId = a.threadId as string | undefined; - const agentBody: Record = removeEmptyTopLevel({ + const agentBody = removeEmptyTopLevel({ prompt: a.prompt as string, urls: a.urls as string[] | undefined, schema: (a.schema as Record) || undefined, @@ -3731,16 +3676,7 @@ The agent only calls Alexandria providers whose data terms the team has accepted // per-thread inheritance. exchange: a.exchange as Record | undefined, }); - // On a follow-up, `urls: []` and `schema: null` (or `{}`) replace what the - // thread would otherwise inherit, so they are sent as given rather than - // dropped as empty. A new thread has nothing to clear. - if (threadId) { - if (Array.isArray(a.urls) && a.urls.length === 0) agentBody.urls = []; - if (a.schema === null || isEmptyPlainObject(a.schema)) { - agentBody.schema = a.schema; - } - } - const res = await postAgent(client, { + const res = await (client as any).startAgent({ ...agentBody, origin: requestOrigin(mcpClient, session), }); diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 97425e94..cb0e60e9 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -4167,15 +4167,6 @@ test('firecrawl_agent continues a thread and answers a pending approval', async }); assert.notEqual(asked.isError, true); - // On a follow-up, explicit clears replace what the thread would inherit. - const cleared = await call({ prompt: 'Search the whole web now.', threadId, urls: [], schema: null }); - assert.notEqual(cleared.isError, true); - const emptySchema = await call({ prompt: 'Any shape is fine.', threadId, schema: {} }); - assert.notEqual(emptySchema.isError, true); - // A new thread has nothing to clear, so the same values stay off the wire. - const fresh = await call({ prompt: 'Start over.', urls: [], schema: null }); - assert.notEqual(fresh.isError, true); - const bodies = fakeApi.requests .filter((request) => request.method === 'POST' && request.url === '/v2/agent') .map(({ body }) => { @@ -4200,9 +4191,6 @@ test('firecrawl_agent continues a thread and answers a pending approval', async mode: 'chat', }, { prompt: 'Keep asking about terms.', threadId, exchange: { maxCalls: 2, onTermsRequired: 'ask' } }, - { prompt: 'Search the whole web now.', threadId, urls: [], schema: null }, - { prompt: 'Any shape is fine.', threadId, schema: {} }, - { prompt: 'Start over.' }, ]); // Nothing invents a model: the gateway runs every request on spark-2. for (const body of bodies) assert.equal('model' in body, false); From 3d7f64af6eab36ca39dbbee747fc6dab1f7d863a Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sun, 27 Sep 2026 02:01:47 +1000 Subject: [PATCH 8/8] refactor(agent): keep this PR to thread continuation Move the exchange options, approve/decline checks, terms-flow text and the pendingApproval/exchange status fields to a stacked PR. This PR keeps threadId and mode on firecrawl_agent (forwarded through the SDK), message and suggestions in the status output, the README arguments for effort, maxCredits and strictConstrainToURLs, and the CLAUDE_CODE_TEXT_CAP test fix. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 28 +--- src/index.ts | 93 +------------ src/tool-output.ts | 8 +- tests/mcp-smoke.test.mjs | 287 ++++----------------------------------- 5 files changed, 33 insertions(+), 385 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93254372..620fb0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. -- `firecrawl_agent` can continue a thread and answer a pending approval. It accepts `threadId`, `mode` (`"extract"` or `"chat"`) and an `exchange` object that mirrors the API's (`enabled`, `toolkits`, `maxCalls`, `requireApproval`, `approve: { approvalId, callIds?, always? }`, `decline: { approvalId }`, `onTermsRequired`), all forwarded to `POST /v2/agent`. `exchange.onTermsRequired` (`"skip"` or `"ask"`) controls Alexandria providers whose data terms the team has not accepted; they are never called. After an ask-mode terms offer and the user's explicit consent to `terms/accept`, a caller answers the offer on the same thread with `exchange.approve: { approvalId }` (or `exchange.decline`) instead of starting over. `approve` and `decline` require `threadId` and cannot be sent together. `exchange.requireApproval` requires `mode: "chat"` on the same call, and `exchange.toolkits` takes at most 5 slugs. There is no auto-accept. `firecrawl_agent_status` now keeps `exchange` (including `skippedProviders` and `requiresAction`, whose provider `digest` is `string | null` and always present), `pendingApproval`, `message` and `suggestions` in its structured content. +- `firecrawl_agent` can continue a thread: it accepts `threadId` and `mode` (`"extract"` or `"chat"`) and forwards them to `POST /v2/agent` through the SDK. On a follow-up, omitted `mode`, `urls` and `schema` carry over from the previous turn. `firecrawl_agent_status` now keeps `message` and `suggestions` in its structured content, next to `threadId` and `threadTurn`. ### Changed diff --git a/README.md b/README.md index 3264ee10..d9339057 100644 --- a/README.md +++ b/README.md @@ -738,25 +738,8 @@ The agent performs web searches, follows links, reads pages, and gathers data au - `effort`: Optional. `"low"`, `"medium"` or `"high"` reasoning budget for the agent task. - `maxCredits`: Optional positive integer. Spending limit in credits for this run. The API defaults to 2,500 when omitted, and caps a free request at 2,500. - `strictConstrainToURLs`: Optional boolean. If `true`, the agent only visits the URLs in `urls`. -- `threadId`: Optional. Continue an existing thread: the `threadId` from an earlier `firecrawl_agent` or `firecrawl_agent_status` result. Omit to start a new thread. On a follow-up, omitted `mode`, `urls`, `schema` and `exchange` settings carry over from the previous turn. -- `mode`: Optional. `"extract"` (default) returns the complete structured result every turn. `"chat"` lets a follow-up that asks for no new data get a short reply in `message` instead of a re-run; `exchange.requireApproval` needs it. -- `exchange`: Optional. Alexandria provider settings for this turn, forwarded as-is to `POST /v2/agent`: - - `enabled`, `toolkits` (up to 5 provider slugs), `maxCalls` (1 to 30), `requireApproval` (paid calls end the turn with a `pendingApproval`; needs `mode: "chat"` on the same call, even on a follow-up) - - `onTermsRequired`: what to do when an Alexandria provider the agent would use needs data terms your team has not accepted. Gated providers are never called in any mode. Omitted on a follow-up keeps the previous turn's value. - - `"skip"` (default): answer with accepted providers only. `exchange.skippedProviders` on the status result lists the gated providers that would have helped. - - `"ask"`: the same, plus a terms `pendingApproval` and `exchange.requiresAction` with the exact `terms/show` and `terms/accept` calls for each provider. Each provider's `digest` is always present and is `string | null`; when it is `null`, `terms/show` returns the current digest to send. - - `approve`: `{ approvalId, callIds?, always? }` answers yes to the `pendingApproval` the previous turn ended on. `callIds` and `always` apply to paid-call approvals only. - - `decline`: `{ approvalId }` answers no. A declined terms offer keeps those providers out of the rest of the thread. - - `approve` and `decline` need `threadId`, and only one of them can be sent. - -**Provider terms (ask mode):** there is no auto-accept mode. When a turn ends on a terms offer, the status result carries `pendingApproval` (`kind: "terms"`) and `exchange.requiresAction` with the `approvalId` and the exact `terms/show` and `terms/accept` calls. To use the provider: - -1. Show the user the terms (`terms/show` through `firecrawl_scrape` with `alexandria`). -2. Get the user's explicit consent to that provider's terms. A data request is not consent. -3. Run the `terms/accept` call through `firecrawl_scrape`. -4. Continue the same thread: call `firecrawl_agent` with the same `threadId` and `exchange.approve: { "approvalId": "..." }`. - -If the user says no, call `firecrawl_agent` with the same `threadId` and `exchange.decline: { "approvalId": "..." }` instead. +- `threadId`: Optional. Continue an existing thread: the `threadId` from an earlier `firecrawl_agent` or `firecrawl_agent_status` result. Omit to start a new thread. On a follow-up, omitted `mode`, `urls` and `schema` carry over from the previous turn. +- `mode`: Optional. `"extract"` (default) returns the complete structured result every turn. `"chat"` lets a follow-up that asks for no new data get a short reply in `message` instead of a re-run. **Prompt Example:** @@ -803,15 +786,14 @@ Then poll with `firecrawl_agent_status` using the returned job ID. } ``` -**Usage Example (continue the thread after the user accepted a provider's terms):** +**Usage Example (follow-up on the same thread):** ```json { "name": "firecrawl_agent", "arguments": { - "prompt": "I accepted the Apollo terms. Continue.", - "threadId": "0199a1b2-0000-7000-8000-000000000031", - "exchange": { "approve": { "approvalId": "0199a1b2-0000-7000-8000-000000000033" } } + "prompt": "Only keep the startups based in Europe", + "threadId": "0199a1b2-0000-7000-8000-000000000031" } } ``` diff --git a/src/index.ts b/src/index.ts index 46281112..1d9a51f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3515,60 +3515,6 @@ Deprecated compatibility entry point. Use firecrawl_scrape once per known URL wi }, }); -// Mirrors agentExchangeSchema in firecrawl/firecrawl -// apps/api/src/controllers/v2/types.ts: the gateway forwards it verbatim to -// the agent service, which owns every default and the per-thread inheritance. -const agentExchangeSchema = z - .strictObject({ - enabled: z - .boolean() - .optional() - .describe('Let the agent call Alexandria providers. On by default.'), - toolkits: z - .array(z.string()) - .max(5) - .optional() - .describe('Pin up to 5 providers the agent may use, by slug. Omitted means the whole catalog.'), - maxCalls: z - .number() - .int() - .min(1) - .max(30) - .optional() - .describe('Most provider calls the agent may make in this turn.'), - requireApproval: z - .boolean() - .optional() - .describe( - 'End the turn with a paid-call pendingApproval before any paid provider call. Requires mode "chat" on the same request, even on a follow-up.' - ), - approve: z - .strictObject({ - approvalId: z.string().uuid(), - callIds: z.array(z.string()).optional(), - always: z.boolean().optional(), - }) - .optional() - .describe( - 'Answer yes to the pendingApproval the previous turn of this thread ended on (its id, also exchange.requiresAction.approvalId). Needs threadId. For a terms offer, send it only after the user explicitly agreed and terms/accept succeeded; callIds and always are ignored on terms offers. For paid calls, callIds picks a subset (default all) and always stops asking for the rest of the thread.' - ), - decline: z - .strictObject({ approvalId: z.string().uuid() }) - .optional() - .describe( - 'Answer no to that pendingApproval. Needs threadId. A declined terms offer keeps those providers out of the rest of the thread.' - ), - onTermsRequired: z - .enum(['skip', 'ask']) - .optional() - .describe( - 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus a terms pendingApproval and exchange.requiresAction with the terms/show and terms/accept calls. Each provider digest is string | null and always present; when null, terms/show returns it. There is no auto-accept. Omitted on a follow-up keeps the previous turn\'s value.' - ), - }) - .describe( - 'Alexandria provider settings for this turn, forwarded as the request\'s exchange object.' - ); - server.addTool({ name: 'firecrawl_agent', annotations: { @@ -3582,9 +3528,8 @@ Run web research that returns structured data when the URLs are not known or the This call returns only a job ID, not the research result. Read the job with \`firecrawl_agent_status\` until it reaches \`completed\` or \`failed\`; a typical research run takes one to three minutes. For one known URL use \`firecrawl_scrape\` (with formats: ["json"] for structured output); for a plain lookup that a results page answers, use \`firecrawl_search\`. -The job also returns a \`threadId\`. To continue that thread, pass it with a follow-up \`prompt\`; omitted \`mode\`, \`urls\`, \`schema\` and exchange settings carry over from the previous turn. +The job also returns a \`threadId\`. To continue that thread, pass it with a follow-up \`prompt\`; omitted \`mode\`, \`urls\` and \`schema\` carry over from the previous turn. -The agent only calls Alexandria providers whose data terms the team has accepted. The status result's \`exchange.skippedProviders\` lists gated providers that would have helped. With \`exchange.onTermsRequired\` "ask", a terms offer ends the turn: \`pendingApproval\` (kind "terms") and \`exchange.requiresAction\` carry the \`approvalId\` and the exact terms/show and terms/accept calls. Show the user the terms, get their EXPLICIT consent, run terms/accept through \`firecrawl_scrape\`, then call \`firecrawl_agent\` with the same \`threadId\` and \`exchange.approve: {approvalId}\`. If they decline, send \`exchange.decline: {approvalId}\` instead. Never call terms/accept without that consent; a data request is not consent. `, outputSchema: agentOutputSchema, parameters: z.object({ @@ -3620,38 +3565,9 @@ The agent only calls Alexandria providers whose data terms the team has accepted .enum(['extract', 'chat']) .optional() .describe( - '"extract" (default) returns the complete structured result every turn. "chat" lets a follow-up that asks no new data get a short reply in message instead of a re-run; required for exchange.requireApproval. Omitted on a follow-up keeps the previous turn\'s mode.' + '"extract" (default) returns the complete structured result every turn. "chat" lets a follow-up that asks no new data get a short reply in message instead of a re-run. Omitted on a follow-up keeps the previous turn\'s mode.' ), - exchange: agentExchangeSchema.optional(), - }) - .refine( - (data) => !(data.exchange?.approve && data.exchange?.decline), - { - message: - 'Send exchange.approve or exchange.decline, not both: each answers the pending approval one way.', - path: ['exchange'], - } - ) - .refine( - (data) => - !(data.exchange?.approve || data.exchange?.decline) || - Boolean(data.threadId), - { - message: - 'exchange.approve and exchange.decline answer a pending approval on an existing thread: pass that thread\'s threadId.', - path: ['threadId'], - } - ) - .refine( - (data) => !data.exchange?.requireApproval || data.mode === 'chat', - { - // The agent service checks the mode sent on this request, not the - // thread's inherited one, and the gateway turns its 400 into a 500. - message: - 'exchange.requireApproval needs mode: "chat" on the same request, including on a follow-up.', - path: ['mode'], - } - ), + }), execute: async ( args: unknown, { session, log, client: mcpClient } @@ -3672,9 +3588,6 @@ The agent only calls Alexandria providers whose data terms the team has accepted strictConstrainToURLs: a.strictConstrainToURLs as boolean | undefined, threadId: a.threadId as string | undefined, mode: a.mode as 'extract' | 'chat' | undefined, - // Forwarded verbatim: the agent service owns the defaults and the - // per-thread inheritance. - exchange: a.exchange as Record | undefined, }); const res = await (client as any).startAgent({ ...agentBody, diff --git a/src/tool-output.ts b/src/tool-output.ts index a8ba4031..957525f5 100644 --- a/src/tool-output.ts +++ b/src/tool-output.ts @@ -234,14 +234,8 @@ export const agentStatusOutputSchema = z mode: str('Agent mode the job ran in.'), threadId: str('Research thread this job belongs to.'), threadTurn: num('Turn number of this job within its thread.'), - message: unknown('The agent\'s reply, including what it could not answer.'), + message: unknown('The agent\'s reply; in chat mode, the short answer to a follow-up.'), suggestions: unknown('Follow-ups the agent offers; send one as the prompt of the next turn with this threadId.'), - exchange: unknown( - 'What the job did with Alexandria providers: onTermsRequired, paidCalls, creditsUsed, skippedProviders (gated providers that would have helped), and requiresAction (approvalId plus the terms/show and terms/accept calls, each provider digest string | null and always present; call accept only with the user\'s explicit consent, then continue the thread with exchange.approve: {approvalId}).' - ), - pendingApproval: unknown( - 'Set when the job ended waiting on the caller. Answer it by calling firecrawl_agent with this threadId and exchange.approve or exchange.decline carrying its id. kind "terms" lists providers whose data terms need accepting; otherwise calls lists paid calls waiting for approval.' - ), }) .describe('Progress or final result of a research agent job.'); diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index cb0e60e9..57df463e 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -285,72 +285,13 @@ async function startFakeFirecrawlApi() { return; } - if (req.method === 'GET' && req.url === '/v2/agent/00000000-0000-4000-8000-000000000032') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end( - JSON.stringify({ - data: null, - expiresAt: '2026-10-01T00:00:00.000Z', - message: 'Apollo could add verified work emails.', - mode: 'chat', - model: 'spark-2', - status: 'completed', - success: true, - exchange: { - enabled: true, - onTermsRequired: 'ask', - paidCalls: 0, - creditsUsed: null, - skippedProviders: [ - { - provider: 'apollo', - name: 'Apollo', - capability: 'people/search', - reason: 'terms_required', - version: 'F-1.0.0', - termsUrl: 'https://www.firecrawl.dev/app/alexandria/apollo', - }, - ], - requiresAction: { - type: 'accept_terms', - approvalId: '00000000-0000-4000-8000-000000000033', - providers: [ - { - provider: 'apollo', - name: 'Apollo', - version: 'F-1.0.0', - digest: null, - url: 'https://www.firecrawl.dev/app/alexandria/apollo', - show: { provider: 'firecrawl', capability: 'terms/show', options: { provider: 'apollo' } }, - accept: { - provider: 'firecrawl', - capability: 'terms/accept', - options: { provider: 'apollo', version: 'F-1.0.0', digest: null, confirmed: true }, - }, - }, - ], - }, - }, - pendingApproval: { - id: '00000000-0000-4000-8000-000000000033', - kind: 'terms', - reason: 'Apollo could add verified work emails.', - calls: [], - terms: [{ provider: 'apollo', name: 'Apollo', version: 'F-1.0.0', digest: null, url: 'https://www.firecrawl.dev/app/alexandria/apollo' }], - resolution: null, - }, - }) - ); - return; - } - if (req.method === 'GET' && req.url === '/v2/agent/00000000-0000-4000-8000-000000000034') { res.writeHead(200, { 'content-type': 'application/json' }); res.end( JSON.stringify({ data: null, expiresAt: '2026-10-01T00:00:00.000Z', - message: 'Apollo can return verified work emails for 3 credits.', + message: 'Kept the 2 founders.', mode: 'chat', model: 'spark-2', status: 'completed', @@ -358,22 +299,6 @@ async function startFakeFirecrawlApi() { threadId: '00000000-0000-4000-8000-000000000031', threadTurn: 2, suggestions: [{ label: 'Only founders', prompt: 'Only keep the founders' }], - exchange: { enabled: true, requireApproval: true, paidCalls: 0, creditsUsed: null }, - pendingApproval: { - id: '00000000-0000-4000-8000-000000000035', - kind: 'calls', - reason: 'Apollo can return verified work emails.', - calls: [ - { - id: 'call-1', - provider: 'apollo', - capability: 'people/search', - input: { domain: 'exa.ai' }, - creditsEstimate: 3, - }, - ], - resolution: null, - }, }) ); return; @@ -4007,70 +3932,7 @@ test('firecrawl_agent forwards effort, maxCredits and strictConstrainToURLs to / assert.equal(fakeApi.requests.filter((request) => request.url === '/v2/agent').length, sentBefore); }); -test('firecrawl_agent forwards onTermsRequired and status keeps the terms-required fields', async (t) => { - const fakeApi = await startFakeFirecrawlApi(); - t.after(() => fakeApi.close()); - - const child = spawnServer({ - FIRECRAWL_API_KEY: 'fc-test', - FIRECRAWL_API_URL: fakeApi.url, - }); - t.after(() => stopChild(child)); - - const client = new StdioMcpClient(child); - await client.request('initialize', { - capabilities: {}, - clientInfo: { name: 'firecrawl-mcp-terms-required', version: '0.0.0' }, - protocolVersion: '2025-06-18', - }); - client.notify('notifications/initialized'); - - const { tools } = await client.request('tools/list'); - const agentTool = tools.find((tool) => tool.name === 'firecrawl_agent'); - assert.equal('onTermsRequired' in agentTool.inputSchema.properties, false); - assert.deepEqual(agentTool.inputSchema.properties.exchange.properties.onTermsRequired.enum, ['skip', 'ask']); - assert.match(agentTool.description, /exchange\.skippedProviders/); - assert.match(agentTool.description, /exchange\.requiresAction/); - assert.match(agentTool.description, /get their EXPLICIT consent.*Never call terms\/accept without that consent/); - - const asked = await client.request('tools/call', { - arguments: { prompt: 'Find the key business contact at exa.ai', exchange: { onTermsRequired: 'ask' } }, - name: 'firecrawl_agent', - }); - assert.notEqual(asked.isError, true); - const plain = await client.request('tools/call', { - arguments: { prompt: 'Find the example domain owner' }, - name: 'firecrawl_agent', - }); - assert.notEqual(plain.isError, true); - const bodies = fakeApi.requests - .filter((request) => request.method === 'POST' && request.url === '/v2/agent') - .map((request) => request.body); - assert.deepEqual(bodies[0].exchange, { onTermsRequired: 'ask' }); - assert.equal('exchange' in bodies[1], false); - - // There is no auto-accept mode: any other value fails parameter validation. - await assert.rejects( - client.request('tools/call', { - arguments: { prompt: 'Find the key business contact at exa.ai', exchange: { onTermsRequired: 'fail' } }, - name: 'firecrawl_agent', - }), - /onTermsRequired/ - ); - - const status = await client.request('tools/call', { - arguments: { id: '00000000-0000-4000-8000-000000000032' }, - name: 'firecrawl_agent_status', - }); - assert.notEqual(status.isError, true); - const structured = status.structuredContent; - assert.equal(structured.exchange.skippedProviders[0].reason, 'terms_required'); - assert.equal(structured.exchange.requiresAction.providers[0].accept.capability, 'terms/accept'); - assert.equal(structured.pendingApproval.kind, 'terms'); - assert.equal(structured.message, 'Apollo could add verified work emails.'); -}); - -test('firecrawl_agent continues a thread and answers a pending approval', async (t) => { +test('firecrawl_agent continues a thread', async (t) => { const fakeApi = await startFakeFirecrawlApi(); t.after(() => fakeApi.close()); @@ -4089,83 +3951,25 @@ test('firecrawl_agent continues a thread and answers a pending approval', async client.notify('notifications/initialized'); const threadId = '00000000-0000-4000-8000-000000000031'; - const approvalId = '00000000-0000-4000-8000-000000000033'; const { tools } = await client.request('tools/list'); const agentTool = tools.find((tool) => tool.name === 'firecrawl_agent'); const props = agentTool.inputSchema.properties; assert.equal(props.threadId.format, 'uuid'); assert.deepEqual(props.mode.enum, ['extract', 'chat']); - // The exchange object mirrors the gateway's agentExchangeSchema key for key. - assert.deepEqual(Object.keys(props.exchange.properties).sort(), [ - 'approve', - 'decline', - 'enabled', - 'maxCalls', - 'onTermsRequired', - 'requireApproval', - 'toolkits', - ]); - assert.equal(props.exchange.additionalProperties, false); - assert.deepEqual(Object.keys(props.exchange.properties.approve.properties).sort(), [ - 'always', - 'approvalId', - 'callIds', - ]); - assert.deepEqual(props.exchange.properties.approve.required, ['approvalId']); - assert.deepEqual(Object.keys(props.exchange.properties.decline.properties), ['approvalId']); - assert.equal(props.exchange.properties.maxCalls.minimum, 1); - assert.equal(props.exchange.properties.maxCalls.maximum, 30); assert.equal('model' in props, false); assert.ok(agentTool.description.length <= CLAUDE_CODE_TEXT_CAP, `description is ${agentTool.description.length} chars`); - assert.match(agentTool.description, /same `threadId` and `exchange\.approve: \{approvalId\}`/); - assert.match(agentTool.description, /`exchange\.decline: \{approvalId\}`/); - assert.match(agentTool.description, /EXPLICIT consent/); - assert.match(agentTool.description, /Never call terms\/accept without that consent/); + assert.match(agentTool.description, /To continue that thread, pass it with a follow-up `prompt`/); const call = (args) => client.request('tools/call', { arguments: args, name: 'firecrawl_agent' }); - // 1. A follow-up turn on the same thread. + // A follow-up turn on the same thread, and a turn that only sets the mode. const followUp = await call({ prompt: 'Only keep the founders', threadId, mode: 'chat' }); assert.notEqual(followUp.isError, true); assert.equal(followUp.structuredContent.threadId, threadId); assert.equal(followUp.structuredContent.threadTurn, 1); - - // 2. Accepting a terms offer after terms/accept, and declining one. - const approved = await call({ - prompt: 'I accepted the Apollo terms. Continue.', - threadId, - exchange: { approve: { approvalId } }, - }); - assert.notEqual(approved.isError, true); - const declined = await call({ - prompt: 'Do not use Apollo.', - threadId, - exchange: { decline: { approvalId } }, - }); - assert.notEqual(declined.isError, true); - - // A paid-call approval with a subset, plus the other exchange settings. - const paid = await call({ - prompt: 'Run only the first call.', - threadId, - exchange: { - approve: { approvalId, callIds: ['call-1'], always: true }, - toolkits: ['apollo'], - maxCalls: 4, - requireApproval: true, - enabled: true, - }, - mode: 'chat', - }); - assert.notEqual(paid.isError, true); - - const asked = await call({ - prompt: 'Keep asking about terms.', - threadId, - exchange: { maxCalls: 2, onTermsRequired: 'ask' }, - }); - assert.notEqual(asked.isError, true); + const inherited = await call({ prompt: 'Add their LinkedIn URLs', threadId }); + assert.notEqual(inherited.isError, true); const bodies = fakeApi.requests .filter((request) => request.method === 'POST' && request.url === '/v2/agent') @@ -4176,56 +3980,21 @@ test('firecrawl_agent continues a thread and answers a pending approval', async }); assert.deepEqual(bodies, [ { prompt: 'Only keep the founders', threadId, mode: 'chat' }, - { prompt: 'I accepted the Apollo terms. Continue.', threadId, exchange: { approve: { approvalId } } }, - { prompt: 'Do not use Apollo.', threadId, exchange: { decline: { approvalId } } }, - { - prompt: 'Run only the first call.', - threadId, - exchange: { - approve: { approvalId, callIds: ['call-1'], always: true }, - toolkits: ['apollo'], - maxCalls: 4, - requireApproval: true, - enabled: true, - }, - mode: 'chat', - }, - { prompt: 'Keep asking about terms.', threadId, exchange: { maxCalls: 2, onTermsRequired: 'ask' } }, + { prompt: 'Add their LinkedIn URLs', threadId }, ]); // Nothing invents a model: the gateway runs every request on spark-2. for (const body of bodies) assert.equal('model' in body, false); - // Schema validation: every rejection happens before any request is sent. - const sent = bodies.length; - const rejects = [ + // Invalid values fail parameter validation before anything is sent. + for (const [args, pattern] of [ [{ prompt: 'x', threadId: 'not-a-uuid' }, /threadId/], [{ prompt: 'x', mode: 'research' }, /mode/], - [{ prompt: 'x', threadId, exchange: { approve: { approvalId: 'nope' } } }, /approvalId/], - [{ prompt: 'x', threadId, exchange: { approve: {} } }, /approvalId/], - [{ prompt: 'x', threadId, exchange: { approve: { approvalId, autoAccept: true } } }, /autoAccept/], - [{ prompt: 'x', threadId, exchange: { acceptTerms: true } }, /acceptTerms/], - [{ prompt: 'x', threadId, exchange: { maxCalls: 31 } }, /maxCalls/], - [{ prompt: 'x', threadId, exchange: { maxCalls: 0 } }, /maxCalls/], - [{ prompt: 'x', threadId, exchange: { onTermsRequired: 'accept' } }, /onTermsRequired/], - [{ prompt: 'x', exchange: { approve: { approvalId } } }, /threadId/], - [{ prompt: 'x', exchange: { decline: { approvalId } } }, /threadId/], - [ - { prompt: 'x', threadId, exchange: { approve: { approvalId }, decline: { approvalId } } }, - /not both/, - ], - [{ prompt: 'x', threadId, exchange: { toolkits: ['a', 'b', 'c', 'd', 'e', 'f'] } }, /toolkits/], - // The agent service checks the mode on the request itself, so an inherited - // chat mode is not enough. - [{ prompt: 'x', exchange: { requireApproval: true } }, /requireApproval needs mode/], - [{ prompt: 'x', mode: 'extract', exchange: { requireApproval: true } }, /requireApproval needs mode/], - [{ prompt: 'x', threadId, exchange: { requireApproval: true } }, /requireApproval needs mode/], - ]; - for (const [args, pattern] of rejects) { + ]) { await assert.rejects(call(args), pattern, JSON.stringify(args)); } assert.equal( fakeApi.requests.filter((request) => request.method === 'POST' && request.url === '/v2/agent').length, - sent + bodies.length ); // A thread error from the API reaches the caller with its message. @@ -4235,26 +4004,16 @@ test('firecrawl_agent continues a thread and answers a pending approval', async ); assert.match(busy, /This thread already has a run in progress/); - // 3. Status keeps the thread and both pendingApproval shapes in structuredContent. - const status = async (id) => { - const result = await client.request('tools/call', { arguments: { id }, name: 'firecrawl_agent_status' }); - assert.notEqual(result.isError, true); - return result.structuredContent; - }; - const terms = await status('00000000-0000-4000-8000-000000000032'); - assert.equal(terms.pendingApproval.kind, 'terms'); - assert.deepEqual(terms.pendingApproval.calls, []); - assert.equal(terms.pendingApproval.terms[0].provider, 'apollo'); - assert.equal(terms.exchange.requiresAction.type, 'accept_terms'); - assert.equal(terms.exchange.requiresAction.approvalId, terms.pendingApproval.id); - assert.equal(terms.exchange.requiresAction.providers[0].show.capability, 'terms/show'); - - const paidStatus = await status('00000000-0000-4000-8000-000000000034'); - assert.equal(paidStatus.threadId, threadId); - assert.equal(paidStatus.threadTurn, 2); - assert.equal(paidStatus.mode, 'chat'); - assert.equal(paidStatus.pendingApproval.kind, 'calls'); - assert.equal(paidStatus.pendingApproval.calls[0].id, 'call-1'); - assert.equal(paidStatus.pendingApproval.calls[0].creditsEstimate, 3); - assert.deepEqual(paidStatus.suggestions, [{ label: 'Only founders', prompt: 'Only keep the founders' }]); + // Status keeps the thread fields, the chat reply and the suggestions. + const status = await client.request('tools/call', { + arguments: { id: '00000000-0000-4000-8000-000000000034' }, + name: 'firecrawl_agent_status', + }); + assert.notEqual(status.isError, true); + const structured = status.structuredContent; + assert.equal(structured.threadId, threadId); + assert.equal(structured.threadTurn, 2); + assert.equal(structured.mode, 'chat'); + assert.equal(structured.message, 'Kept the 2 founders.'); + assert.deepEqual(structured.suggestions, [{ label: 'Only founders', prompt: 'Only keep the founders' }]); });