Skip to main content
Glama
fanfou-mcp

fanfou-mcp

by Anthonyeef

fanfou-mcp

A Model Context Protocol server for Fanfou (饭否). 让 Claude、Cursor 等 MCP 客户端直接读写你的饭否时间线。

fanfou-mcp exposes the Fanfou REST API as MCP tools so that an AI assistant can read your timeline, search statuses, look up users and (optionally, with explicit confirmation) post, delete, favorite and follow.

It ships with two transports sharing the same tool set:

Transport

Entry point

Typical use

stdio

server.py

Local MCP clients (Claude Desktop, Cursor, …) launching the server as a subprocess

Streamable HTTP

http_app.py (ASGI)

Remote / serverless hosting, e.g. on Vercel — single-user (one bearer token) or multi-user (each user authorizes their own Fanfou account)

Python ≥ 3.12, built on the official mcp Python SDK (FastMCP). The Fanfou client itself uses only the standard library (no requests / oauthlib).


⚠️ Before you start: you need your own Fanfou app key

Fanfou has not accepted new API application registrations since 2020 (the developer program is whitelist-only). 饭否自 2020 年起不再开放新应用申请,新注册的应用无法通过审核,调用 API 会返回 401。

This project therefore does not include any consumer key. To use it you must bring:

  1. A consumer key / secret of a Fanfou app that has already been approved (审核通过), and

  2. An OAuth access token / secret for your account, obtained with that app (see OAuth authorization).

An app whose review status is "rejected" (已驳回) or pending will be able to complete the OAuth handshake, but every API call (even /help/test.json) will fail with 401 参数错误!.


Related MCP server: twitter-mcp

Tools

Read-only tools (11)

Tool

Description

verify_credentials

Return the authenticated account

home_timeline

Home timeline (statuses from people you follow); count 1–60, since_id, max_id, page

user_timeline

A user's statuses (user_id empty = yourself)

mentions

Statuses mentioning you

public_timeline

Latest public statuses

search_public_timeline

Keyword search across public statuses (a b = AND, a|b = OR)

search_user_timeline

Keyword search within one user's statuses

get_status

One status by id

get_context

Conversation context (reply chain) of a status

get_user

A user's profile (user_id empty = yourself)

favorites

A user's favorited statuses

Write tools (4)

Tool

Description

post_status

Post a status; supports in_reply_to_status_id and repost_status_id

delete_status

Delete one of your own statuses

favorite

Favorite (action="create") or unfavorite (action="destroy") a status

friendship

Follow (action="create") or unfollow (action="destroy") a user

Safety rails for write tools:

  • Every write tool requires confirm=true. Without it, nothing is sent and the tool returns an error (post_status also returns a preview of the text). Tool descriptions instruct the model to only set confirm=true after the user has explicitly approved the exact action in chat.

  • Set FANFOU_MCP_READONLY=1 to not register the write tools at all. This is the default for the HTTP server (http_app.py); the stdio server defaults to 0 (write tools available).

Responses are trimmed to the useful fields (id, text, author, timestamps, photo URL, repost, …) to keep the model's context small. API errors are returned as {"error": "..."} rather than raised.


Architecture

fanfou_api.py   Minimal Fanfou OAuth 1.0a client (HMAC-SHA1 signing, stdlib urllib)
creds.py        Credential resolution: app key from env + per-request user token; multi-user token storage
kv.py           Tiny KV client: Vercel KV / Upstash Redis REST API (stdlib), or in-memory for local dev
tools.py        FastMCP instance and all 15 tool definitions; READONLY switch
server.py       stdio entry point            ->  mcp.run()
http_app.py     Streamable HTTP ASGI app     ->  POST /mcp, GET /healthz, bearer auth,
                                                 GET /authorize, GET /callback, POST /revoke (multi-user)

Helper scripts:

File

Purpose

run.sh

Launch the stdio server via uv, optionally loading credentials from local files (see below)

dev_http.sh

