Skip to main content
Glama

Bluesky Social MCP

server.py31.1 kB
"""Bluesky MCP Server. A single-file implementation with all tool logic directly embedded. """ import base64 from contextlib import asynccontextmanager from dataclasses import dataclass import os from typing import Any, AsyncIterator, Dict, List, Optional, Union from atproto import Client from mcp.server.fastmcp import Context, FastMCP from pathlib import Path project_root = Path(__file__).parent.parent.absolute() LOG_FILE = project_root / "custom-mcp.log" def login() -> Optional[Client]: """Login to Bluesky API and return the client. Authenticates using environment variables: - BLUESKY_IDENTIFIER: The handle (username) - BLUESKY_APP_PASSWORD: The app password - BLUESKY_SERVICE_URL: The service URL (defaults to "https://bsky.social") Returns: Authenticated Client instance or None if credentials are not available """ handle = os.environ.get("BLUESKY_IDENTIFIER") password = os.environ.get("BLUESKY_APP_PASSWORD") service_url = os.environ.get("BLUESKY_SERVICE_URL", "https://bsky.social") if not handle or not password: return None # This is helpful for debugging. # print(f"LOGIN {handle=} {service_url=}", file=sys.stderr) # Create and authenticate client client = Client(service_url) client.login(handle, password) return client def get_authenticated_client(ctx: Context) -> Client: """Get an authenticated client, creating it lazily if needed. Args: ctx: MCP context Returns: Authenticated Client instance Raises: ValueError: If credentials are not available """ app_context = ctx.request_context.lifespan_context # If we already have a client, return it if app_context.bluesky_client is not None: return app_context.bluesky_client # Try to create a new client by calling login again client = login() if client is None: raise ValueError( "Authentication required but credentials not available. " "Please set BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD environment variables." ) # Store it in the context for future use app_context.bluesky_client = client return client @dataclass class AppContext: bluesky_client: Optional[Client] @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Manage application lifecycle with typed context. Args: server: The FastMCP server instance Yields: ServerContext with initialized resources """ # Initialize resources - login may return None if credentials not available bluesky_client = login() try: yield AppContext(bluesky_client=bluesky_client) finally: # TODO: Add a logout here. pass # Create MCP server mcp = FastMCP( "bluesky-social", lifespan=app_lifespan, dependencies=["atproto", "mcp"], ) @mcp.tool() def check_auth_status(ctx: Context) -> str: """Check if the current session is authenticated. Authentication happens automatically using environment variables: - BLUESKY_IDENTIFIER: Required - your Bluesky handle - BLUESKY_APP_PASSWORD: Required - your app password - BLUESKY_SERVICE_URL: Optional - defaults to https://bsky.social Returns: Authentication status """ try: bluesky_client = get_authenticated_client(ctx) return f"Authenticated to {bluesky_client._base_url}" except ValueError as e: return f"Not authenticated: {str(e)}" @mcp.tool() def get_profile(ctx: Context, handle: Optional[str] = None) -> Dict: """Get a user profile. Args: ctx: MCP context handle: Optional handle to get profile for. If None, gets the authenticated user Returns: Profile data """ try: bluesky_client = get_authenticated_client(ctx) # If no handle provided, get authenticated user's profile if not handle: handle = bluesky_client.me.handle profile_response = bluesky_client.get_profile(handle) profile = profile_response.dict() return {"status": "success", "profile": profile} except Exception as e: error_msg = f"Failed to get profile: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_follows( ctx: Context, handle: Optional[str] = None, limit: Union[int, str] = 50, cursor: Optional[str] = None, ) -> Dict: """Get users followed by an account. Args: ctx: MCP context handle: Optional handle to get follows for. If None, gets the authenticated user limit: Maximum number of results to return (1-100) cursor: Optional pagination cursor Returns: List of followed accounts """ try: bluesky_client = get_authenticated_client(ctx) # If no handle provided, get authenticated user's follows if not handle: handle = bluesky_client.me.handle # Convert limit to int if it's a string if isinstance(limit, str): limit = int(limit) limit = max(1, min(100, limit)) # Call get_follows directly with positional arguments as per the client signature follows_response = bluesky_client.get_follows(handle, cursor, limit) follows_data = follows_response.dict() return {"status": "success", "follows": follows_data} except Exception as e: error_msg = f"Failed to get follows: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_followers( ctx: Context, handle: Optional[str] = None, limit: Union[int, str] = 50, cursor: Optional[str] = None, ) -> Dict: """Get users who follow an account. Args: ctx: MCP context handle: Optional handle to get followers for. If None, gets the authenticated user limit: Maximum number of results to return (1-100) cursor: Optional pagination cursor Returns: List of follower accounts """ try: bluesky_client = get_authenticated_client(ctx) # If no handle provided, get authenticated user's followers if not handle: handle = bluesky_client.me.handle # Convert limit to int if it's a string if isinstance(limit, str): limit = int(limit) limit = max(1, min(100, limit)) # Call get_followers directly with positional arguments as per the client signature followers_response = bluesky_client.get_followers(handle, cursor, limit) followers_data = followers_response.dict() return {"status": "success", "followers": followers_data} except Exception as e: error_msg = f"Failed to get followers: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def like_post( ctx: Context, uri: str, cid: str, ) -> Dict: """Like a post. Args: ctx: MCP context uri: URI of the post to like cid: CID of the post to like Returns: Status of the like operation """ try: bluesky_client = get_authenticated_client(ctx) like_response = bluesky_client.like(uri, cid) return { "status": "success", "message": "Post liked successfully", "like_uri": like_response.uri, "like_cid": like_response.cid, } except Exception as e: error_msg = f"Failed to like post: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def unlike_post( ctx: Context, like_uri: str, ) -> Dict: """Unlike a previously liked post. Args: ctx: MCP context like_uri: URI of the like. Returns: Status of the unlike operation """ try: bluesky_client = get_authenticated_client(ctx) bluesky_client.unlike(like_uri) return { "status": "success", "message": "Post unliked successfully", } except Exception as e: error_msg = f"Failed to unlike post: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def send_post( ctx: Context, text: str, profile_identify: Optional[str] = None, reply_to: Optional[Dict[str, Any]] = None, embed: Optional[Dict[str, Any]] = None, langs: Optional[List[str]] = None, facets: Optional[List[Dict[str, Any]]] = None, ) -> Dict: """Send a post to Bluesky. Args: ctx: MCP context text: Text content of the post profile_identify: Optional handle or DID. Where to send post. If not provided, sends to current profile reply_to: Optional reply reference with 'root' and 'parent' containing 'uri' and 'cid' embed: Optional embed object (images, external links, records, or video) langs: Optional list of language codes used in the post (defaults to ['en']) facets: Optional list of rich text facets (mentions, links, etc.) Returns: Status of the post creation with uri and cid of the created post """ try: bluesky_client = get_authenticated_client(ctx) # Prepare parameters for send_post kwargs: Dict[str, Any] = {"text": text} # Add optional parameters if provided if profile_identify: kwargs["profile_identify"] = profile_identify if reply_to: kwargs["reply_to"] = reply_to if embed: kwargs["embed"] = embed if langs: kwargs["langs"] = langs if facets: kwargs["facets"] = facets # Create the post using the native send_post method post_response = bluesky_client.send_post(**kwargs) return { "status": "success", "message": "Post sent successfully", "post_uri": post_response.uri, "post_cid": post_response.cid, } except Exception as e: error_msg = f"Failed to send post: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def repost( ctx: Context, uri: str, cid: str, ) -> Dict: """Repost another user's post. Args: ctx: MCP context uri: URI of the post to repost cid: CID of the post to repost Returns: Status of the repost operation """ try: bluesky_client = get_authenticated_client(ctx) repost_response = bluesky_client.repost(uri, cid) return { "status": "success", "message": "Post reposted successfully", "repost_uri": repost_response.uri, "repost_cid": repost_response.cid, } except Exception as e: error_msg = f"Failed to repost: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def unrepost( ctx: Context, repost_uri: str, ) -> Dict: """Remove a repost of another user's post. Args: ctx: MCP context repost_uri: URI of the repost to remove Returns: Status of the unrepost operation """ try: bluesky_client = get_authenticated_client(ctx) success = bluesky_client.unrepost(repost_uri) if success: return { "status": "success", "message": "Repost removed successfully", } else: return { "status": "error", "message": "Failed to remove repost", } except Exception as e: error_msg = f"Failed to unrepost: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_likes( ctx: Context, uri: str, cid: Optional[str] = None, limit: Union[int, str] = 50, cursor: Optional[str] = None, ) -> Dict: """Get likes for a post. Args: ctx: MCP context uri: URI of the post to get likes for cid: Optional CID of the post (not strictly required) limit: Maximum number of results to return (1-100) cursor: Optional pagination cursor Returns: List of likes for the post """ try: bluesky_client = get_authenticated_client(ctx) params = {"uri": uri, "limit": max(1, min(100, limit))} if cursor: params["cursor"] = cursor likes_response = bluesky_client.get_likes(**params) likes_data = likes_response.dict() return {"status": "success", "likes": likes_data} except Exception as e: error_msg = f"Failed to get likes: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_reposted_by( ctx: Context, uri: str, cid: Optional[str] = None, limit: Union[int, str] = 50, cursor: Optional[str] = None, ) -> Dict: """Get users who reposted a post. Args: ctx: MCP context uri: URI of the post to get reposts for cid: Optional CID of the post (not strictly required) limit: Maximum number of results to return (1-100) cursor: Optional pagination cursor Returns: List of users who reposted the post """ try: bluesky_client = get_authenticated_client(ctx) # Convert limit to int if it's a string if isinstance(limit, str): limit = int(limit) limit = max(1, min(100, limit)) # Call get_reposted_by with positional arguments as per the client signature reposts_response = bluesky_client.get_reposted_by(uri, cid, cursor, limit) reposts_data = reposts_response.dict() return {"status": "success", "reposts": reposts_data} except Exception as e: error_msg = f"Failed to get reposts: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_post( ctx: Context, post_rkey: str, profile_identify: Optional[str] = None, cid: Optional[str] = None, ) -> Dict: """Get a specific post. Args: ctx: MCP context post_rkey: The record key of the post profile_identify: Handle or DID of the post author cid: Optional CID of the post Returns: The requested post """ try: bluesky_client = get_authenticated_client(ctx) post_response = bluesky_client.get_post(post_rkey, profile_identify, cid) # Convert the response to a dictionary if hasattr(post_response, "model_dump"): post_data = post_response.model_dump() else: post_data = post_response return {"status": "success", "post": post_data} except Exception as e: error_msg = f"Failed to get post: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_posts( ctx: Context, uris: List[str], ) -> Dict: """Get multiple posts by their URIs. Args: ctx: MCP context uris: List of post URIs to retrieve Returns: List of requested posts """ try: bluesky_client = get_authenticated_client(ctx) posts_response = bluesky_client.get_posts(uris) # Convert the response to a dictionary if hasattr(posts_response, "model_dump"): posts_data = posts_response.model_dump() else: posts_data = posts_response return {"status": "success", "posts": posts_data} except Exception as e: error_msg = f"Failed to get posts: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_timeline( ctx: Context, algorithm: Optional[str] = None, cursor: Optional[str] = None, limit: Optional[int] = None, ) -> Dict: """Get posts from your home timeline. Args: ctx: MCP context algorithm: Optional algorithm to use for timeline cursor: Optional pagination cursor limit: Maximum number of results to return Returns: Timeline feed with posts """ try: bluesky_client = get_authenticated_client(ctx) timeline_response = bluesky_client.get_timeline(algorithm, cursor, limit) # Convert the response to a dictionary if hasattr(timeline_response, "model_dump"): timeline_data = timeline_response.model_dump() else: timeline_data = timeline_response return {"status": "success", "timeline": timeline_data} except Exception as e: error_msg = f"Failed to get timeline: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_author_feed( ctx: Context, actor: str, cursor: Optional[str] = None, filter: Optional[str] = None, limit: Optional[int] = None, include_pins: bool = False, ) -> Dict: """Get posts from a specific user. Args: ctx: MCP context actor: Handle or DID of the user cursor: Optional pagination cursor filter: Optional filter for post types limit: Maximum number of results to return include_pins: Whether to include pinned posts Returns: Feed with posts from the specified user """ try: bluesky_client = get_authenticated_client(ctx) feed_response = bluesky_client.get_author_feed( actor, cursor, filter, limit, include_pins ) # Convert the response to a dictionary if hasattr(feed_response, "model_dump"): feed_data = feed_response.model_dump() else: feed_data = feed_response return {"status": "success", "feed": feed_data} except Exception as e: error_msg = f"Failed to get author feed: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def get_post_thread( ctx: Context, uri: str, depth: Optional[int] = None, parent_height: Optional[int] = None, ) -> Dict: """Get a full conversation thread. Args: ctx: MCP context uri: URI of the post to get thread for depth: How many levels of replies to include parent_height: How many parent posts to include Returns: Thread with the post and its replies/parents """ try: bluesky_client = get_authenticated_client(ctx) thread_response = bluesky_client.get_post_thread(uri, depth, parent_height) # Convert the response to a dictionary if hasattr(thread_response, "model_dump"): thread_data = thread_response.model_dump() else: thread_data = thread_response return {"status": "success", "thread": thread_data} except Exception as e: error_msg = f"Failed to get post thread: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def resolve_handle( ctx: Context, handle: str, ) -> Dict: """Resolve a handle to a DID. Args: ctx: MCP context handle: User handle to resolve (e.g. "user.bsky.social") Returns: Resolved DID information """ try: bluesky_client = get_authenticated_client(ctx) resolved = bluesky_client.resolve_handle(handle) # Convert the response to a dictionary if hasattr(resolved, "model_dump"): resolved_data = resolved.model_dump() else: resolved_data = resolved return { "status": "success", "handle": handle, "did": resolved_data.get("did"), } except Exception as e: error_msg = f"Failed to resolve handle: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def mute_user( ctx: Context, actor: str, ) -> Dict: """Mute a user. Args: ctx: MCP context actor: Handle or DID of the user to mute Returns: Status of the mute operation """ try: bluesky_client = get_authenticated_client(ctx) # The mute method returns a boolean success = bluesky_client.mute(actor) if success: return { "status": "success", "message": f"Muted user {actor}", } else: return { "status": "error", "message": "Failed to mute user", } except Exception as e: error_msg = f"Failed to mute user: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def unmute_user( ctx: Context, actor: str, ) -> Dict: """Unmute a previously muted user. Args: ctx: MCP context actor: Handle or DID of the user to unmute Returns: Status of the unmute operation """ try: bluesky_client = get_authenticated_client(ctx) # The unmute method returns a boolean success = bluesky_client.unmute(actor) if success: return { "status": "success", "message": f"Unmuted user {actor}", } else: return { "status": "error", "message": "Failed to unmute user", } except Exception as e: error_msg = f"Failed to unmute user: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def unfollow_user( ctx: Context, follow_uri: str, ) -> Dict: """Unfollow a user. Args: ctx: MCP context follow_uri: URI of the follow record to delete Returns: Status of the unfollow operation """ try: bluesky_client = get_authenticated_client(ctx) # The unfollow method returns a boolean success = bluesky_client.unfollow(follow_uri) if success: return { "status": "success", "message": "Successfully unfollowed user", } else: return { "status": "error", "message": "Failed to unfollow user", } except Exception as e: error_msg = f"Failed to unfollow user: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def send_image( ctx: Context, text: str, image_data: str, image_alt: str, profile_identify: Optional[str] = None, reply_to: Optional[Dict[str, Any]] = None, langs: Optional[List[str]] = None, facets: Optional[List[Dict[str, Any]]] = None, ) -> Dict: """Send a post with a single image. Args: ctx: MCP context text: Text content of the post image_data: Base64-encoded image data image_alt: Alternative text description for the image profile_identify: Optional handle or DID for the post author reply_to: Optional reply information dict with keys uri and cid langs: Optional list of language codes facets: Optional list of facets (mentions, links, etc.) Returns: Status of the post creation """ try: bluesky_client = get_authenticated_client(ctx) # Decode base64 image try: image_bytes = base64.b64decode(image_data) except Exception as e: return { "status": "error", "message": f"Failed to decode image data: {str(e)}", } # Send the post with image post_response = bluesky_client.send_image( text=text, image=image_bytes, image_alt=image_alt, profile_identify=profile_identify, reply_to=reply_to, langs=langs, facets=facets, ) return { "status": "success", "message": "Post with image created successfully", "post_uri": post_response.uri, "post_cid": post_response.cid, } except Exception as e: error_msg = f"Failed to create post with image: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def send_images( ctx: Context, text: str, images_data: List[str], image_alts: Optional[List[str]] = None, profile_identify: Optional[str] = None, reply_to: Optional[Dict[str, Any]] = None, langs: Optional[List[str]] = None, facets: Optional[List[Dict[str, Any]]] = None, ) -> Dict: """Send a post with multiple images (up to 4). Args: ctx: MCP context text: Text content of the post images_data: List of base64-encoded image data (max 4) image_alts: Optional list of alt text for each image profile_identify: Optional handle or DID for the post author reply_to: Optional reply information dict with keys uri and cid langs: Optional list of language codes facets: Optional list of facets (mentions, links, etc.) Returns: Status of the post creation """ try: bluesky_client = get_authenticated_client(ctx) # Verify we have 1-4 images if not images_data: return { "status": "error", "message": "At least one image is required", } if len(images_data) > 4: return { "status": "error", "message": "Maximum of 4 images allowed", } # Decode all images images_bytes = [] for img_data in images_data: try: image_bytes = base64.b64decode(img_data) images_bytes.append(image_bytes) except Exception as e: return { "status": "error", "message": f"Failed to decode image data: {str(e)}", } # Send the post with images post_response = bluesky_client.send_images( text=text, images=images_bytes, image_alts=image_alts, profile_identify=profile_identify, reply_to=reply_to, langs=langs, facets=facets, ) return { "status": "success", "message": "Post with images created successfully", "post_uri": post_response.uri, "post_cid": post_response.cid, } except Exception as e: error_msg = f"Failed to create post with images: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def send_video( ctx: Context, text: str, video_data: str, video_alt: Optional[str] = None, profile_identify: Optional[str] = None, reply_to: Optional[Dict[str, Any]] = None, langs: Optional[List[str]] = None, facets: Optional[List[Dict[str, Any]]] = None, ) -> Dict: """Send a post with a video. Args: ctx: MCP context text: Text content of the post video_data: Base64-encoded video data video_alt: Optional alternative text description for the video profile_identify: Optional handle or DID for the post author reply_to: Optional reply information dict with keys uri and cid langs: Optional list of language codes facets: Optional list of facets (mentions, links, etc.) Returns: Status of the post creation """ try: bluesky_client = get_authenticated_client(ctx) # Decode base64 video try: video_bytes = base64.b64decode(video_data) except Exception as e: return { "status": "error", "message": f"Failed to decode video data: {str(e)}", } # Send the post with video post_response = bluesky_client.send_video( text=text, video=video_bytes, video_alt=video_alt, profile_identify=profile_identify, reply_to=reply_to, langs=langs, facets=facets, ) return { "status": "success", "message": "Post with video created successfully", "post_uri": post_response.uri, "post_cid": post_response.cid, } except Exception as e: error_msg = f"Failed to create post with video: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def delete_post( ctx: Context, uri: str, ) -> Dict: """Delete a post created by the authenticated user. Args: ctx: MCP context uri: URI of the post to delete Returns: Status of the delete operation """ try: bluesky_client = get_authenticated_client(ctx) # Delete the post bluesky_client.delete_post(uri) return { "status": "success", "message": "Post deleted successfully", } except Exception as e: error_msg = f"Failed to delete post: {str(e)}" return {"status": "error", "message": error_msg} @mcp.tool() def follow_user( ctx: Context, handle: str, ) -> Dict: """Follow a user. Args: ctx: MCP context handle: Handle of the user to follow Returns: Status of the follow operation """ try: bluesky_client = get_authenticated_client(ctx) # First resolve the handle to a DID resolved = bluesky_client.resolve_handle(handle) did = resolved.did # Now follow the user - follow method expects the DID as subject parameter follow_response = bluesky_client.follow(did) return { "status": "success", "message": f"Now following {handle}", "follow_uri": follow_response.uri, "follow_cid": follow_response.cid, } except Exception as e: error_msg = f"Failed to follow user: {str(e)}" return {"status": "error", "message": error_msg} # Add resource to provide information about available tools @mcp.resource("info://bluesky-tools") def get_bluesky_tools_info() -> Dict: """Get information about the available Bluesky tools.""" tools_info = { "description": "Bluesky API Tools", "version": "0.1.0", "auth_requirements": "Most tools require authentication using BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD environment variables", "categories": { "authentication": ["check_environment_variables", "check_auth_status"], "profiles": ["get_profile", "get_follows", "get_followers", "follow_user"], "posts": [ "get_timeline_posts", "get_feed_posts", "get_list_posts", "get_user_posts", "get_liked_posts", "create_post", "like_post", "get_post_thread", ], "search": ["search_posts", "search_people", "search_feeds"], "utilities": ["convert_url_to_uri", "get_trends", "get_pinned_feeds"], }, } return tools_info def main(): """Main entry point for the script.""" # Stdio is prefered for local execution. mcp.run(transport="stdio") # Main entry point if __name__ == "__main__": main()

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/gwbischof/bluesky-social-mcp'

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