Threads
FreeNot checkedMCP server for Meta's Threads API that enables publishing posts and reply chains, reading timelines and insights, and managing long-lived credentials with proac
About
MCP server for Meta's Threads API that enables publishing posts and reply chains, reading timelines and insights, and managing long-lived credentials with proactive refresh and safety checks.
README
An MCP server for Meta's Threads API. Thirteen tools over Streamable HTTP: publish posts, reply chains, images, and replies to anyone; read your timeline, replies, and insights; and keep the credential alive without ever touching it.
Built around a problem most Threads integrations defer: the credential expires permanently in 60 days, and the failure is silent.
It runs as a long-lived service on your own infrastructure rather than as a process your desktop client spawns, and the limits that matter are enforced in code rather than described in a prompt.
The problem this server solves
Threads does not issue a separate, immutable refresh token. The long-lived access token is the refreshable credential, it lives 60 days, and every refresh replaces it. Miss the window and it is permanently dead: no API call recovers it, only a human re-running the browser OAuth flow.
The obvious design puts the token in .env and refreshes it in memory. That works perfectly for 60 days and then dies on the next container restart, when the process re-reads a token that expired weeks ago. The failure lands two months after the last code change, with nothing recent to blame.
So instead:
| Mitigation | Where |
|---|---|
Token persists to a named Docker volume, written atomically (temp file, then os.replace), mode 600 |
clients/tokenstore.py |
.env holds a one-time seed, consumed only when the volume is empty |
ThreadsClient.load_state |
| Refresh fires proactively at day 45, never at the last moment | TokenState.needs_refresh |
Refresh guarded by an asyncio.Lock with a double-check inside it, so concurrent tool calls cannot race two tokens into the store |
ThreadsClient.ensure_token |
| Days-to-expiry logged at every startup | ThreadsClient.log_startup_status |
token_status tool exposes expiry for external alerting |
tools/token.py |
There is an explicit regression test for the trap: refresh, discard the client, rebuild it from the same stale .env seed, and assert it loads the new token from the volume. Reintroducing the naive pattern turns that test red.
Why it runs centrally
Most MCP servers are stdio processes a desktop client spawns and kills with the session. That is a fine model for a stateless API wrapper and the wrong one here.
A refresh deadline needs a process that exists. A server that only runs while a desktop app is open cannot promise to refresh a credential inside 60 days. Close the laptop for two months and the token is gone. Refreshing "on next use" is not a guarantee, it is a hope about your own habits.
One credential, many clients. Running centrally means the token exists in exactly one place, on one box, at mode 600 on a volume. Your laptop never holds it. A workflow engine, a scheduler, or a second machine reach the same server over HTTP instead of each keeping a copy of a live posting credential.
Webhooks need an address. Real-time mentions and replies require a stable endpoint. A stdio server has to tunnel out to fake one.
The tradeoff is honest: this needs somewhere to run. If you want something that works on a laptop with no infrastructure, several stdio Threads MCP servers exist and one of them is a better fit. This is built for a box that is already on.
The approval layer
Publishing is irreversible in the way that matters: a deleted post was still seen. So the constraints live in code, at the tool boundary, where a model cannot talk its way past them.
- Two-step publishing.
create_postbuilds an inert container with zero timeline effect;publish_postcommits it. A misfiring agent produces an unused container, not a live post. - Byte-accurate limits. The 500 limit is counted in UTF-8 bytes because an emoji costs 4, and a model asked to count characters will eventually be wrong in public.
- Quota from the API, not a local tally. A local counter cannot see the post you made from your phone. Measured on a real account: Meta reported 1 post used while the local log said 0.
- Missing scopes refuse before the call, with the remedy, instead of forwarding a raw 403 that reads like a bug in the server.
The human gate lives one layer up, in the agent skill that drives these tools: draft, show the operator the exact bytes and segment counts, stop, and publish only on explicit approval. Approval is never inferred from silence or from a vague "sounds good." Replying to a stranger is treated as higher risk than posting, because it puts your name in someone else's mentions: the skill must read the target post back verbatim before it will compose a reply.
The server enforces what is enforceable. The skill enforces what requires judgment. Neither trusts the model to remember a rule.
Tools
| Tool | Idempotent | Notes |
|---|---|---|
token_status |
yes | Credential health. No network call. Never returns the token. |
whoami |
yes | Profile + connectivity check. |
list_posts |
yes | Recent posts with permalinks. |
get_replies |
yes | Top-level replies, or the whole conversation. |
get_publishing_limit |
yes | Meta's authoritative post/reply/delete/location quotas. |
get_post_insights |
yes | Views, likes, replies, reposts, quotes, shares for one post. |
get_account_insights |
yes | Profile-level metrics, optional date range and demographic breakdown. |
preview_chain |
yes | Shows how long text would split. No network call, nothing published. |
create_post |
no | Creates an inert container: text, image, or reply to any post. No timeline effect. |
get_container_status |
yes | Whether an image container finished processing. Read-only. |
publish_post |
no | Commits a container. Goes live. |
post_chain |
no | Splits long text and publishes it as a reply chain. Goes live. |
delete_post |
no | Destructive, and currently blocked: the token has no threads_delete scope. |
13 tools. The surface is kept deliberately small: image support, replies to other
people's posts, and quote posts are parameters on create_post, not separate
tools, because they are the same two-step flow with a different payload.
Every tool returns a JSON string in one of two shapes:
{"data": ...} // success
{"error": "...", "code": "...", "details": {...}} // failure
code comes from a fixed enum: UPSTREAM_DOWN, AUTH_FAILED, INVALID_INPUT, NOT_FOUND, RATE_LIMITED, INTERNAL. Exceptions never escape a tool.
Two-step publishing, on purpose
create_post then publish_post. The Threads API offers auto_publish_text to collapse both into one call; this server deliberately does not use it. The separation is a safety boundary: an agent that misfires produces an inert container instead of a live post. One extra HTTP call is cheap insurance when an LLM holds publish rights.
Chain splitting
post_chain splits on paragraph boundaries first, then sentences, then words. It only breaks inside a word when a single word exceeds the limit on its own (a very long URL), and never inside a multi-byte character.
Length is measured in UTF-8 bytes, because Threads counts emoji as bytes rather than as single characters. len(str) says 400 emoji fit in a 500-character post; Threads says they are 1600 and rejects it.
Each segment after the first replies to the published media ID of the previous segment, not its container ID — using the container ID produces orphaned replies that never attach to the thread.
A mid-chain failure returns the IDs already published, the remaining text, and a resume_reply_to_id, so a partial chain can be resumed or rolled back. It is never silently swallowed.
Limits enforced client-side
- 500 per post, measured in UTF-8 bytes.
- Max 5 unique links per post: every unique URL in
text, pluslink_attachmentwhen it differs from all of them. Checked before the call, so the API never has to answer withTHREADS_API__LINK_LIMIT_EXCEEDED. - Four independent rolling-24h quotas, read from Meta's
threads_publishing_limitendpoint: 250 posts, 1000 replies, 100 deletes, 500 location searches. The API is the source of truth, because a local counter cannot see posts made from the Threads app.publish_log.jsonon the volume is a labelled fallback used only when that call fails, and every response carries asourcefield saying which one answered. - A chain of N segments spends 1 post and N-1 replies, against two separate
budgets.
post_chainpre-flights both and refuses before creating a container. - Image posts: JPEG/PNG, publicly reachable URL (Meta fetches it server-side), and
1000-character alt text. Size, aspect ratio and width are enforced by Meta during
container processing and surface through
get_container_status; this server does not download caller-supplied URLs.
Missing scopes fail honestly
delete_post needs threads_delete, which this token was never granted. Rather
than forwarding a raw 403, the tool refuses before the call and returns the exact
remedy: add the permission on the Meta App Dashboard Use cases page and mint a new
token, because scopes bind at authorization time and no refresh can widen them. Set THREADS_GRANTED_SCOPES empty to disable the pre-flight and let the API
decide.
Setup
1. Create the Threads app
In the Meta App Dashboard, create an app with the Threads use case against the account you want to post from. Capture the app ID and the Threads App secret (App settings > Basic).
Scopes: threads_basic, threads_content_publish, threads_read_replies, threads_manage_replies, threads_manage_insights, threads_delete.
Scopes bind at authorization time, so a missing one means redoing the entire flow. get_replies 403s without threads_read_replies, the two insight tools need threads_manage_insights, and delete_post is gated on threads_delete. Request the full set on the first and only run.
threads_deleteis offered by the use-case dashboard but absent from the authorize doc's scope list, so the authorize call may reject it. If it does,bootstrap.pyprints the exactTHREADS_SCOPESvalue to re-run with. Dropping it meansdelete_postwill 403 at runtime — drop the tool or document it as expected-to-fail rather than shipping one that silently does nothing.
Posting to your own account and to app tester accounts works with standard access. Advanced access and App Review are only required to post on behalf of other users.
Register this redirect URI:
http://127.0.0.1:8766/callback
Then copy it back out of the dashboard verbatim — the dashboard may rewrite what you typed, notably by appending a trailing slash, and it must match exactly at both the authorize and the exchange step. bootstrap.py accepts /callback and /callback/; if the saved value differs in any other way, set THREADS_REDIRECT_URI to the dashboard's exact string.
Unverified: every redirect-URI example in Meta's docs uses HTTPS. Whether the Threads use-case settings accept a plain-http loopback URI has not been confirmed. If the dashboard rejects it, this flow needs an HTTPS tunnel or a hosted callback instead.
2. Get a token
The fast path is not OAuth. The use-case Settings page has a User Token Generator that mints a long-lived token directly for Threads Testers of the app, skipping the callback flow entirely:
- Add the account under Add or Remove Threads Testers.
- Accept the invite in Threads: Settings > Account > Website permissions > Invites.
- Reload Settings; the account now has a generate action.
- Generate, and paste the result into
THREADS_SEED_TOKEN.
Two requirements that fail quietly if missed. The account must be public; generation is blocked for private profiles. And add the permissions before generating: the token carries whatever the app holds at that instant, so generating early yields a token that authenticates fine, passes a profile call, and then fails every publish.
This also sidesteps the redirect-URI problem entirely, which matters because the dashboard rejects plain-http loopback URIs.
Fallback: the OAuth flow
Run once, on a workstation, never in the container:
export THREADS_APP_ID=...
export THREADS_APP_SECRET=...
python bootstrap.py
It opens the browser, catches the redirect, exchanges the code for a short-lived token, then exchanges that for a long-lived (60-day) token and prints it.
Three hosts are involved, which is not a typo — authorize on threads.net, the short-lived code exchange as a POST to graph.threads.net/oauth/access_token, and both the long-lived exchange and the refresh as GETs to graph.threads.com. Override with THREADS_OAUTH_BASE and THREADS_AUTH_BASE if Meta's migration moves them.
3. Configure and run
cp .env.example .env # fill in THREADS_APP_ID, THREADS_APP_SECRET, THREADS_SEED_TOKEN
docker compose up -d
The seed is consumed on first boot and written to the threads-data volume. After that the volume is authoritative and the .env value goes stale — that is expected, not a bug. Deleting the volume destroys the credential permanently and forces a bootstrap.py re-run.
Register with an MCP client:
claude mcp add threads --transport http --scope user --url http://<host>:3726/mcp
Then start a new session. MCP clients enumerate tools at startup, so a
session that was already open when you registered the server will report
✔ Connected and still expose none of its tools. That looks like a broken
server and is not one.
Configuration
| Variable | Default | Purpose |
|---|---|---|
THREADS_APP_ID |
— | Required. |
THREADS_APP_SECRET |
— | Required. Server-side only, never sent to a client. |
THREADS_SEED_TOKEN |
— | One-time seed. Ignored once the volume holds a token. |
THREADS_DATA_DIR |
/data |
Token store location. Must be a durable volume. |
THREADS_GRAPH_BASE |
https://graph.threads.net/v1.0 |
Publishing and read endpoints. |
THREADS_AUTH_BASE |
https://graph.threads.com |
Token endpoints. |
THREADS_GRANTED_SCOPES |
the five granted scopes | Comma list used for the pre-flight scope check. Empty disables it. |
MCP_PORT |
3726 |
Bind port. |
MCP_HOST |
0.0.0.0 |
Bind host. |
MCP_TRANSPORT |
streamable-http |
Transport. |
MCP_AUTH_TOKEN |
unset | Optional bearer token for the HTTP transport. |
MCP_HEALTH_PATH |
/healthz |
Path the container healthcheck probes. Do not point it at /mcp — see Monitoring. |
Monitoring: GET /healthz
Uptime Kuma polls HTTP and cannot call an MCP tool, so credential health is also exposed as a plain HTTP route. It reads the persisted token store only: no network call, no refresh, no rate-limit cost, safe to poll every 60s.
curl -i http://<host>:3726/healthz
| Condition | Code | status |
|---|---|---|
| More than 14 whole days to expiry | 200 |
ok |
| 14 or fewer days to expiry | 503 |
warning |
| Token expired | 503 |
critical |
| Token file present but unreadable or corrupt | 503 |
critical |
| No token at all (unseeded volume, no seed) | 503 |
unseeded |
{
"status": "ok",
"days_remaining": 45,
"token_source": "refresh",
"detail": "token healthy",
"expires_at": "2026-10-11T14:03:22+00:00",
"refresh_count": 3,
"version": "0.2.0"
}
Notes:
- Unseeded is 503 on purpose. A server with no credential is not serving; the monitor should say so rather than showing green.
days_remainingis floored to whole days and the threshold is applied to that floored value, so the body and the status code can never disagree. Alerting can therefore fire up to a day early (14.9 days reads as 14). Proactive refresh runs at day 45 (15 days remaining), so reaching this endpoint's warning state already means refresh has stopped working.- No secret material in the body under any state. Every field is an integer, an ISO timestamp, or a value from a closed vocabulary;
token_sourceis clamped toseed/refresh/nullso nothing read out of the token file is echoed back.tests/test_health.pyasserts the token and every prefix of it are absent in each state, including the corrupt-file path. - Unauthenticated by design. FastMCP wraps only
/mcpin the bearer-auth middleware; custom routes sit outside it. That is what makes the endpoint pollable, and it is safe because the body carries no credential material. - The container healthcheck probes this path too, but reads it differently. Docker restarts a container that fails
HEALTHCHECK, and a restart cannot renew a credential, so a probe that failed on 503 would restart-loop a server whose only problem is an expiring token. The shim therefore treats401and503as alive: "the app answered" is the liveness signal.500is deliberately excluded, so a genuine fault still fails. Uptime Kuma accepts only200-299and is what actually alerts you. - Never point the container healthcheck at
/mcp. A bare request to the MCP mount allocates a transport session that is never reaped, roughly 40 KB each, before method dispatch and before auth. At a 30-second interval that is about 115 MiB/day of permanent growth. Measured: 300 probes against/mcpcost 11 MiB, 300 against/healthzcost 0.
Uptime Kuma monitor: HTTP(s), URL http://<host>:3726/healthz, interval 60s, accepted status codes 200-299 (the default). No keyword match needed — the status code carries the signal.
Both base hosts are configurable because Meta's own documentation is inconsistent: token endpoints are documented on graph.threads.com, publishing and read endpoints on graph.threads.net/v1.0, and Meta has been migrating .net to .com. If one host starts 404ing, switch it in .env rather than patching code.
Secrets
The token endpoints pass the credential as a query parameter, and httpx logs full request URLs at INFO. That is a real leak vector, so clients/redact.py drops the HTTP-client loggers to WARNING and installs a filter that scrubs access_token=, client_secret=, Bearer <token>, and bare Threads tokens from every log record. There is a test that runs a real (mocked) refresh with logging wide open and scans every emitted record, plus a positive control asserting the unredacted string really would have leaked.
token_status never returns the token, and neither does GET /healthz — the latter is unauthenticated, so its body is restricted to integers, ISO timestamps, and a closed status vocabulary. The store file is mode 600. .env is gitignored.
Development
uv venv .venv && uv pip install -r requirements-dev.in --python .venv/bin/python
.venv/bin/python -m pytest -q
.venv/bin/python -m ruff check .
Regenerate the hash-pinned lockfile (must be universal — a macOS-only resolution omits Linux transitives and the image build fails on --require-hashes):
uv pip compile requirements.in -o requirements.lock --generate-hashes --universal --python-version 3.13
Layout
server.py FastMCP wiring only, no tool bodies
bootstrap.py one-time OAuth, run locally
healthcheck.py Docker HEALTHCHECK shim
clients/
threads.py API client, token lifecycle, request plumbing
errors.py exception hierarchy (kept separate to avoid an import cycle)
quota.py threads_publishing_limit parsing + the budget gate
insights.py three-shape insight parser + request validation
media.py image URL and alt-text validation
tokenstore.py atomic token persistence + per-kind fallback log
chain.py chain orchestration and partial-failure semantics
text.py byte-aware length, splitter, link counting
redact.py log redaction
health.py token-expiry snapshot + HTTP status mapping (no network)
tools/
token.py token_status, whoami
health.py GET /healthz route (not an MCP tool)
read.py list_posts, get_replies
quota.py get_publishing_limit
insights.py get_post_insights, get_account_insights
publish.py create_post, get_container_status, publish_post, preview_chain,
post_chain, delete_post
common.py Standard Error Contract helpers
License
MIT.
Installing Threads
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/pete-builds/mcp-threadsFAQ
Is Threads MCP free?
Yes, Threads MCP is free — one-click install via Unyly at no cost.
Does Threads need an API key?
No, Threads runs without API keys or environment variables.
Is Threads hosted or self-hosted?
A hosted option is available: Unyly runs the server in the cloud, no local setup required.
How do I install Threads in Claude Desktop, Claude Code or Cursor?
Open Threads on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
GitHub
PRs, issues, code search, CI status
by GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
by mcpdotdirectAmap Maps Mcp Server
MCP server for using the AMap Maps API
by duxiaohuiSupabase
Database, auth and storage
by SupabaseEverything
Reference / test server with prompts, resources, and tools.
Git
Tools to read, search, and manipulate Git repositories.
Sequential Thinking
Dynamic and reflective problem-solving through thought sequences.
Time
Time and timezone conversion capabilities.
Compare Threads with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All development MCPs