Serve http_app:app locally with uvicorn on 127.0.0.1:${PORT:-8765}

test_client.py

stdio smoke test: list tools, call a few read tools (--all for more)

test_http_client.py

HTTP smoke test against a local or remote /mcp endpoint

test_multiuser.py

Multi-user end-to-end test (OAuth routes, per-user tokens, revoke)

dev/mock_upstash.py

Local stand-in for the KV REST API, for testing

dev/mint_token.py

Issue a personal token for an existing Fanfou access token (admin / migration)

Notes on the HTTP app:

  • Stateless, JSON responses (stateless_http=True, json_response=True) and a fresh StreamableHTTPSessionManager per request, so it does not depend on ASGI lifespan — safe for serverless.

  • creds.current_principal / creds.current_user_token are ContextVars set per request by the auth layer. In multi-user mode they carry the calling user's Fanfou token (looked up in KV); with the admin token, or over stdio, the owner token from env is used. Tools never see bearer tokens.


Implementation notes (Fanfou quirks)

These cost real debugging time; they are documented here so you don't have to rediscover them.

  1. OAuth 1.0a: sign http://, request https://. Requests are sent to https://api.fanfou.com/..., but the OAuth signature base string must use the http:// URL. Signing the https URL fails with Invalid signature. Expected basestring is GET&http%3A%2F%2F.... 签名 base string 必须用 http://,实际请求走 https://。

  2. The API gateway rejects curl/* User-Agents. api.fanfou.com answers an nginx 404 for any curl/* UA, so the client sends its own User-Agent. Keep this in mind when testing by hand with curl (pass -A something-else).

  3. Requests use mode=lite where supported to get smaller payloads.

  4. Percent-encoding follows RFC 3986 (quote(s, safe="~")) for both the signature and the request.


Configuration

Variable

Required

Description

FANFOU_API_KEY

yes

Consumer key of an approved Fanfou app

FANFOU_API_SECRET

yes

Consumer secret

FANFOU_OAUTH_TOKEN

single-user

User access token (owner account; optional in multi-user mode)

FANFOU_OAUTH_TOKEN_SECRET

single-user

User access token secret

MCP_AUTH_TOKEN

HTTP only

Admin / owner bearer token (Authorization: Bearer <token>); acts as the env account

KV_REST_API_URL / KV_REST_API_TOKEN

multi-user (option A)

Vercel KV / Upstash Redis REST credentials. Also accepts UPSTASH_REDIS_REST_* and UPSTASH_KV_REST_API_* (custom UPSTASH prefix)

REDIS_URL

multi-user (option B)

Redis URL (Vercel Marketplace Redis injects this; rediss:// TLS ok). UPSTASH_REDIS_URL also accepted. Used only when REST env vars are unset

FANFOU_MCP_PUBLIC_URL

multi-user

Public origin used to build the OAuth callback, e.g. https://<your-project>.vercel.app. Falls back to the request's Host header

FANFOU_MCP_KV

no

memory = in-process KV for local dev (single process, lost on restart)

FANFOU_MCP_READONLY

no

1 = don't register write tools. Default 0 for stdio, 1 for HTTP

FANFOU_MCP_ALLOW_NOAUTH

no

1 = allow HTTP requests without bearer when MCP_AUTH_TOKEN is unset. Local dev only — never in production

Optional convenience for run.sh / dev_http.sh (environment variables always win):

  • ~/.fanfou_app — an INI-like file with sections headed # <app name> containing FANFOU_API_KEY=... / FANFOU_API_SECRET=...; select the section with FANFOU_APP_SECTION (default default).

  • FANFOU_TOKEN_FILE (default ~/.fanfou_token) — a file containing FANFOU_OAUTH_TOKEN=... / FANFOU_OAUTH_TOKEN_SECRET=... lines.

Never commit these files or a .env (both are git-ignored).


Running locally (stdio)

Requires uv.

git clone https://github.com/Anthonyeef/fanfou-mcp.git
cd fanfou-mcp
uv sync

export FANFOU_API_KEY=<consumer_key>
export FANFOU_API_SECRET=<consumer_secret>
export FANFOU_OAUTH_TOKEN=<access_token>
export FANFOU_OAUTH_TOKEN_SECRET=<access_token_secret>

uv run python test_client.py --all     # smoke test

MCP client configuration

Claude Desktop (claude_desktop_config.json), Cursor (~/.cursor/mcp.json) and most other clients accept:

{
  "mcpServers": {
    "fanfou": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/fanfou-mcp", "python", "server.py"],
      "env": {
        "FANFOU_API_KEY": "<consumer_key>",
        "FANFOU_API_SECRET": "<consumer_secret>",
        "FANFOU_OAUTH_TOKEN": "<access_token>",
        "FANFOU_OAUTH_TOKEN_SECRET": "<access_token_secret>",
        "FANFOU_MCP_READONLY": "1"
      }
    }
  }
}

Drop FANFOU_MCP_READONLY (or set it to 0) if you want the write tools. Alternatively point command at /absolute/path/to/fanfou-mcp/run.sh and let it load credentials from local files.

Local HTTP server

export MCP_AUTH_TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
./dev_http.sh                                   # serves http://127.0.0.1:8765/mcp
uv run python test_http_client.py               # in another shell, same env

Remote deployment (Vercel)

pyproject.toml declares the ASGI entry point for Vercel's Python runtime:

[tool.vercel]
entrypoint = "http_app:app"
  1. Create a project in your Vercel account/team. Framework preset: Python (not "Other"/null — with no preset Vercel expects functions under /api). Root directory: this repo.

  2. Add environment variables (Production, mark as Sensitive): FANFOU_API_KEY, FANFOU_API_SECRET, FANFOU_OAUTH_TOKEN, FANFOU_OAUTH_TOKEN_SECRET, MCP_AUTH_TOKEN (a fresh random string), and optionally FANFOU_MCP_READONLY=1 (already the HTTP default). Do not set FANFOU_MCP_ALLOW_NOAUTH.

  3. Deploy: vercel deploy --prod from the repo, or connect the Git repository. Preview deployments sit behind Vercel Deployment Protection, so test against the production domain.

  4. Verify:

    curl https://<your-project>.vercel.app/healthz
    MCP_AUTH_TOKEN=<token> uv run python test_http_client.py https://<your-project>.vercel.app/mcp
  5. Connect a client that supports remote MCP servers:

    {
      "mcpServers": {
        "fanfou": {
          "url": "https://<your-project>.vercel.app/mcp",
          "headers": { "Authorization": "Bearer <MCP_AUTH_TOKEN>" }
        }
      }
    }

Endpoints: POST /mcp (MCP Streamable HTTP, stateless, JSON), GET /healthz (returns {"ok": true, "readonly": ...}). vercel.json requests the hkg1 region; Vercel may still run the function elsewhere — the Fanfou API has been reachable from US regions as well.

Security: with the single-user model, anyone holding MCP_AUTH_TOKEN acts as your Fanfou account. Treat it like a password and rotate it if leaked. Keep the remote server read-only unless you need writes.


Hosting a multi-user instance / 托管多用户实例

One deployment can serve many people: everyone connects to the same /mcp endpoint, but each person authorizes their own Fanfou account in the browser and gets a personal bearer token, so tools read their own timeline. 同一个实例服务多人,每人授权自己的饭否账号、拿到自己的个人令牌。

How it works:

  1. GET /authorize — the server requests a Fanfou request token (with oauth_callback=<public url>/callback), stores its secret in KV for 10 minutes, and redirects the browser to Fanfou's authorization page.

  2. The user logs in to Fanfou and approves the app.

  3. GET /callback — Fanfou redirects back; the server consumes the request-token secret (one-shot), exchanges it for the user's access token, calls verify_credentials, generates a random personal token (ffm_…, 256 bits) and shows it once, together with a ready-to-paste MCP client config.

  4. POST /mcp with Authorization: Bearer ffm_… — the server looks the token up in KV and runs the tools with that user's Fanfou token. Unknown or revoked tokens get 401.

  5. POST /revoke with the same bearer deletes the personal token.

Storage (Vercel KV / Upstash Redis, REST API):

Key

Value

TTL

fanfou-mcp:rt:<request_token>

request token secret

10 min, deleted on use

fanfou-mcp:user:<sha256(personal_token)>

{oauth_token, oauth_token_secret, fanfou_user_id, created_at}

none

Personal tokens are never stored in clear, only their SHA-256. The KV does hold users' Fanfou access tokens, so treat the KV credentials as secrets.

Setup on Vercel: attach either a KV / Upstash store (injects KV_REST_API_URL + KV_REST_API_TOKEN) or the official Redis Marketplace integration (injects REDIS_URL). Either one enables multi-user mode; REST is preferred when both are set. Then set FANFOU_MCP_PUBLIC_URL, keep FANFOU_API_KEY / FANFOU_API_SECRET (an approved app), and redeploy. MCP_AUTH_TOKEN + FANFOU_OAUTH_TOKEN[_SECRET] are optional and keep working as an admin/owner login. See DEPLOY.md.

Things to consider before opening an instance to others:

  • All users share one consumer key: Fanfou rate limits apply to that app, and abuse by one user can affect everyone. Make sure your use of the key complies with Fanfou's terms.

  • Keep the instance read-only (FANFOU_MCP_READONLY=1, the HTTP default).

  • Anyone holding a personal token can read that user's Fanfou (including protected statuses they can see).

Local test without a real KV:

python dev/mock_upstash.py 8079 &                       # fake KV REST API
export KV_REST_API_URL=http://127.0.0.1:8079 KV_REST_API_TOKEN=dev
export FANFOU_API_KEY=... FANFOU_API_SECRET=... FANFOU_OAUTH_TOKEN=... FANFOU_OAUTH_TOKEN_SECRET=...
export MCP_AUTH_TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
./dev_http.sh &
uv run python test_multiuser.py                         # end-to-end checks
# or by hand: open http://127.0.0.1:8765/authorize in a browser, approve on Fanfou,
# then: MCP_AUTH_TOKEN=<the ffm_ token shown> uv run python test_http_client.py

Connecting as a user / 作为用户如何授权接入

If someone runs a hosted instance for you (e.g. https://<instance>):

  1. Open https://<instance>/authorize in your browser, log in to Fanfou and click approve. 打开授权链接,登录饭否并同意授权。

  2. You land on a page showing your personal token (ffm_…) and a config snippet. Copy it now — it is shown only once. 令牌只显示一次,请立即保存。

  3. Add the server to your MCP client:

    {
      "mcpServers": {
        "fanfou": {
          "url": "https://<instance>/mcp",
          "headers": { "Authorization": "Bearer <your personal token>" }
        }
      }
    }
  4. To disconnect: curl -X POST https://<instance>/revoke -H "Authorization: Bearer <your personal token>", and/or revoke the app's authorization in your Fanfou account settings. Lost the token? Just authorize again to get a new one.

The hosted instance is read-only: it can read your timeline, mentions, favorites and search, but cannot post, delete, favorite or follow on your behalf.


OAuth authorization (oob PIN)

Fanfou uses OAuth 1.0a. For a personal setup the simplest way to get an access token is the out-of-band (PIN) flow. 用 oob PIN 方式拿 access token:

  1. Request token — signed (consumer key/secret only, empty token secret) request with oauth_callback=oob:

    GET https://fanfou.com/oauth/request_token

    Response: oauth_token=<request_token>&oauth_token_secret=<request_token_secret>

  2. Authorize — open in a browser while logged in to Fanfou, and approve:

    https://fanfou.com/oauth/authorize?oauth_token=<request_token>&oauth_callback=oob

    Fanfou shows a PIN (verifier).

  3. Access token — signed with the request token/secret and oauth_verifier=<PIN>:

    GET https://fanfou.com/oauth/access_token

    Response: oauth_token=<access_token>&oauth_token_secret=<access_token_secret> → use them as FANFOU_OAUTH_TOKEN / FANFOU_OAUTH_TOKEN_SECRET.

The same quirks apply: sign the http://fanfou.com/oauth/... URL while sending the request over https, and don't use a curl/* User-Agent. A minimal standalone helper:

# oauth_pin.py — usage: python oauth_pin.py   (needs FANFOU_API_KEY / FANFOU_API_SECRET in env)
import base64, hashlib, hmac, os, secrets, time, urllib.parse as up, urllib.request as ur

CK, CS = os.environ["FANFOU_API_KEY"], os.environ["FANFOU_API_SECRET"]
q = lambda s: up.quote(str(s), safe="~")

def call(path, token="", token_secret="", **extra):
    p = {"oauth_consumer_key": CK, "oauth_nonce": secrets.token_hex(8), "oauth_signature_method": "HMAC-SHA1",
         "oauth_timestamp": str(int(time.time())), "oauth_version": "1.0", **extra}
    if token:
        p["oauth_token"] = token
    norm = "&".join(f"{q(k)}={q(v)}" for k, v in sorted(p.items()))
    base = "&".join(["GET", q(f"http://fanfou.com{path}"), q(norm)])          # sign http://
    key = f"{q(CS)}&{q(token_secret)}".encode()
    p["oauth_signature"] = base64.b64encode(hmac.new(key, base.encode(), hashlib.sha1).digest()).decode()
    hdr = {"Authorization": "OAuth " + ", ".join(f'{k}="{q(v)}"' for k, v in p.items()),
           "User-Agent": "fanfou-mcp-oauth/0.1"}
    with ur.urlopen(ur.Request(f"https://fanfou.com{path}", headers=hdr), timeout=20) as r:  # send https://
        return dict(up.parse_qsl(r.read().decode()))

req = call("/oauth/request_token", oauth_callback="oob")
print("Open and approve:\n  https://fanfou.com/oauth/authorize?oauth_token=%s&oauth_callback=oob" % req["oauth_token"])
pin = input("PIN: ").strip()
acc = call("/oauth/access_token", req["oauth_token"], req["oauth_token_secret"], oauth_verifier=pin)
print("FANFOU_OAUTH_TOKEN=%s\nFANFOU_OAUTH_TOKEN_SECRET=%s" % (acc["oauth_token"], acc["oauth_token_secret"]))

Store the printed values somewhere private (a password manager, a chmod 600 file, or your host's secret store). You can revoke the authorization from your Fanfou account settings at any time.


Roadmap

  • KV/Redis-backed per-user tokens + web OAuth callback flow, enabling multi-user hosting

  • MCP OAuth (authorization-server metadata) so clients can connect without pasting a token

  • Photo upload (/photos/upload.json), direct messages, follower/friend lists

Contributions are welcome — please open an issue or PR.

Acknowledgements

License

MIT © Anthonyeef

Available Tools

15 tools
delete_statusA

Delete one of the user's own statuses. WRITE ACTION: only call after the user has explicitly confirmed this exact action and content in chat. Requires confirm=true.

Inputs: status_id (required), confirm.

favoriteA

Favorite (action='create') or unfavorite (action='destroy') a status. WRITE ACTION: only call after the user has explicitly confirmed this exact action and content in chat. Requires confirm=true.

Inputs: status_id (required), action, confirm.

favoritesC

Read a user's favorited statuses (empty = the authenticated user).

Inputs: page, count, user_id. Returns: result

friendshipA

Follow (action='create') or unfollow (action='destroy') a user. WRITE ACTION: only call after the user has explicitly confirmed this exact action and content in chat. Requires confirm=true.

Inputs: user_id (required), action, confirm.

get_contextC

Get the conversation context (reply chain) of a status.

Inputs: status_id (required). Returns: result

get_statusC

Get one status by id.

Inputs: status_id (required).

get_userA

Get a user's profile by id (empty = the authenticated user).

Inputs: user_id.

home_timelineC

Read the authenticated user's home timeline (statuses from people they follow). count 1-60.

Inputs: page, count, max_id, since_id. Returns: result

mentionsB

Read statuses that mention the authenticated user. count 1-60.

Inputs: page, count, max_id, since_id. Returns: result

post_statusA

Post a new status (发饭). Optional in_reply_to_status_id / repost_status_id. WRITE ACTION: only call after the user has explicitly confirmed this exact action and content in chat. Requires confirm=true.

Inputs: status (required), confirm, repost_status_id, in_reply_to_status_id.

public_timelineC

Read the latest public statuses on Fanfou. count 1-60.

Inputs: count. Returns: result

search_public_timelineC

Search public statuses by keyword (supports 'a b' AND and 'a|b' OR).

Inputs: q (required), count, max_id. Returns: result

search_user_timelineB

Search within one user's statuses (user_id empty = the authenticated user).

Inputs: q (required), count, max_id, user_id. Returns: result

user_timelineB

Read a user's statuses. user_id empty = the authenticated user. count 1-60.

Inputs: page, count, max_id, user_id, +1 more. Returns: result

verify_credentialsA

Return the authenticated Fanfou account (read-only).

Inputs: no named parameters.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    B3.3/5.0

    Scored across 15 tools

    Disambiguation4/5

    Most tools have clearly distinct resource+scope (home_timeline vs user_timeline vs public_timeline vs mentions are separated by audience, not confused). The one real overlap is verify_credentials versus get_user with an empty id, which both return the authenticated user, and the two search_* variants could be momentarily confused. Otherwise boundaries are clean.

    Naming Consistency3/5

    The set mixes verb_noun (get_user, get_status, post_status, delete_status) with bare-noun reads (home_timeline, user_timeline, mentions, favorites) and bare-noun writes (favorite, friendship). The convention is readable and mostly predictable, but the inconsistent use of get_/search_ prefixes across reads keeps it from being fully uniform.

    Tool Count4/5

    15 tools is at the upper edge of the well-scoped band but each one maps to a distinct Fanfou operation (timelines, search, status lifecycle, favorites, follow, context). Nothing feels redundant enough to cut. Slightly heavy but justified by the surface.

    Completeness4/5

    Core lifecycle is covered: read/search timelines, get status and reply context, post/delete, favorite/unfavorite, follow/unfollow. Gaps exist (no follower/following lists, no direct messages, no user search), but these are minor for the primary microblogging workflows an agent would run.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      D
      maintenance
      MCP server for interacting with the X platform (Twitter) via MCP clients like Claude, Cursor AI, and Windsurf AI.
      20
      18 npm
      6
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Twitter/X MCP server for the API v2, enabling users to manage profiles, posts, likes, retweets, and follows through natural language using a stateless Streamable HTTP transport with OAuth user access token authentication.
      14 npm
      ISC
    • A
      license
      A
      quality
      B
      maintenance
      Enables agents and scripts to drive a social publishing account through remote or local MCP tools: drafting, scheduling, approving and publishing posts, running durable background jobs with file attachments, and reading or editing Brand Memory, assets, and standing agents. Hosts can connect over OAuth with no API key, or run the stdio variant with a key for local disk tools.
      65
      1
      MIT
    • -
      license
      Not graded
      quality
      Not graded
      maintenance
      A local read-only MCP server that lets MCP clients such as the Claude app fetch X/Twitter posts and search, Reddit posts, threads and search, and YouTube transcripts and search. It reads through your own saved logins using a fixed set of read-only commands, and cannot post, vote, follow or message.
      -