diff --git a/examples/atproto_mcp/demo.py b/examples/atproto_mcp/demo.py index 43a4c23db..f4992a696 100644 --- a/examples/atproto_mcp/demo.py +++ b/examples/atproto_mcp/demo.py @@ -1,5 +1,6 @@ """Demo script showing ATProto MCP server capabilities.""" +import argparse import asyncio import json @@ -8,7 +9,7 @@ from atproto_mcp.server import atproto_mcp from fastmcp import Client -async def main(): +async def main(enable_posting: bool = False): print("๐Ÿ”ต ATProto MCP Server Demo\n") async with Client(atproto_mcp) as client: @@ -77,22 +78,37 @@ async def main(): else: print(f"โŒ Failed to get notifications: {notifs.get('error')}") - # 5. Demo posting (commented out to avoid spam) - print("\n5. Posting capability:") - print(" To post, you would use:") - print( - ' await client.call_tool("post_to_bluesky", {"text": "Hello from FastMCP! ๐Ÿš€"})' - ) - - # Uncomment to actually post: - # post = await client.call_tool("post_to_bluesky", { - # "text": "Testing ATProto MCP server with FastMCP! ๐Ÿš€" - # }) - # if post.get("success"): - # print(f"โœ… Posted successfully: {post['uri']}") + # 5. Demo posting + if enable_posting: + print("\n5. Creating a test post...") + post = await client.call_tool( + "post_to_bluesky", + { + "text": "๐Ÿงช Testing the ATProto MCP server demo! This post was created programmatically using FastMCP. #FastMCP #ATProto" + }, + ) + result = json.loads(post[0].text) if post else {} + if result.get("success"): + print("โœ… Posted successfully!") + print(f" URI: {result['uri']}") + print(f" Created at: {result['created_at']}") + else: + print(f"โŒ Failed to post: {result.get('error')}") + else: + print("\n5. Posting capability:") + print(" To enable posting, run with --post flag") + print(" Example: python demo.py --post") print("\nโœจ Demo complete!") if __name__ == "__main__": - asyncio.run(main()) + parser = argparse.ArgumentParser(description="ATProto MCP Server Demo") + parser.add_argument( + "--post", + action="store_true", + help="Enable posting a test message to Bluesky", + ) + args = parser.parse_args() + + asyncio.run(main(enable_posting=args.post)) diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto.py b/examples/atproto_mcp/src/atproto_mcp/_atproto.py new file mode 100644 index 000000000..3b052ca67 --- /dev/null +++ b/examples/atproto_mcp/src/atproto_mcp/_atproto.py @@ -0,0 +1,197 @@ +"""Private ATProto implementation details.""" + +from datetime import datetime + +from atproto import Client + +from atproto_mcp.settings import settings + +_client: Client | None = None + + +def get_client() -> Client: + """Get or create an authenticated ATProto client.""" + global _client + if _client is None: + _client = Client() + _client.login(settings.atproto_handle, settings.atproto_password) + return _client + + +def get_profile_info() -> dict: + """Get profile information for the authenticated user.""" + try: + client = get_client() + profile = client.get_profile(client.me.did) + return { + "connected": True, + "handle": profile.handle, + "display_name": profile.display_name, + "did": client.me.did, + "followers": profile.followers_count, + "following": profile.follows_count, + "posts": profile.posts_count, + } + except Exception as e: + return {"connected": False, "error": str(e)} + + +def create_post(text: str) -> dict: + """Create a new post.""" + try: + client = get_client() + post = client.send_post(text=text) + return { + "success": True, + "uri": post.uri, + "cid": post.cid, + "text": text, + "created_at": datetime.now().isoformat(), + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def fetch_timeline(limit: int = 10) -> dict: + """Fetch the authenticated user's timeline.""" + try: + client = get_client() + timeline = client.get_timeline(limit=limit) + + posts = [] + for feed_view in timeline.feed: + post = feed_view.post + posts.append( + { + "author": post.author.handle, + "text": post.record.text if hasattr(post.record, "text") else None, + "created_at": post.record.created_at + if hasattr(post.record, "created_at") + else None, + "likes": post.like_count, + "reposts": post.repost_count, + "replies": post.reply_count, + "uri": post.uri, + } + ) + + return { + "success": True, + "count": len(posts), + "posts": posts, + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def search_for_posts(query: str, limit: int = 10) -> dict: + """Search for posts containing specific text.""" + try: + client = get_client() + search_results = client.app.bsky.feed.search_posts( + params={"q": query, "limit": limit} + ) + + posts = [] + for post in search_results.posts: + posts.append( + { + "author": post.author.handle, + "text": post.record.text if hasattr(post.record, "text") else None, + "created_at": post.record.created_at + if hasattr(post.record, "created_at") + else None, + "likes": post.like_count, + "reposts": post.repost_count, + "replies": post.reply_count, + "uri": post.uri, + } + ) + + return { + "success": True, + "query": query, + "count": len(posts), + "posts": posts, + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def fetch_notifications(limit: int = 10) -> dict: + """Get recent notifications.""" + try: + client = get_client() + notifications = client.app.bsky.notification.list_notifications( + params={"limit": limit} + ) + + notifs = [] + for notif in notifications.notifications: + notifs.append( + { + "reason": notif.reason, + "author": notif.author.handle if notif.author else None, + "is_read": notif.is_read, + "created_at": notif.indexed_at, + "uri": notif.uri, + } + ) + + return { + "success": True, + "count": len(notifs), + "notifications": notifs, + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def follow_user_by_handle(handle: str) -> dict: + """Follow a user by their handle.""" + try: + client = get_client() + # Resolve handle to DID + resolved = client.app.bsky.actor.search_actors(q=handle, limit=1) + if not resolved.actors: + return {"success": False, "error": f"User {handle} not found"} + + user_did = resolved.actors[0].did + follow = client.follow(user_did) + + return { + "success": True, + "followed": handle, + "did": user_did, + "uri": follow.uri, + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def like_post_by_uri(uri: str) -> dict: + """Like a post by its AT URI.""" + try: + client = get_client() + like = client.like(uri) + return { + "success": True, + "liked_uri": uri, + "like_uri": like.uri, + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def repost_by_uri(uri: str) -> dict: + """Repost a post by its AT URI.""" + try: + client = get_client() + repost = client.repost(uri) + return { + "success": True, + "reposted_uri": uri, + "repost_uri": repost.uri, + } + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/examples/atproto_mcp/src/atproto_mcp/server.py b/examples/atproto_mcp/src/atproto_mcp/server.py index 9b46871ea..e1067c5c5 100644 --- a/examples/atproto_mcp/src/atproto_mcp/server.py +++ b/examples/atproto_mcp/src/atproto_mcp/server.py @@ -1,8 +1,6 @@ -from datetime import datetime +"""ATProto MCP Server - Public API exposing Bluesky tools.""" -from atproto import Client - -from atproto_mcp.settings import settings +from atproto_mcp import _atproto from fastmcp import FastMCP atproto_mcp = FastMCP( @@ -12,200 +10,50 @@ atproto_mcp = FastMCP( ], ) -_client: Client | None = None - - -def get_client() -> Client: - """Get or create an authenticated ATProto client.""" - global _client - if _client is None: - _client = Client() - _client.login(settings.atproto_handle, settings.atproto_password) - return _client - @atproto_mcp.tool def atproto_status() -> dict: """Check the status of the ATProto connection and current user.""" - try: - client = get_client() - profile = client.get_profile(client.me.did) - return { - "connected": True, - "handle": profile.handle, - "display_name": profile.display_name, - "did": client.me.did, - "followers": profile.followers_count, - "following": profile.follows_count, - "posts": profile.posts_count, - } - except Exception as e: - return {"connected": False, "error": str(e)} + return _atproto.get_profile_info() @atproto_mcp.tool def post_to_bluesky(text: str) -> dict: """Create a new post on Bluesky.""" - try: - client = get_client() - post = client.send_post(text=text) - return { - "success": True, - "uri": post.uri, - "cid": post.cid, - "text": text, - "created_at": datetime.now().isoformat(), - } - except Exception as e: - return {"success": False, "error": str(e)} + return _atproto.create_post(text) @atproto_mcp.tool def get_timeline(limit: int = 10) -> dict: """Get the authenticated user's timeline.""" - try: - client = get_client() - timeline = client.get_timeline(limit=limit) - - posts = [] - for feed_view in timeline.feed: - post = feed_view.post - posts.append( - { - "author": post.author.handle, - "text": post.record.text if hasattr(post.record, "text") else None, - "created_at": post.record.created_at - if hasattr(post.record, "created_at") - else None, - "likes": post.like_count, - "reposts": post.repost_count, - "replies": post.reply_count, - "uri": post.uri, - } - ) - - return { - "success": True, - "count": len(posts), - "posts": posts, - } - except Exception as e: - return {"success": False, "error": str(e)} + return _atproto.fetch_timeline(limit) @atproto_mcp.tool def search_posts(query: str, limit: int = 10) -> dict: """Search for posts containing specific text.""" - try: - client = get_client() - search_results = client.app.bsky.feed.search_posts( - params={"q": query, "limit": limit} - ) - - posts = [] - for post in search_results.posts: - posts.append( - { - "author": post.author.handle, - "text": post.record.text if hasattr(post.record, "text") else None, - "created_at": post.record.created_at - if hasattr(post.record, "created_at") - else None, - "likes": post.like_count, - "reposts": post.repost_count, - "replies": post.reply_count, - "uri": post.uri, - } - ) - - return { - "success": True, - "query": query, - "count": len(posts), - "posts": posts, - } - except Exception as e: - return {"success": False, "error": str(e)} + return _atproto.search_for_posts(query, limit) @atproto_mcp.tool def get_notifications(limit: int = 10) -> dict: """Get recent notifications for the authenticated user.""" - try: - client = get_client() - notifications = client.app.bsky.notification.list_notifications( - params={"limit": limit} - ) - - notifs = [] - for notif in notifications.notifications: - notifs.append( - { - "reason": notif.reason, - "author": notif.author.handle if notif.author else None, - "is_read": notif.is_read, - "created_at": notif.indexed_at, - "uri": notif.uri, - } - ) - - return { - "success": True, - "count": len(notifs), - "notifications": notifs, - } - except Exception as e: - return {"success": False, "error": str(e)} + return _atproto.fetch_notifications(limit) @atproto_mcp.tool def follow_user(handle: str) -> dict: """Follow a user by their handle.""" - try: - client = get_client() - # Resolve handle to DID - resolved = client.app.bsky.actor.search_actors(q=handle, limit=1) - if not resolved.actors: - return {"success": False, "error": f"User {handle} not found"} - - user_did = resolved.actors[0].did - follow = client.follow(user_did) - - return { - "success": True, - "followed": handle, - "did": user_did, - "uri": follow.uri, - } - except Exception as e: - return {"success": False, "error": str(e)} + return _atproto.follow_user_by_handle(handle) @atproto_mcp.tool def like_post(uri: str) -> dict: """Like a post by its AT URI.""" - try: - client = get_client() - like = client.like(uri) - return { - "success": True, - "liked_uri": uri, - "like_uri": like.uri, - } - except Exception as e: - return {"success": False, "error": str(e)} + return _atproto.like_post_by_uri(uri) @atproto_mcp.tool def repost(uri: str) -> dict: """Repost a post by its AT URI.""" - try: - client = get_client() - repost = client.repost(uri) - return { - "success": True, - "reposted_uri": uri, - "repost_uri": repost.uri, - } - except Exception as e: - return {"success": False, "error": str(e)} + return _atproto.repost_by_uri(uri)