Search Bluesky posts and feeds; read profiles, threads, and trends. STDIO or Streamable HTTP.
One address for any MCP client. No sign-in needed on this server.
8 read
Grouped by tool name. At most: only reads or looks up information.
Odel charges nothing per call. The publisher may charge for its own service.
Search Bluesky posts and feeds; read profiles, threads, and trends. STDIO or Streamable HTTP.
Public Hosted Server: https://bluesky.caseyjhand.com/mcp
Read Bluesky without an account: profiles, feed catalog searches, trending topics and the feeds behind them, custom and author feeds, threads, and quote posts. Paste a bsky.app link or @handle anywhere one is asked for, and add an optional app password to unlock full-text post search.
| Tool | Description |
|---|---|
bsky_search_posts | Full-text search across public Bluesky posts, with author, mention, language, tag, domain, URL, date, and sort filters — offered only when an app password is configured |
bsky_get_profile | Fetch a Bluesky actor's public profile by handle or DID — the handle↔DID resolver |
bsky_get_feed | Read a feed generator's posts — a trend's feed, Discover, or any custom feed — by AT-URI or bsky.app URL |
bsky_get_author_feed | A user's recent posts ordered newest-first, filterable by post type |
bsky_get_post_thread | Fetch the conversation for a post by AT-URI or bsky.app URL — parent chain upward and reply tree downward, with what Bluesky counted but did not return |
bsky_get_post_quotes | Read the quote posts behind a post's quoteCount, newest first or by like count — where much of the reaction to a post lives |
bsky_search_actors | Find Bluesky accounts by name or handle fragment |
bsky_search_feeds | Search public feed catalog names and descriptions, then read a returned feedUri with bsky_get_feed |
bsky_get_follows | Paginated social graph edges — who a user follows or who follows them |
bsky_get_trending | Real-time trending topics on Bluesky with a story summary, post count, category, status, the accounts driving each topic, and the feed that collects its posts |
| Resource | Description |
|---|---|
bsky://profile/{actor} | A Bluesky actor's public profile, addressable by handle or DID |
All resource data is also reachable via tools. Use bsky_get_profile for programmatic access or bsky://profile/{actor} to inject profile context directly.
bsky_search_posts toolBLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD are set — without them it is absent from tools/list (see Post search)search_auth_failed (the login or its renewal was rejected), search_login_limited (the account's daily login limit is used up — the error names when searching resumes), or search_refused (Bluesky refused the search)@handle or a bsky.app profile URL — mentions matches rich-text mentions only), two-letter language code, hashtag (with or without #), linked domain (bare hostname, leading www. dropped) or exact url (in text links or link cards), since/until, and top/latest sort; up to 100 results per call via opaque cursor paginationsince/until compare against each post's sort time — the earlier of createdAt and indexedAt — to the whole second, inclusive; a date alone is 00:00:00 UTC, so until: "2026-01-01" covers all of 2025-12-31"fil", "eng") is rejected, since Bluesky search ignores one and returns unfiltered results; the code is sent lowercased, and later subtags are accepted but ignored upstream (en-US filters as en)upstream_rejected_filter error reason instead of a bare status code; a cursor it can't decode fails as invalid_cursor, after one requesthitsTotal is Bluesky's estimate, never an exact count: below 10,000 it is an upper bound on what paging returns (Bluesky counts before dropping posts it won't return), and exactly 10,000 — the cap — means "at least that many"; truncated/shown/cap are keyed on the returned cursor, which Bluesky omits once nothing more matcheslimit that fits and returned whole — budgetCapped: true, with hitsTotal and the cursor from that one responsetype-discriminated union (images, external, record, video, unknown); a quoted post carries its own attachments up to 3 nesting levels, with omittedEmbeds counting what went deeper and recordKind naming a quote that's deleted, blocked, detached, or not a postPost tools preserve optional rich-text facets separately from the original text: UTF-8 byte ranges (inclusive byteStart, exclusive byteEnd) and complete link URIs, mention DIDs, and tags. Unrecognized feature variants and additional upstream fields remain visible as literal metadata on both channels. Quoted posts retain their own facets within the existing embed-depth bound. Both response channels include this metadata in the response budget; bsky_get_post_quotes omits the queried target's facets together with its restated text.
bsky_get_feed toolat://<handle-or-did>/app.bsky.feed.generator/<rkey>) or its bsky.app page (https://bsky.app/profile/<handle-or-did>/feed/<rkey>, with any trailing /, ?…, or #… ignored); a trend's feedUri works as-is, and Discover is at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hotresolveHandle), a DID owner none; any other collection, such as a post AT-URI, is rejected before the upstream callpinned: true; a repost carries repostedBy/repostedAtlimit with more behind itlimit that fits and returned whole, marked budgetCapped: true; a ranked feed reranks on every request, so its posts and cursor still come from one responsefeed_not_found (no such feed or handle), feed_unavailable (the feed's generator did not answer — not retried, so a down feed fails fast), feed_requires_login (a personalized feed)bsky_get_profile tool@handle or the account's bsky.app page (https://bsky.app/profile/<handle-or-did>); returns displayName, handle, DID, bio, pronouns, website, follower/following/post counts, avatar, moderation labels, and pinned post AT-URIwebsite and pronouns are absent when the account set neither; optional links carries ordered profile buttons separately from website, with each record's URI/CID, complete destination URL, optional title/icon, and moderation labels (val, src?, cts?). An omitted array stays omitted and an explicit empty array stays empty; the profile resource returns the same fieldsverification carries Bluesky's full verification state — verifiedStatus and trustedVerifierStatus (valid, invalid, or none, passed through verbatim) and every verification a trusted verifier issued, with its issuer, validity, date, and record AT-URI; absent when Bluesky sent nonecontent[], since it's account-authored text that can carry its own markdown structureactor_not_found when the handle doesn't resolve — resolve the handle with bsky_search_actors firstbsky_get_author_feed tool@handle or the account's bsky.app pagefilter: posts_with_replies, posts_no_replies (default, excludes replies), and posts_and_author_threads include reposts; posts_with_media (the actor's own posts with images or video, no link cards) and posts_with_video return noneinclude_pins: true adds the profile's pinned post, marked pinned: true, first on the first page — in addition to limit, and whether or not it matches filterrepostedBy and repostedAt; author always names who actually wrote the postlimit counts them too, so a heavily-reposting account can return far fewer of its own posts than the limit suggests; originalPosts/reposts report the actual split whenever a repost is presentlimit that fits (the pin not counted), marked budgetCapped: true, and its cursor resumes at the first post left outactor_not_found when the handle or DID doesn't resolve; invalid_cursor, after one request, when Bluesky can't decode the cursor passed (it answers those with HTTP 500, which is otherwise retried)bsky_get_post_thread toolhttps://bsky.app/profile/<handle-or-did>/post/<rkey>, trailing /, ?…, or #… ignored)bsky_get_post_quotesdepth (reply levels, default 6, max 10 — Bluesky's own ceiling, however deep the request) and parent_height (parent chain height, default 80, max 100)replyCount carries truncated: true, unreturnedReplies (an upper bound, not an exact shortfall), and truncationReason ("depth" — fetch that node's AT-URI to continue, or "unavailable" — no request closes the gap)parent_height short of the conversation root, the topmost node carries parentChainTruncated: true — recoverable by fetching that node's AT-URI as its own threadbudgetCapped and budgetOmitted (posts left out) in enrichment, budgetOmittedReplyUris on the target, budgetOmittedReplies on a kept reply, and budgetOmittedParents on the topmost parent kept — fetching those AT-URIs reads everything left outnotFound: true and blocked posts blocked: true### ↳2 Name) rather than by indentation, so a deeply nested reply never crosses into a markdown code blockinvalid_at_uri and post_not_found errors when the AT-URI doesn't resolve; AT-URIs come from the uri field of any returned postapp.bsky.feed.generator) is rejected before any request as uri_is_feed, pointing to bsky_get_feedbsky_get_post_quotes toolresolveHandle), since getQuotes answers a handle authority with an empty listrecordKind when it's unreadable, and any media the quoting post attached — the queried post's text and attachments aren't repeated on every itemsort: latest (default) is newest first, and sort: top is descending like count as ordered by Bluesky. The applied sort is returned on both surfaces; pass cursors back with the same post and sort. Truncation is disclosed on the cursor, since pages often come back short of limit with more behind themlimit that fits, marked budgetCapped: true, and its cursor resumes at the first quote left outquoteCount is an upper bound on what this returns — Bluesky's counter keeps quotes that have left the indexpost_not_found when the post doesn't exist or its handle doesn't resolve (an empty first page is checked with one getPosts); invalid_cursor after one request for a cursor Bluesky can't decodebsky_search_actors toolwebsite, which only bsky_get_profile returnsverification.verifiedStatus, verification.trustedVerifierStatus) when Bluesky sent them, which tells a verified account from a look-alike handle; who issued the verification is on bsky_get_profilecontent[], since it's account-authored textlimit and still continue, and the last page carries no cursorinvalid_cursor, after one request, when Bluesky can't decode the cursor passed (it answers those with HTTP 400)bsky_get_profile or bsky_get_author_feed when you have a name but not a confirmed handlebsky_search_feeds toolfeedUri, displayName, optional description, and creator DID/handle with optional display name, in provider orderfeedUri directly to bsky_get_feed to read its postsquery is required; limit defaults to 25 and caps at 100 through the shared clamping policy. Pass the opaque cursor with the same query; cursor presence alone signals continuation, even on an empty pageapp.bsky.unspecced.getPopularFeedGenerators, which may change or disappear; catalog_unavailable recovers through bsky_get_trending. This is discovery, not comprehensive enumerationbsky_get_follows tooldirection: followers (who follows the actor) or following (who the actor follows)sort: latest (Bluesky's default — most recent follows first) or top (Bluesky's own ranking of prominent accounts, not a follower-count order); pass a cursor back with the same sort@handle or the account's bsky.app pagewebsite on this view, for the list or the subject — resolve with bsky_get_profile when they matteractor_not_found when the handle or DID doesn't resolvebsky_get_trending tooldescription), post count, category, status (e.g. hot, cooling, stale), start time, and up to 5 representative accounts driving each topicfeedUri is that feed's AT-URI, parsed from the trend's link, and bsky_get_feed reads its posts; topic is the feed's record key, not a search termlimit (default 10, max 25, Bluesky's own maximum); truncated means more topics are trending than limit, so it never appears at 25app.bsky.unspecced.getTrends, an unstable endpoint Bluesky may change without noticebsky://profile/{actor} resourcebsky_get_profile in injectable-context form — displayName, handle, DID, bio, pronouns, website, follower/following/post counts, avatar, moderation labels, pinned post AT-URI, full verification state{actor}, with or without a leading @ (bsky://profile/@bsky.app)actor_not_found when the handle doesn't resolveBuilt on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
Bluesky-specific:
api.bsky.app without credentials; bsky_search_posts runs through an optional app-password session and is left out without one@handle and a bsky.app profile URL wherever an account is asked for, a bsky.app post or feed URL wherever that post or feed is, each rewritten to the handle, DID, or AT-URI it names before the requestBlueskyService wrapping the AT Protocol AppView, with a 15s per-request timeout, retry on transient upstream failures (up to 3 retries, 500ms base delay — never for a login, or for a failure already mapped to a tool error), and a versioned User-Agenttype-discriminated unionAgent-friendly output:
bsky_search_feeds or bsky_get_trending → bsky_get_feed → bsky_get_post_thread without extra stepsverifiedStatus / trustedVerifierStatus on the author's DID line; bsky_get_profile adds who issued each verificationtype: "images" | "external" | "record" | "video" | "unknown" lets callers branch on data instead of parsing $type strings; an unmapped lexicon type arrives as unknown with its raw $type rather than vanishing>-prefixed blockquotes in content[], so a post's own heading or code fence never merges with the server's structure; values that render inline (display names, pronouns, topic names) have line breaks folded to spaces for the same reason. Inside both, the Markdown and raw HTML a CommonMark renderer would act on — emphasis, links, images, code, lists, headings, tags, entities — is escaped, so user text displays as written; URLs stay copyable and structuredContent carries every string unmodified. Each quote ends before the next line the server writes, so a rendering client never folds a post's counts or labels into ittruncated, unreturnedReplies, parentChainTruncated, hitsTotal at its 10,000 cap) are reported as bounds rather than measurementslimit that fits so their cursor still continues where they end, threads mark the posts they left out by AT-URI, and budgetCapped says when either happened; a response that fits is unchangedConnect directly — no installation required:
{
"mcpServers": {
"bluesky-mcp-server": {
"type": "streamable-http",
"url": "https://bluesky.caseyjhand.com/mcp"
}
}
}
Add the following to your MCP client configuration file. No API key required; add BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD to env to enable post search.
{
"mcpServers": {
"bluesky-mcp-server": {
"type": "stdio",
"command": "bunx",
"args": ["@cyanheads/bluesky-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with npx (no Bun required):
{
"mcpServers": {
"bluesky-mcp-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@cyanheads/bluesky-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with Docker:
{
"mcpServers": {
"bluesky-mcp-server": {
"type": "stdio",
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MCP_TRANSPORT_TYPE=stdio",
"ghcr.io/cyanheads/bluesky-mcp-server:latest"
]
}
}
}
For Streamable HTTP, set the transport and start the server:
MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
# Server listens at http://localhost:3010/mcp
git clone https://github.com/cyanheads/bluesky-mcp-server.git
cd bluesky-mcp-server
bun install
cp .env.example .env
# edit .env to override any framework defaults
This server requires no API keys. Everything below is optional.
| Variable | Description | Default |
|---|---|---|
BLUESKY_IDENTIFIER | Handle, DID, or email of the account post search runs as. Set together with BLUESKY_APP_PASSWORD, or neither. | — |
BLUESKY_APP_PASSWORD | App password for that account. Without the pair, bsky_search_posts is not offered. | — |
BLUESKY_CLAMP_READ_LIMITS | Read-limit policy: clamp oversized requests to the existing caps and disclose requested/applied values; false restores rejection. | true |
MCP_TRANSPORT_TYPE | Transport: stdio or http | stdio |
MCP_SESSION_MODE | HTTP session mode: stateful, stateless, or auto. Unset or empty, the server resolves to stateless from its own createApp({ sessionMode }) declaration; an explicit value still overrides it. | stateless |
MCP_HTTP_PORT | Port for HTTP server | 3010 |
MCP_AUTH_MODE | Auth mode: none, jwt, or oauth | none |
MCP_LOG_LEVEL | Log level (RFC 5424) | info |
LOGS_DIR | Directory for log files (Node.js only) | <project-root>/logs |
STORAGE_PROVIDER_TYPE | Storage backend | in-memory |
OTEL_ENABLED | Enable OpenTelemetry instrumentation | false |
See .env.example for the full list of optional overrides.
Bluesky refuses app.bsky.feed.searchPosts without a signed-in account, so bsky_search_posts is registered only when BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD are both set. Setting one without the other fails startup with a message naming the missing variable.
createSession to about 10 per day per account, so the server logs in on the first search — never at startup — reuses the session, refreshes it when the access token expires, and logs in again only when the refresh is rejected. A rejected login is not retried until the process restarts. When Bluesky answers a login or refresh with 429, every search fails as search_login_limited without a request until the ratelimit-reset time it names (15 minutes when it names none), then logs in again.Build and run:
# One-time build
bun run rebuild
# Run the built server
bun run start:stdio
# or
bun run start:http
Run checks and tests:
bun run devcheck # Lint, format, typecheck, security
bun run test # Vitest test suite
bun run lint:mcp # Validate MCP definitions against spec
docker build -t bluesky-mcp-server .
docker run --rm -p 3010:3010 bluesky-mcp-server
The Dockerfile defaults to HTTP transport, stateless session mode, and logs to /var/log/bluesky-mcp-server. OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.
| Directory | Purpose |
|---|---|
src/index.ts | createApp() entry point — reads config, registers tools and resource, and inits the service. |
src/config | Read-limit policy and the optional app-password pair that enables post search. |
src/services/bluesky | AT Protocol HTTP client — public AppView reads, the app-password search session, retry, timeout, and User-Agent. |
src/mcp-server/tools | Tool definitions (*.tool.ts) — ten read-only Bluesky tools. |
src/mcp-server/resources | Resource definitions (*.resource.ts) — bsky://profile/{actor}. |
tests/ | Unit and integration tests mirroring src/. |
See CLAUDE.md for development guidelines and architectural rules. The short version:
try/catch in tool logicctx.log for request-scoped logging, ctx.state for tenant-scoped storagesrc/mcp-server/server-surface.ts and resources in src/index.tsIssues are welcome. Run checks and tests before submitting:
bun run devcheck
bun run test
Apache-2.0 — see LICENSE for details.