@nouracea/meta-mcp
v0.1.0
Published
Read-only MCP server for Meta (Facebook) Graph API — Marketing, Pages, Instagram, WhatsApp Business
Maintainers
Readme
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_tokenworks (Meta requiresAPPID|APPSECRETto 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
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-mcpOr manually in ~/.claude.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
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/Dockerfile— multi-stage Node 20 alpine build, runs as non-root withtinifor clean signalsdeploy/docker-compose.yml— two services: the app + Caddy as TLS-terminating reverse proxydeploy/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.
# 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/tcpFrom your laptop, push the project to the server:
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:
ssh root@SERVER_IP
cd /opt/meta-mcp
cp .env.example .env
nano .envFill 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
[email protected]chmod 600 .env. Build and start:
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 certVerify:
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:
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— walkpaging.nextup tomax_pages(default 4) timesmax_pages— safety cap
When auto_paginate=true the response shape is:
{
"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_LIMITrequests / 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 (usingestimated_time_to_regain_accessif present). - Retry: on HTTP 429 or Graph error codes
4,17,32,613the request is retried with exponential backoff (max 2 retries).
Security
FB_ACCESS_TOKENis sent only asAuthorization: Bearerheader, never as a query parameter — keeps it out of proxy / CDN logs.- If
FB_APP_SECRETis set, every request includesappsecret_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_proofare replaced with***to prevent leakage via tool output. - HTTP mode:
MCP_HTTP_AUTH_TOKENis mandatory, compared with constant-time.MCP_HTTP_ALLOWED_HOSTSenables 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→ pollreport_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/accountsround-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.jsonSmoke test without Claude
stdio:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.jsHTTP:
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"}}}' \
-iLicense
MIT.
