meta-mcp
# meta-mcp
Read-only MCP server for **Meta (Facebook) Graph API**. Plugs into Claude Code (and any other MCP-compatible client) over **stdio** (local) or **Streamable HTTP** (remote).
Covers four API surfaces with a single long-lived token:
- **Marketing API** — ad accounts, campaigns, ad sets, ads, creatives, insights
- **Facebook Pages** — pages, posts, comments, page insights
- **Instagram Graph** — business accounts, media, media/account insights, hashtag search
- **WhatsApp Business** — WABAs, phone numbers, message templates, analytics
Plus `debug_token` (inspect any token) and `graph_get` (raw GET escape-hatch).
HTTP method is hard-coded to `GET` everywhere — Claude cannot accidentally create, mutate, or delete anything.
## Tools
Specialized (validated with zod):
| Surface | Tools |
|---|---|
| Common | `graph_get`, `debug_token` |
| Pages | `pages_list`, `page_get`, `page_posts`, `post_get`, `post_comments`, `page_insights` |
| Marketing | `ad_accounts_list`, `ad_account_get`, `campaigns_list`, `campaign_get`, `adsets_list`, `ads_list`, `adcreatives_list`, `adcreative_get`, `insights` |
| Instagram | `ig_business_account_get`, `ig_media_list`, `ig_media_get`, `ig_media_insights`, `ig_user_insights`, `ig_hashtag_search`, `ig_hashtag_media` |
| WhatsApp | `wa_business_owned_wabas`, `wa_phone_numbers`, `wa_phone_number_get`, `wa_message_templates`, `wa_template_get`, `wa_analytics` |
## 1. Get an access token
The token prefix `EAA...` is the same for every Graph token type — **you can't tell which kind you have from the prefix.** Run `debug_token` after first start to identify it. The four kinds:
| Kind | When to use | How to obtain |
|---|---|---|
| **System User** | Recommended for ads / business automation — long-lived, doesn't tie to a human account | Business Manager → Business settings → Users → System users → Generate new token |
| **Page Access Token** | Single page only | Graph Explorer with a user token: `GET /me/accounts?fields=access_token` |
| **User OAuth token** | Personal access, expires in ~60 days | Standard FB Login OAuth flow |
| **App access token** | Server-to-server calls that don't need user context (e.g. `debug_token`) | `APPID|APPSECRET` literal |
**For `page_insights` and any Instagram tool** you need a Page Access Token. This server resolves it lazily: if `FB_ACCESS_TOKEN` is a user/system token with admin rights, calling any Page/IG tool triggers a one-time `/me/accounts` fetch that caches each page's token. No manual swap needed.
## 2. App credentials (recommended)
If you set both `FB_APP_ID` and `FB_APP_SECRET` in `.env`:
- Every request is signed with `appsecret_proof` — Meta's defence against token replay if the access token leaks.
- `debug_token` works (Meta requires `APPID|APPSECRET` to call that endpoint).
Find them at **developers.facebook.com → My Apps → {your app} → Settings → Basic**.
## 3. Three ways to run
| Mode | When to use |
|---|---|
| **npm (`npx`)** | Easiest for end users — no clone, no build |
| **stdio (from source)** | Hacking on the server locally |
| **Docker (Ubuntu)** | Shared server, multiple devices, mobile, teammates |
### A. Install from npm
```bash
claude mcp add meta \
--env FB_ACCESS_TOKEN=<your-token> \
--env FB_APP_ID=<your-app-id> \
--env FB_APP_SECRET=<your-app-secret> \
-- npx -y meta-mcp
```
Or manually in `~/.claude.json`:
```json
{
"mcpServers": {
"meta": {
"command": "npx",
"args": ["-y", "meta-mcp"],
"env": {
"FB_ACCESS_TOKEN": "EAA...",
"FB_APP_ID": "<your-app-id>",
"FB_APP_SECRET": "<your-app-secret>"
}
}
}
}
```
In Claude Code: `/mcp` → `meta: connected`.
### B. Local — stdio from source
```bash
git clone https://github.com/nourgithub/meta-mcp.git
cd meta-mcp
cp .env.example .env
# edit .env — set FB_ACCESS_TOKEN at minimum
# (also FB_APP_ID + FB_APP_SECRET for debug_token / appsecret_proof)
npm install
npm run build
claude mcp add meta -- node "$(pwd)/dist/index.js"
```
### C. Remote — Docker on Ubuntu
Everything ships in [`deploy/`](deploy/):
- `deploy/Dockerfile` — multi-stage Node 20 alpine build, runs as non-root with `tini` for clean signals
- `deploy/docker-compose.yml` — two services: the app + Caddy as TLS-terminating reverse proxy
- `deploy/Caddyfile` — auto-HTTPS via Let's Encrypt, proxies `/mcp` (SSE-aware) and `/health`
Pre-requisites: Ubuntu server with public IP and a DNS A-record pointing your domain at it.
```bash
# 1) On the server — install Docker
ssh root@SERVER_IP
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 2) Open ports 80 and 443 (skip if no ufw)
ufw allow 80/tcp
ufw allow 443/tcp
```
**From your laptop**, push the project to the server:
```bash
tar -czf - -C /d/meta-mcp \
--exclude='./node_modules' --exclude='./dist' --exclude='./.env' --exclude='./.git' . \
| ssh root@SERVER_IP "mkdir -p /opt/meta-mcp && tar -xzf - -C /opt/meta-mcp"
```
Back on the **server**, prepare `.env`:
```bash
ssh root@SERVER_IP
cd /opt/meta-mcp
cp .env.example .env
nano .env
```
Fill at minimum:
```
FB_ACCESS_TOKEN=EAA...
FB_APP_ID=...
FB_APP_SECRET=...
MCP_TRANSPORT=http
MCP_HTTP_PORT=8765
MCP_HTTP_AUTH_TOKEN=<openssl rand -hex 32>
MCP_HTTP_ALLOWED_HOSTS=meta-mcp.example.com
META_MCP_DOMAIN=meta-mcp.example.com
ACME_EMAIL=you@example.com
```
`chmod 600 .env`. Build and start:
```bash
cd /opt/meta-mcp/deploy
docker compose build
docker compose up -d
docker compose ps # both services should be "running" / "healthy"
docker compose logs -f # watch Caddy fetch the cert
```
Verify:
```bash
curl https://meta-mcp.example.com/health
# {"ok":true,"name":"meta-mcp"}
curl -i -X POST https://meta-mcp.example.com/mcp
# 401 — missing bearer (expected)
```
Connect Claude Code:
```bash
claude mcp add --transport http meta https://meta-mcp.example.com/mcp \
--header "Authorization: Bearer <your MCP_HTTP_AUTH_TOKEN>"
```
## Pagination
All `*_list` tools accept:
- `limit` — page size (default 25)
- `auto_paginate=true` — walk `paging.next` up to `max_pages` (default 4) times
- `max_pages` — safety cap
When `auto_paginate=true` the response shape is:
```json
{
"items": [ ...flattened from all pages ],
"pages": 3,
"truncated": false,
"next_cursor": "QVFIUjF..."
}
```
Cursors are short-lived (~30 min). Don't reuse a stale `next_cursor` after a long delay.
## Rate limits
Meta's actual budget depends on app tier (Standard / Advanced), token type, and per-object usage — there is no single fixed RPS for Graph API. The server applies:
- **Client-side**: soft cap of `META_RPS_LIMIT` requests / second (default 25, env-tunable).
- **Header-aware cooldown**: after each response we parse `X-App-Usage`, `X-Ad-Account-Usage`, `X-Business-Use-Case-Usage`. If any bucket > 90%, the next request is delayed (using `estimated_time_to_regain_access` if present).
- **Retry**: on HTTP 429 or Graph error codes `4`, `17`, `32`, `613` the request is retried with exponential backoff (max 2 retries).
## Security
- `FB_ACCESS_TOKEN` is sent only as `Authorization: Bearer` header, never as a query parameter — keeps it out of proxy / CDN logs.
- If `FB_APP_SECRET` is set, every request includes `appsecret_proof` (HMAC-SHA256 of token with secret). Token replay from another machine fails.
- Graph error bodies are scrubbed before being returned: occurrences of the token, app secret and `appsecret_proof` are replaced with `***` to prevent leakage via tool output.
- HTTP mode: `MCP_HTTP_AUTH_TOKEN` is mandatory, compared with constant-time. `MCP_HTTP_ALLOWED_HOSTS` enables DNS-rebinding protection.
- HTTPS in Docker mode handled by Caddy automatically.
## Caveats
- **Insights async jobs not supported.** For huge Ads Insights ranges Meta recommends `POST /insights` → poll `report_run_id`. This server is GET-only; use shorter ranges or export from Ads Manager.
- **Webhook subscriptions out of scope.** MCP is a request/response model; subscribing to realtime updates needs a separate server.
- **Page-token cache is per-process.** Restarting the server clears it. First IG/Page-Insights call after restart triggers one extra `/me/accounts` round-trip.
## Project layout
```
meta-mcp/
├── src/
│ ├── index.ts # entry: picks transport, registers tools
│ ├── config.ts # loads .env, validates required vars
│ ├── client.ts # fetch wrapper, rate limiter, paginator, page-token cache
│ ├── http-server.ts # Express + StreamableHTTPServerTransport + bearer auth
│ └── tools/
│ ├── raw.ts # graph_get
│ ├── debug.ts # debug_token
│ ├── pages.ts # Facebook Pages
│ ├── marketing.ts # Ads
│ ├── instagram.ts # Instagram Graph
│ └── whatsapp.ts # WhatsApp Business
├── deploy/
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── Caddyfile
├── .env.example
├── package.json
└── tsconfig.json
```
## Smoke test without Claude
stdio:
```bash
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js
```
HTTP:
```bash
curl -sS -X POST https://meta-mcp.example.com/mcp \
-H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
-i
```
## License
MIT.
TDQS
Scored across 30 tools
Every tool has a clearly distinct purpose targeting a specific resource (ad account, campaign, Instagram media, WhatsApp phone number, etc.). Even similar tools like ads_list and adsets_list are distinguished by the parent entity (account, campaign, adset). No ambiguity.
Naming follows a consistent snake_case pattern (resource_verb) for most tools (e.g., ad_account_get, ads_list, campaign_get). A few tools like graph_get and debug_token deviate slightly but are still readable and serve special purposes.
30 tools is on the higher side but justified by the broad scope covering Facebook Ads, Instagram, WhatsApp Business, and Page insights. Each tool addresses a specific read operation, and the count is manageable for such a large API surface.
The tool set is comprehensive for read operations on Meta's Graph API, covering ads, Instagram, WhatsApp, and Pages. However, it lacks write operations (create/update/delete) which are available via graph_get escape hatch only. This is acceptable for a read-only server, but some common workflows (e.g., posting content) are absent.