DataForSEO MCP Server
README.md
# DataForSEO MCP Server
A production-ready **remote MCP server** that exposes the [DataForSEO API v3](https://docs.dataforseo.com/v3/) to Claude over the **Streamable HTTP** transport, designed to run on **Railway**.
It gives Claude access to essentially the whole DataForSEO surface — SERP, DataForSEO Labs, Keywords Data, Backlinks, OnPage, Business Data, Content Analysis, Merchant, App Data, Domain Analytics, Content Generation and AI Optimization — through 51 tools rather than several hundred, with cost controls, response normalisation and a strictly bounded generic request layer.
- **Endpoint:** `POST https://<your-app>.up.railway.app/mcp`
- **Transport:** Streamable HTTP (`@modelcontextprotocol/sdk` 1.30, stateless by default)
- **Stack:** Node 20+, TypeScript, Express 5, Zod 4
---
## Contents
- [Architecture](#architecture)
- [Tool layers](#tool-layers)
- [Available tools](#available-tools)
- [API family coverage](#api-family-coverage)
- [Local development](#local-development)
- [Environment variables](#environment-variables)
- [Railway deployment](#railway-deployment)
- [Connecting Claude](#connecting-claude)
- [DataForSEO authentication](#dataforseo-authentication)
- [Asynchronous DataForSEO jobs](#asynchronous-dataforseo-jobs)
- [Cost controls](#cost-controls)
- [Response modes and pagination](#response-modes-and-pagination)
- [Locations, languages, filters and sorting](#locations-languages-filters-and-sorting)
- [The generic request layer](#the-generic-request-layer)
- [Security model](#security-model)
- [Adding a new high-level wrapper](#adding-a-new-high-level-wrapper)
- [Testing](#testing)
- [Sandbox mode](#sandbox-mode)
---
## Architecture
```
src/
server.ts entry point: listen, graceful shutdown
app.ts Express app: /, /health, /mcp, limits, error handling
config.ts env parsing; the only place credentials live
mcp/
server.ts McpServer factory + model-facing instructions
transport.ts Streamable HTTP wiring, auth gate, origin gate, sessions
context.ts ToolContext (config, client, metadata, result builder)
tools/ tool definitions, one module per domain
discovery.ts keywords.ts domain.ts serp.ts backlinks.ts
onpage.ts business.ts content.ts account.ts generic.ts helpers.ts
dataforseo/
client.ts the single outbound HTTP path (auth, retries, logging)
auth.ts HTTP Basic header construction, forbidden headers
endpoints.ts family registry, allow-list, endpoint catalog, metadata sources
errors.ts DataForSEO status-code model → typed errors
types.ts response envelope types
services/ per-family request building + normalisation
serp.ts labs.ts keywords.ts backlinks.ts onpage.ts business.ts
content.ts account.ts generic.ts metadata.ts types.ts
normalizers/ DataForSEO response → concise SEO objects
serp.ts keywords.ts labs.ts backlinks.ts onpage.ts business.ts content.ts common.ts
schemas/common.ts shared Zod fields, filter/sort validation, target normalisation
utils/ logger (redacting), redaction, TTL cache, semaphore, result builder
tests/ vitest suite; all DataForSEO calls are mocked
scripts/
verify-mcp.mjs local Streamable HTTP + tools/list verification
smoke.mjs MANUAL smoke test against real DataForSEO credentials
```
Data flows in one direction: **tool → service → client → DataForSEO**, and back **client → normaliser → result builder → tool**. Only `dataforseo/client.ts` performs network I/O, and every request it makes passes `validateEndpointPath()` first.
## Tool layers
**Layer 1 — discovery.** `dataforseo_capabilities`, `dataforseo_locations`, `dataforseo_languages`, `dataforseo_endpoint_info`. These let Claude learn what exists, resolve identifiers and confirm a path before spending money. `dataforseo_capabilities` and `dataforseo_endpoint_info` make no DataForSEO call at all.
**Layer 2 — curated tools.** 46 tools covering the common SEO workflows with strict Zod schemas, friendly parameters, resolved locations/languages and normalised output.
**Layer 3 — the generic request.** `dataforseo_request` reaches any documented endpoint under an allowed `/v3/<family>/` prefix. New DataForSEO endpoints are usable the day they ship, without changing this server.
## Available tools
| Tool | What it does | DataForSEO endpoint |
| --- | --- | --- |
| `dataforseo_capabilities` | Families, coverage, limits, workflow hints | *(local)* |
| `dataforseo_locations` | Search location codes by name | `*/locations`, `dataforseo_labs/locations_and_languages` |
| `dataforseo_languages` | Search language codes | `*/languages` |
| `dataforseo_endpoint_info` | Explain/validate an endpoint, live vs async | *(local)* |
| `account_status` | Balance, limits, prices | `appendix/user_data` |
| `keyword_overview` | Volume, CPC, competition, difficulty, intent | `dataforseo_labs/google/keyword_overview/live` |
| `keyword_suggestions` | Long-tail keywords containing a seed | `dataforseo_labs/google/keyword_suggestions/live` |
| `related_keywords` | "Searches related to" expansion (depth 0–4) | `dataforseo_labs/google/related_keywords/live` |
| `keyword_ideas` | Category-based keyword ideas | `dataforseo_labs/google/keyword_ideas/live` |
| `search_intent` | Informational/commercial/etc. classification | `dataforseo_labs/google/search_intent/live` |
| `keyword_historical_volume` | Monthly history per keyword | `dataforseo_labs/google/historical_keyword_data/live` |
| `keywords_search_volume` | Google Ads / Bing Ads volumes | `keywords_data/{google_ads,bing}/search_volume/live` |
| `keyword_gap` | Keywords a competitor has and you don't | `dataforseo_labs/google/domain_intersection/live` (`intersections:false`) |
| `domain_overview` | Organic/paid metrics for a domain | `dataforseo_labs/google/domain_rank_overview/live` |
| `domain_ranked_keywords` | Every keyword a domain ranks for | `dataforseo_labs/google/ranked_keywords/live` |
| `page_ranked_keywords` | Keywords for one URL | `dataforseo_labs/google/ranked_keywords/live` |
| `domain_competitors` | Organic competitors | `dataforseo_labs/google/competitors_domain/live` |
| `domain_intersection` | Keywords two domains share | `dataforseo_labs/google/domain_intersection/live` |
| `page_intersection` | Keywords where pages rank together | `dataforseo_labs/google/page_intersection/live` |
| `relevant_pages` | Best pages of a domain | `dataforseo_labs/google/relevant_pages/live` |
| `serp_competitors` | Domains ranking for a keyword set | `dataforseo_labs/google/serp_competitors/live` |
| `bulk_traffic_estimation` | Traffic for up to 1000 targets | `dataforseo_labs/google/bulk_traffic_estimation/live` |
| `live_serp` | Live SERP, 7 engines, many search types | `serp/{engine}/{type}/live/advanced` |
| `rank_check` | Where a domain/URL ranks for a keyword | `serp/{engine}/organic/live/advanced` |
| `maps_serp` | Google Maps / Local Finder results | `serp/google/{maps,local_finder}/live/advanced` |
| `serp_task_post` | Queue a SERP task (cheaper) | `serp/{engine}/{type}/task_post` |
| `serp_task_get` | Collect a SERP task | `serp/{engine}/{type}/task_get/advanced/{id}` |
| `serp_tasks_ready` | List finished SERP tasks | `serp/{engine}/organic/tasks_ready` |
| `backlinks_summary` | Backlink profile summary | `backlinks/summary/live` |
| `backlinks` | Individual backlinks | `backlinks/backlinks/live` |
| `referring_domains` | Referring domains | `backlinks/referring_domains/live` |
| `backlink_anchors` | Anchor text distribution | `backlinks/anchors/live` |
| `backlink_competitors` | Shared-referrer competitors | `backlinks/competitors/live` |
| `backlinks_history` | Monthly link history | `backlinks/history/live` |
| `backlinks_bulk_metrics` | Bulk rank/backlinks/spam/refdomains | `backlinks/bulk_*/live` |
| `onpage_start_audit` | Start a crawl (async) | `on_page/task_post` |
| `onpage_audit_status` | Crawl progress / finished crawls | `on_page/summary/{id}`, `on_page/tasks_ready` |
| `onpage_summary` | Site-level technical SEO results | `on_page/summary/{id}` |
| `onpage_pages` | Crawled pages + per-page checks | `on_page/pages` |
| `onpage_issues` | Duplicate tags/content, non-indexable, redirects, links | `on_page/{issue_type}` |
| `onpage_resources` | Images/scripts/stylesheets | `on_page/resources` |
| `onpage_instant_page` | Analyse one URL immediately | `on_page/instant_pages` |
| `onpage_lighthouse` | Lighthouse scores | `on_page/lighthouse/live/json` |
| `onpage_force_stop` | Stop a running crawl | `on_page/force_stop` |
| `business_search` | Search local business listings | `business_data/business_listings/search/live` |
| `business_info` | Google Business Profile (async) | `business_data/google/my_business_info/task_*` |
| `business_reviews` | Google reviews (async) | `business_data/google/reviews/task_*` |
| `content_mentions` | Brand/keyword citations | `content_analysis/search/live` |
| `content_summary` | Mention volume aggregation | `content_analysis/summary/live` |
| `content_sentiment` | Sentiment of mentions | `content_analysis/sentiment_analysis/live` |
| `dataforseo_request` | Any allowed `/v3/` endpoint | *(generic)* |
## API family coverage
| Family | Coverage | Notes |
| --- | --- | --- |
| SERP | **Direct** + generic | Google (organic, maps, local finder, news, images, jobs, events, shopping, autocomplete, ai_mode…), Bing, Yahoo, YouTube, Baidu, Naver, Seznam. Live **and** task-based flows are first class. Less common Google verticals (finance, ads_search, dataset_search, screenshot, search-by-image) go through `dataforseo_request`. |
| DataForSEO Labs | **Direct** + generic | 13 dedicated tools. `keywords_for_site`, `subdomains`, `bulk_keyword_difficulty`, `historical_serps`, categories, top_searches, and the Amazon / Google Play / App Store Labs datasets via `dataforseo_request`. |
| Keywords Data | **Direct** + generic | `search_volume` for Google Ads and Bing directly; `keywords_for_site`, `keywords_for_keywords`, `ad_traffic_by_keywords`, Google Trends, DataForSEO Trends and clickstream via `dataforseo_request`. |
| Backlinks | **Direct** + generic | 7 tools cover the common profile work; `referring_networks`, intersections, timeseries, `domain_pages*`, remaining bulk endpoints via `dataforseo_request`. |
| OnPage | **Direct** + generic | Full async crawl lifecycle, pages, resources, links, duplicates, non-indexable, redirects, instant pages, Lighthouse. `waterfall`, `keyword_density`, `microdata`, `raw_html`, `content_parsing`, `page_screenshot` via `dataforseo_request`. |
| Business Data | **Direct** + generic | Listings search, Business Profile, reviews. Q&A, hotel searches/info, Trustpilot, Tripadvisor, social media via `dataforseo_request`. |
| Content Analysis | **Direct** + generic | Search, summary, sentiment directly; rating distribution, phrase/category trends via `dataforseo_request`. |
| Merchant | Generic | Google Shopping and Amazon products/ASIN/sellers. |
| App Data | Generic | Google Play and App Store searches, info, listings, reviews. |
| Domain Analytics | Generic | Technology detection and Whois with filters. |
| Content Generation | Generic | Generate, paraphrase, grammar check, meta tags, text summary. |
| AI Optimization | Generic | LLM responses/scrapers (ChatGPT, Claude, Gemini, Perplexity), LLM mentions, AI keyword volume. |
| Appendix | **Direct** + generic | `account_status`; errors/status via `dataforseo_request`. |
**Not reachable through this server:** DataForSEO features that are not v3 REST endpoints — the client dashboards, webhook *delivery* to this server (you can still set `postback_url` / `pingback_url` on a task, but nothing here receives them), and any endpoint outside the thirteen `/v3/` families above.
## Local development
```bash
npm install
cp .env.example .env # fill in DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD
npm run dev # tsx watch on http://localhost:3000
npm run typecheck
npm test # mocked, no network, no cost
npm run build && npm run verify:mcp # boots the built server and drives real MCP calls
```
Point any MCP client at `http://localhost:3000/mcp`, or use the MCP Inspector:
```bash
npx @modelcontextprotocol/inspector
```
## Environment variables
Required:
| Variable | Purpose |
| --- | --- |
| `DATAFORSEO_LOGIN` | DataForSEO API login (server-side only) |
| `DATAFORSEO_PASSWORD` | DataForSEO API password (server-side only) |
| `PORT` | HTTP port — Railway injects this automatically |
Optional:
| Variable | Default | Purpose |
| --- | --- | --- |
| `DATAFORSEO_SANDBOX` | `false` | Route everything to `sandbox.dataforseo.com` (free, dummy data) |
| `DATAFORSEO_BASE_URL` | `https://api.dataforseo.com` | Override; must be an https `*.dataforseo.com` host |
| `DATAFORSEO_TIMEOUT_MS` | `60000` | Per-request timeout (max 180000) |
| `DATAFORSEO_MAX_RETRIES` | `2` | Retries for transient failures (never for `task_post`) |
| `DATAFORSEO_MAX_CONCURRENCY` | `8` | Max in-flight DataForSEO requests |
| `MCP_AUTH_TOKEN` | *(unset)* | Comma-separated bearer tokens required on `/mcp` |
| `MCP_SESSION_MODE` | `stateless` | `stateless` or `stateful` |
| `MCP_SESSION_IDLE_TIMEOUT_MS` | `1800000` | Stateful mode only: reap sessions idle this long |
| `MCP_ALLOWED_ORIGINS` | *(unset)* | Comma-separated allowed `Origin` values |
| `PUBLIC_URL` | *(unset)* | Public base URL, echoed on `/` |
| `MAX_RESULT_ITEMS` | `50` | Default rows per tool call |
| `MAX_RESULT_ITEMS_HARD_CAP` | `1000` | Absolute row ceiling |
| `MAX_RESPONSE_CHARS` | `100000` | Character budget per tool response |
| `MAX_BATCH_ITEMS` | `100` | Max keywords/targets per batched call |
| `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX` | `60000` / `120` | Rate limit on this server |
| `MAX_REQUEST_BODY_SIZE` | `1mb` | JSON body limit on `/mcp` |
| `METADATA_CACHE_TTL_MS` | `21600000` | Locations/languages cache TTL (6h) |
| `LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error`/`silent` |
| `TRUST_PROXY` | `1` | Express `trust proxy` setting |
`.env.example` contains names only — never commit real values.
## Railway deployment
1. Push this repository to GitHub and create a Railway project from it.
2. Railway detects Node via Nixpacks; `railway.json` pins the commands:
- build: `npm ci && npm run build`
- start: `npm run start`
- healthcheck: `/health`
3. Set variables in the Railway service:
- `DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD` (required)
- optionally `MCP_AUTH_TOKEN`, `PUBLIC_URL`, `LOG_LEVEL`, limit overrides
- do **not** set `PORT` manually; Railway provides it
4. Deploy, then check `https://<app>.up.railway.app/health`.
Notes: `/health` returns **503** while credentials are missing, so a misconfigured deploy fails its healthcheck by design. `trust proxy` is on for Railway's proxy. `SIGTERM` triggers graceful shutdown (open MCP sessions closed, then the HTTP server). Nothing is written to disk — state is in memory only, so replicas and restarts are safe (stateless session mode is the default for exactly this reason). Docker is not required.
## Connecting Claude
Claude.ai / Claude Desktop → **Settings → Connectors → Add custom connector**:
- URL: `https://<your-app>.up.railway.app/mcp`
- If `MCP_AUTH_TOKEN` is set, supply the token as a bearer token in the connector's authentication settings.
Claude Code:
```bash
claude mcp add --transport http dataforseo https://<your-app>.up.railway.app/mcp \
--header "Authorization: Bearer <token>"
```
Ask Claude to call `dataforseo_capabilities` first — it returns the map of families, tools and limits.
## DataForSEO authentication
DataForSEO uses HTTP Basic auth. This server:
- builds the `Authorization: Basic base64(login:password)` header in `dataforseo/auth.ts`
- attaches it inside `DataForSeoClient.send()` immediately before `fetch`
- never places it on any object that is logged or serialised
- never returns login, password, encoded credentials or authorization headers to the MCP client — `account_status` deliberately omits the account `login`, and `/health` reports only *whether* credentials are configured
Tool callers cannot supply headers at all, and `dataforseo_request` rejects bodies containing `login`, `password`, `authorization`, `headers`, `credentials` or `api_key` at any depth.
## Asynchronous DataForSEO jobs
Task-based DataForSEO endpoints are exposed honestly — no request is ever held open waiting for a long job.
| Workflow | Start | Poll / collect |
| --- | --- | --- |
| SERP tasks | `serp_task_post` → `task_id` | `serp_tasks_ready`, then `serp_task_get` |
| Site crawls | `onpage_start_audit` → `task_id` | `onpage_audit_status` until `crawl_progress: "finished"`, then `onpage_summary` / `onpage_pages` / `onpage_issues` |
| Business Profile / reviews | `business_info` / `business_reviews` with `action: "start"` | same tool with `action: "collect"` and the `task_id` |
| Anything else | `dataforseo_request` on a `task_post` path | `dataforseo_request` on the matching `task_get` path |
While DataForSEO is still working (status codes `40601`/`40602`) the collect tools return `ready: false` with a note, rather than an error or a hanging request.
## Cost controls
DataForSEO is usage-priced, so the defaults are deliberately conservative:
- default `limit` is 50 rows (`MAX_RESULT_ITEMS`), hard-capped at 1000
- keyword/target batches are capped at `MAX_BATCH_ITEMS` (100) with an explicit error rather than silent truncation
- crawls default to `max_crawl_pages: 100`; SERP depth defaults to 100 elements
- nothing recursively pages: `next_offset` is returned and Claude must ask for the next page
- no tool fans out into multiple DataForSEO calls; bulk endpoints are preferred and documented in the tool descriptions
- `task_post` is never retried, so a task cannot be created (and billed) twice
- concurrency is bounded by a semaphore (`DATAFORSEO_MAX_CONCURRENCY`)
- every response preserves DataForSEO's own accounting:
```json
"usage": { "dataforseo_cost": 0.0123, "tasks_count": 1, "result_count": 100, "duration_ms": 812 }
```
Every outbound call is logged as one structured line (`dataforseo_request`) with endpoint, method, status, duration, cost and result counts — and never any credential.
## Response modes and pagination
Every list-returning tool accepts:
- `response_mode`: `summary` (headline fields) · `standard` (default; the useful SEO fields) · `full` (raw DataForSEO fields, still subject to `MAX_RESPONSE_CHARS`)
- `limit` / `offset`, with `pagination.next_offset` returned when a full page came back
If a response would still exceed the character budget, rows are dropped from the end and an `output_truncated` block explains exactly what was omitted and how to narrow the request.
## Locations, languages, filters and sorting
Pass natural values — `"United Kingdom"`, `"London,England,United Kingdom"`, `"Marbella, Andalusia, Spain"`, `"English"`, `"Spanish"` — and they are resolved against the correct per-family DataForSEO metadata list (SERP, Labs and Keywords Data use different location code sets). Numeric location codes and ISO language codes are accepted verbatim and skip the lookup entirely. Ambiguous names produce an error listing the candidates; metadata lists are cached in memory for `METADATA_CACHE_TTL_MS`.
DataForSEO's filtering power is preserved, not removed: `filters` accepts documented expressions such as
```json
[["keyword_data.keyword_info.search_volume", ">", 1000], "and", ["keyword_data.keyword_info.cpc", "<", 5]]
```
Field paths, operators, value types and nesting depth are validated before the request is sent. `order_by` accepts up to 3 rules of the form `"field,asc"` / `"field,desc"`.
## The generic request layer
```jsonc
{
"endpoint": "/v3/domain_analytics/technologies/domain_technologies/live",
"method": "POST",
"body": { "target": "example.com" },
"response_mode": "standard",
"item_limit": 50
}
```
Guarantees enforced by `validateEndpointPath()` and `services/generic.ts`:
- the host is **always** the configured DataForSEO base URL; callers supply a path, never a URL
- the path must start with `/v3/` and sit under one of the thirteen allowed families
- absolute URLs, protocol-relative paths, `..`, query strings, fragments, `@`, backslashes, whitespace and control characters are rejected
- only `GET` and `POST` are possible
- no header input is accepted, so authentication cannot be injected or overridden
- credential-shaped body keys are rejected at any depth
- bodies are capped at 128 KB and 100 task objects
It is an escape hatch for DataForSEO, not an HTTP proxy.
## Security model
- **Credentials** stay in `config.credentials` and the `Authorization` header; they are registered with the redaction layer so any accidental appearance in logs or tool output is masked.
- **MCP authentication** is optional and pluggable: set `MCP_AUTH_TOKEN` for bearer auth (constant-time comparison, multiple tokens for rotation). The gate is one middleware in `mcp/transport.ts`, so OAuth can replace it without touching the DataForSEO integration. `/health` stays open for platform probes.
- **Origin validation** (`MCP_ALLOWED_ORIGINS`) protects browser-originated callers against DNS rebinding; requests with no `Origin` (Claude, curl) are unaffected.
- **Rate limiting** on `/mcp` via `express-rate-limit`, plus a JSON body size limit.
- **Error sanitisation**: DataForSEO and internal errors become structured payloads (`kind`, `message`, `hint`, `retryable`); stack traces never reach the client.
- **No filesystem persistence**, no `eval`, no dynamic endpoint construction from caller input beyond the validated path.
## Adding a new high-level wrapper
1. Add the endpoint to `ENDPOINT_CATALOG` in `src/dataforseo/endpoints.ts` (this powers discovery).
2. Add a normaliser in `src/normalizers/<family>.ts` using `byMode()` so `summary` / `standard` / `full` all behave.
3. Add a service function in `src/services/<family>.ts` that builds the task object, calls `ctx.client.post(...)` and returns `{ data, meta: metaFrom(response), notes?, pagination? }`.
4. Register the tool in `src/mcp/tools/<family>.ts` with `defineTool()` and a strict Zod schema (reuse `schemas/common.ts` fields).
5. Add tests: schema rejection, a mocked happy path, and the normaliser.
Until a wrapper exists, the endpoint is already usable through `dataforseo_request`.
## Testing
```bash
npm test # 109 tests: credentials, allow-list, error parsing, location/language
# resolution, generic-request protection, normalisers, tool schemas, HTTP
npm run verify:mcp # Streamable HTTP initialize + tools/list against the built server
npm run smoke # MANUAL only — real credentials; defaults to the sandbox
```
The automated suite mocks every DataForSEO call and never makes a paid request. `scripts/smoke.mjs` is the only script that talks to DataForSEO; it uses the sandbox unless you pass `--production`, and only makes a billed call with `--production --paid`.
## Sandbox mode
DataForSEO offers a free sandbox at `https://sandbox.dataforseo.com` that mirrors the production response structure with dummy data. Set `DATAFORSEO_SANDBOX=true` to route every call there — useful for development and for verifying a deployment without spending credits. Responses are tagged with `"sandbox": true` so Claude knows the data is not real. Production behaviour is unchanged when the flag is unset.
---
Built against DataForSEO API v3 documentation and the Model Context Protocol specification as published in August 2026.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing