mcp-bsky

A Bluesky MCP server for the 2026-07-28 era, built on marianoguerra/mcp-server and marianoguerra/atproto. Reads the public AT Protocol AppView with no account and no token; posts with an app password.

mcp
model-context-protocol
bluesky
atproto
server
moon add marianoguerra/mcp-bsky@0.7.0
Download zip
Version
0.7.0
License
MIT
Last updated
2 days ago
Downloads
4
README

#marianoguerra/mcp-bsky

A Bluesky (AT Protocol) MCP server for the 2026-07-28 protocol era, built on marianoguerra/mcp-server and marianoguerra/atproto.

It runs with no account and no token. The default backend is the public AT Protocol AppView, which serves reads to anybody — and is pointed at a real account by two flags.

moon install marianoguerra/mcp-bsky

$ bsky-mcp # stdio, public AppView, anonymous reads $ bsky-mcp --auth app-password \ # a real account: reads and writes --identifier alice.bsky.social --app-password xxxx-xxxx-xxxx-xxxx $ bsky-mcp --service https://my.pds.example # a self-hosted PDS $ bsky-mcp --http 127.0.0.1:3400 # streamable HTTP instead of stdio

#How this differs from mcp-slack

The sibling mcp-slack needs no Slack account because it ships an in-process mock workspace: no account, no token, and no network. marianoguerra/atproto has no equivalent — an in-memory PDS is on its list and not in it — so the zero-setup path here comes from somewhere else: Bluesky genuinely serves unauthenticated reads.

That is a better story in one way and a worse one in another, and it is worth being exact about which:

mcp-slackmcp-bsky
Needs an accountnono
Needs a networknoyes
Data is realno (synthetic)yes
Deterministicyesno
Writes demoable without consequencesyesno — a post is public

So the offline, byte-reproducible leg that mcp-slack gets for free is the thing this module still owes; see What is missing.

#The tools

ToolBacking call
Rbsky_whoamicom.atproto.server.getSession
Rbsky_get_profileapp.bsky.actor.getProfiles
Rbsky_search_actorsapp.bsky.actor.searchActors + rank
Rbsky_permalink(pure — no request at all)
Rbsky_get_timelineapp.bsky.feed.getTimeline (needs an account)
Rbsky_get_author_feedapp.bsky.feed.getAuthorFeed
Rbsky_search_postsapp.bsky.feed.searchPosts (needs an account)
Rbsky_get_post_threadapp.bsky.feed.getPostThread

bsky_get_profile is the exact lookup — a handle, a DID or a profile URL all name one account — and it answers with the whole bio rather than a line of it, plus the fields that tell two similar accounts apart: the DID, the domain verification, the creation date and the website. bsky_search_actors is the inexact one, and it adds two things the AppView does not: exact handle and display-name matches sort first, and every result carries a matchedOn saying which field the query was found in. That last one earns its place on a single case — a search for a person's name returns the person and every account whose bio happens to contain a word that looks like part of it, and only one of those is the answer.

Write tools — bsky_post, bsky_reply, bsky_like, bsky_repost, bsky_follow, bsky_delete — are designed and not yet implemented; bsky_delete will be the first tool in this repo annotated destructiveHint: true, because every Slack write mcp-slack exposes adds, and an annotation that is false everywhere gates nothing.

Reaching a real account takes --auth app-password and a credential on the command line. Nothing is read from the environment, on purpose — the same rule mcp-slack follows, for the same reason: a server that posts as somebody because a variable happened to be set is a server that will one day post during a demo.

#Inspecting it

This repo ships both ends, so its own client is the fastest way to see what the server offers.

$ just bsky-cli --method tools/list --format json \ | jq -r '.result.tools[] | "\(.annotations.readOnlyHint // false | if . then "R" else "W" end) \(.name)"' R bsky_whoami R bsky_get_profile … $ just bsky-cli --method tools/call --tool-name bsky_whoami Not signed in. This server is reading the public Bluesky AppView at public.api.bsky.app (anonymous), so every read works and every write will refuse. $ just bsky-cli --method tools/call --tool-name bsky_get_profile --tool-arg actor=bsky.app Bluesky @bsky.app (did:plc:z72i7hdynmk6r22z27h6tvur) — official Bluesky account … 34477142 followers · 11 following · 806 posts https://bsky.app/profile/bsky.app

Over HTTP it is just bsky-mcp --http 127.0.0.1:3400 and just cli http://127.0.0.1:3400/mcp--method initialize from another shell. There is no initialize on the wire in this era — the CLI synthesizes it from server/discover, so a reply at all means discovery is spec-shaped.

#What it does that a thin API wrapper would not

It accepts what a model actually writes. An account arrives as alice.bsky.social, @alice.bsky.social, did:plc:… or https://bsky.app/profile/alice.bsky.social, and a post as an at:// URI or a bsky.app link with whatever query string the browser attached. A model will mix all of them across three consecutive calls, so every tool resolves first — and every schema says so, because a description mentioning only at-URIs trains the model to resolve one it never needed.

The resolution is pure: a bsky.app link names its author by handle, and the at-URI built from it keeps the handle as the authority rather than resolving it, because AtUri's authority is an AtIdentifier and the AppView resolves one on our behalf. Making that round trip here would spend a request to arrive at a string the service accepts either way.

It refuses locally what it knows will fail. bsky_get_timeline on an anonymous server answers "restart it with --auth app-password" rather than passing the call through to a 401 nobody can act on. A timeline is assembled from who you follow, and the public AppView has no idea who that is.

It splits the two error channels by whose problem it is. A refusal, a rate limit or an unreachable service becomes isError: true content the model can act on — with the reason spelled out, and with the wait attached when the service sent one. A 2xx whose body did not match the Lexicon becomes a JSON-RPC -32603 instead, because no amount of retrying fixes a shape mismatch. A bad handle carries @atproto/syntax's own message, so a rejection can be shown to happen for the same reason upstream rejects rather than merely at the same time:

$ just bsky-cli --method tools/call --tool-name bsky_get_profile --tool-arg actor='not a handle!' 'not a handle!' is not an account this server can use. Expected a handle, a `did:…` or a profile URL — Disallowed characters in handle (ASCII letters, digits, dashes, periods only) isError: true

It builds permalinks rather than fetching them — and answers nothing for a collection with no page on the web front end, because a fabricated …/like/… URL 404s for whoever the model hands it to and looks like a real citation until they click it.

#Layering

src/tools/ the tools, resolution and rendering — native, js, wasm, wasm-gc src/ the binary — native

src/tools imports nothing async, so the domain layer of this server checks on all four backendsjust backends proves it alongside mcp, mcp-server and mcp-slack. Calling @client's async methods needs no runtime; async is a language feature and moonbitlang/async is only the scheduler. Only the binary is native, and only because stdio and sockets are.

That portability is the point being demonstrated, and demonstrating it twice against two unrelated protocols is why this module exists next to mcp-slack.

#What is missing from the library

Found while building this, against marianoguerra/atproto 0.1.0. Ordered by how much they block.

  1. No richtext facet detector. RichtextFacet and its Mention/Link/Tag features are generated types, but nothing produces them — there is no detectFacets(). Without one, bsky_post would publish hey @alice.bsky.social, see https://example.com as inert text: no mention notification, no link. The offsets are UTF-8 bytes and MoonBit strings are UTF-16, so one emoji shifts every facet after it. Blocks the write tools. Belongs upstream as a pure, all-backend atproto/richtext.
  2. No lexicon validation and no grapheme counter. @lexicon parses maxGraphemes: 300 into LexString.max_graphemes and nothing enforces it, so a 320-grapheme post is a round trip and a generic rejection instead of a local error naming the count and the limit.
  3. actor_get_profile answers raw Bytes. The generator decides an output type with payload.schema is Some(Object(_)), so an endpoint whose output is a ref rather than an inline object gets no typed decode. Nine endpoints are affected, including app.bsky.actor.getProfile and com.atproto.identity.resolveIdentity. This server calls getProfiles (plural) with one actor instead — same request, and the decoding stays the library's problem.
  4. No DID→PDS resolution. atproto/identity parses DID documents and deliberately does not resolve them. identity.resolveHandle covers handle→DID over XRPC, but finding an arbitrary account's PDS needs plc.directory — which mcp-slack/src/atproto/fetch.mbt already hand-rolls, which is evidence the same thirty lines get rewritten per consumer. Belongs in atproto-http, which has the socket.
  5. HttpTransport's trust parameter is dead. It is accepted, stored and never passed to @http.get/@http.post, so the self-signed development PDS its own doc comment advertises does not work.
  6. No OAuth. App passwords are the only credential path, and they are all-or-nothing — no scoping — which is why gating writes behind an explicit flag matters more here than for Slack.
  7. No DMs, no firehose, no CAR/DAG-CBOR. chat.bsky.* and tools.ozone.* are not among the vendored Lexicons, and subscriptions need websockets the Transport trait does not model. So there is no counterpart to slack_open_conversation, and no live-mentions resource.

Also worth knowing: no test in atproto-mb ever opens a socket — the native transport's first real exercise is this server.

#And one thing that is missing here

There is no offline backend yet. mcp-slack gets determinism from its in-process mock; the counterpart here is a keyed cassette — a @xrpc.Transport that matches on NSID plus query string rather than replaying a queue, so a model calling tools in any order still replays — together with a --record mode to write one. @testing.FakeTransport is scripted rather than keyed, so it suits tests and not a server.

#Testing

The catalog — tool names, schemas, annotations, instructions — is checked by calling build() and Server::route, both pure, with no transport and no async runtime, which is why those tests run on wasm. Resolution is checked against every spelling a model actually produces, including a link with a query string and one with a fragment. The permalink round-trips, and a collection with no web page is asserted to have no link.

#
Options

type Options

Source Files