multi-scraper-mcp
by Perufitlife
README.md
# MCP server as an Apify Actor — a working reference
This is the source behind [`renzomacar/multi-scraper-mcp`](https://apify.com/renzomacar/multi-scraper-mcp),
an MCP server that runs as an Apify Actor in Standby mode and exposes 16 lead-generation and
web-scraping tools to AI clients like Claude, Cursor and Windsurf.
It is published as a reference because the interesting part is not the tool list — it is the
three transport bugs that kept real clients from connecting while every run reported `SUCCEEDED`.
## The three failures this code fixes
### 1. A stale session id used to wedge the client forever
Sessions live in an in-memory `Map`, but a Standby run is not one long-lived process — Apify
recycles it. A client that reconnects on Tuesday with Monday's `Mcp-Session-Id` hits a process
that has never heard of it.
The server used to answer `400`. The [spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
is explicit that the answer must be `404`:
> The server **MAY** terminate the session at any time, after which it **MUST** respond to requests
> containing that session ID with HTTP 404 Not Found.
> When a client receives HTTP 404 [...] it **MUST** start a new session by sending a new
> `InitializeRequest` without a session ID attached.
The client's recovery path is keyed to the status code. `400` removes its ability to heal itself.
See `sessionGone()` in `src/main.js`.
### 2. The `Accept` header rejected clients before any of our code ran
The spec requires clients to send `Accept: application/json, text/event-stream`. Several real
clients send only one, or nothing, and the transport answers `406` before the request reaches a
handler — so there is nothing in the logs and no failed run in the console.
`normalizeAccept()` accepts them anyway. The catch: the transport reads the header from
`req.rawHeaders`, so patching `req.headers` alone does nothing.
### 3. A slow tool dropped the connection mid-work
These tools wrap scrapers that take one to three minutes. Without bytes on the wire the Standby
gateway drops the connection. The fix streams a `notifications/message` every 10 seconds, and
clears the interval in `finally` so an early error return cannot leak the timer.
## Billing honestly
`Actor.charge` sits on the path that actually returned data, after the items are in hand — not at
the top of the request handler. Charging on arrival means charging for failures.
## Layout
| file | what's in it |
|---|---|
| `src/main.js` | Actor init, Express app, Streamable HTTP + legacy SSE transports, the three fixes |
| `src/tools.js` | tool registry: name, description, input schema, target Actor |
| `.actor/` | Actor definition, input and output schemas |
## Running it
```bash
npm install
npm start
```
Then point an MCP client at `http://localhost:4321/mcp`. On the Apify platform the Standby URL is
`https://USERNAME--ACTOR-NAME.apify.actor/mcp` — note the `USERNAME--` prefix; publishing the URL
without it makes every copy-paste fail.
## A check worth running
Six requests, each one a bug that shipped:
1. handshake with a correct `Accept` header → 200
2. handshake with `Accept: application/json` → 200 (was 406)
3. handshake with no `Accept` header → 200 (was 406)
4. `tools/list` with an unknown session id → 404 (was 400)
5. `initialize` while still holding a stale id → 200 (was 400)
6. `tools/list` on a live session → 200, full tool list
Numbers 2 through 5 were all broken in production while the Actor reported healthy runs.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues