Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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

Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,11 @@ 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
- `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` 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:**

Expand Down Expand Up @@ -781,9 +786,21 @@ Then poll with `firecrawl_agent_status` using the returned job ID.
}
```

**Usage Example (follow-up on the same thread):**

```json
{
"name": "firecrawl_agent",
"arguments": {
"prompt": "Only keep the startups based in Europe",
"threadId": "0199a1b2-0000-7000-8000-000000000031"
}
}
```

**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`)

Expand Down
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3527,6 +3527,9 @@ 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 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.

`,
outputSchema: agentOutputSchema,
parameters: z.object({
Expand All @@ -3551,6 +3554,19 @@ 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.'
),
threadId: z
.string()
.uuid()
.optional()
.describe(
'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. Omitted on a follow-up keeps the previous turn\'s mode.'
),
}),
execute: async (
args: unknown,
Expand All @@ -3561,6 +3577,7 @@ This call returns only a job ID, not the research result. Read the job with \`fi
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 agentBody = removeEmptyTopLevel({
prompt: a.prompt as string,
Expand All @@ -3569,6 +3586,8 @@ 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,
threadId: a.threadId as string | undefined,
Comment thread
rakshith48 marked this conversation as resolved.
mode: a.mode as 'extract' | 'chat' | undefined,
});
const res = await (client as any).startAgent({
...agentBody,
Expand Down
2 changes: 2 additions & 0 deletions src/tool-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +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; in chat mode, the short answer to a follow-up.'),
Comment thread
rakshith48 marked this conversation as resolved.
suggestions: unknown('Follow-ups the agent offers; send one as the prompt of the next turn with this threadId.'),
})
.describe('Progress or final result of a research agent job.');

Expand Down
122 changes: 122 additions & 0 deletions tests/mcp-smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -268,6 +285,25 @@ 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: 'Kept the 2 founders.',
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' }],
})
);
return;
}

if (req.method === 'POST' && req.url === '/v2/map') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
Expand Down Expand Up @@ -3895,3 +3931,89 @@ test('firecrawl_agent forwards effort, maxCredits and strictConstrainToURLs to /
);
assert.equal(fakeApi.requests.filter((request) => request.url === '/v2/agent').length, sentBefore);
});

test('firecrawl_agent continues a thread', 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 { 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']);
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, /To continue that thread, pass it with a follow-up `prompt`/);

const call = (args) => client.request('tools/call', { arguments: args, name: 'firecrawl_agent' });

// 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);
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')
.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: '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);

// 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/],
]) {
await assert.rejects(call(args), pattern, JSON.stringify(args));
}
assert.equal(
fakeApi.requests.filter((request) => request.method === 'POST' && request.url === '/v2/agent').length,
bodies.length
);

// 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/);

// 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' }]);
});
Loading