Skip to main content
Glama
Perufitlife

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

Maintenance

ActivityMaintained
ResponsivenessNo issues