Skip to main content
Glama
pete-builds

mcp-threads

by pete-builds

mcp-threads

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.

Related MCP server: Threads MCP Server

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_post builds an inert container with zero timeline effect; publish_post commits 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, plus link_attachment when it differs from all of them. Checked before the call, so the API never has to answer with THREADS_API__LINK_LIMIT_EXCEEDED.

  • Four independent rolling-24h quotas, read from Meta's threads_publishing_limit endpoint: 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.json on the volume is a labelled fallback used only when that call fails, and every response carries a source field saying which one answered.

  • A chain of N segments spends 1 post and N-1 replies, against two separate budgets. post_chain pre-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_delete is 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.py prints the exact THREADS_SCOPES value to re-run with. Dropping it means delete_post will 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:

  1. Add the account under Add or Remove Threads Testers.

  2. Accept the invite in Threads: Settings > Account > Website permissions > Invites.

  3. Reload Settings; the account now has a generate action.

  4. 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_remaining is 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_source is clamped to seed / refresh / null so nothing read out of the token file is echoed back. tests/test_health.py asserts the token and every prefix of it are absent in each state, including the corrupt-file path.

  • Unauthenticated by design. FastMCP wraps only /mcp in 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 treats 401 and 503 as alive: "the app answered" is the liveness signal. 500 is deliberately excluded, so a genuine fault still fails. Uptime Kuma accepts only 200-299 and 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 /mcp cost 11 MiB, 300 against /healthz cost 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.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Unofficial MCP server for Meta's Threads API. Enables LLMs like Claude to publish posts, manage replies, and track insights through the Model Context Protocol.
    15
    MIT
  • F
    license
    A
    quality
    C
    maintenance
    MCP server for the Threads API, enabling profile management, content reading, publishing, replies, and discovery through 26 tools.
    26

View all related MCP servers

Related MCP Connectors

  • Managed LinkedIn MCP server for AI agents: search, connect, message and enrich on accounts you own.

  • FastMCP server for posting formatted content to X (Twitter) — Tollbooth-monetized, DPYC-native

  • Connect any AI agent to 11+ social platforms: schedule, publish & track posts via hosted MCP.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pete-builds/mcp-threads'

If you have feedback or need assistance with the MCP directory API, please join our Discord server