zzstoatzz commited on
Commit
1fc02fb
·
1 Parent(s): 2a85f54

Implement full ATProto functionality with 8 tools for Bluesky interaction

Browse files
examples/atproto_mcp/README.md CHANGED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ATProto MCP Server
2
+
3
+ This example demonstrates a FastMCP server that provides tools for interacting with the AT Protocol (Bluesky).
4
+
5
+ ## Features
6
+
7
+ The server provides the following tools:
8
+
9
+ - **atproto_status**: Check connection status and profile information
10
+ - **post_to_bluesky**: Create new posts on Bluesky
11
+ - **get_timeline**: Retrieve your timeline feed
12
+ - **search_posts**: Search for posts by keyword
13
+ - **get_notifications**: Get recent notifications
14
+ - **follow_user**: Follow a user by handle
15
+ - **like_post**: Like a post by URI
16
+ - **repost**: Repost content by URI
17
+
18
+ ## Setup
19
+
20
+ 1. Create a `.env` file in the root directory with your Bluesky credentials:
21
+
22
+ ```bash
23
+ ATPROTO_HANDLE=your.handle@bsky.social
24
+ ATPROTO_PASSWORD=your-app-password
25
+ ATPROTO_PDS_URL=https://bsky.social # optional, defaults to bsky.social
26
+ ```
27
+
28
+ 2. Install and run the server:
29
+
30
+ ```bash
31
+ # Install dependencies
32
+ pip install -e .
33
+
34
+ # Run the server
35
+ python -m atproto_mcp
36
+ ```
37
+
38
+ ## Usage Example
39
+
40
+ ```python
41
+ from fastmcp import Client
42
+ from atproto_mcp.server import atproto_mcp
43
+
44
+ async def demo():
45
+ async with Client(atproto_mcp) as client:
46
+ # Check status
47
+ status = await client.call_tool("atproto_status", {})
48
+ print(f"Connected as: {status['handle']}")
49
+
50
+ # Post to Bluesky
51
+ post = await client.call_tool("post_to_bluesky", {
52
+ "text": "Hello from FastMCP! =�"
53
+ })
54
+ print(f"Posted: {post['uri']}")
55
+
56
+ # Get timeline
57
+ timeline = await client.call_tool("get_timeline", {"limit": 5})
58
+ print(f"Found {timeline['count']} posts")
59
+
60
+ # Search posts
61
+ results = await client.call_tool("search_posts", {
62
+ "query": "FastMCP",
63
+ "limit": 10
64
+ })
65
+ print(f"Found {results['count']} posts matching 'FastMCP'")
66
+ ```
67
+
68
+ ## Security Note
69
+
70
+ Store your Bluesky credentials securely in environment variables. Never commit credentials to version control.
examples/atproto_mcp/demo.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Demo script showing ATProto MCP server capabilities.
4
+ """
5
+
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # Add the src directory to the path
11
+ sys.path.insert(0, str(Path(__file__).parent / "src"))
12
+
13
+ from atproto_mcp.server import atproto_mcp
14
+
15
+ from fastmcp import Client
16
+
17
+
18
+ async def main():
19
+ print("🔵 ATProto MCP Server Demo\n")
20
+
21
+ async with Client(atproto_mcp) as client:
22
+ # 1. Check connection status
23
+ print("1. Checking connection status...")
24
+ status = await client.call_tool("atproto_status", {})
25
+
26
+ if status.get("connected"):
27
+ print(f"✅ Connected as: @{status['handle']}")
28
+ print(f" Followers: {status['followers']}")
29
+ print(f" Following: {status['following']}")
30
+ print(f" Posts: {status['posts']}")
31
+ else:
32
+ print(f"❌ Connection failed: {status.get('error')}")
33
+ return
34
+
35
+ # 2. Get timeline
36
+ print("\n2. Getting timeline (last 3 posts)...")
37
+ timeline = await client.call_tool("get_timeline", {"limit": 3})
38
+
39
+ if timeline.get("success"):
40
+ print(f"✅ Found {timeline['count']} posts:")
41
+ for i, post in enumerate(timeline["posts"], 1):
42
+ print(f"\n Post {i}:")
43
+ print(f" Author: @{post['author']}")
44
+ print(
45
+ f" Text: {post['text'][:100]}..."
46
+ if post["text"] and len(post["text"]) > 100
47
+ else f" Text: {post['text']}"
48
+ )
49
+ print(
50
+ f" Likes: {post['likes']} | Reposts: {post['reposts']} | Replies: {post['replies']}"
51
+ )
52
+ else:
53
+ print(f"❌ Failed to get timeline: {timeline.get('error')}")
54
+
55
+ # 3. Search for posts
56
+ print("\n3. Searching for posts about 'Python'...")
57
+ search = await client.call_tool("search_posts", {"query": "Python", "limit": 3})
58
+
59
+ if search.get("success"):
60
+ print(f"✅ Found {search['count']} posts about Python")
61
+ if search["posts"]:
62
+ post = search["posts"][0]
63
+ print(
64
+ f" Latest by @{post['author']}: {post['text'][:100]}..."
65
+ if post["text"] and len(post["text"]) > 100
66
+ else f" Latest by @{post['author']}: {post['text']}"
67
+ )
68
+ else:
69
+ print(f"❌ Search failed: {search.get('error')}")
70
+
71
+ # 4. Get notifications
72
+ print("\n4. Checking notifications...")
73
+ notifs = await client.call_tool("get_notifications", {"limit": 5})
74
+
75
+ if notifs.get("success"):
76
+ print(f"✅ You have {notifs['count']} recent notifications")
77
+ unread = sum(1 for n in notifs["notifications"] if not n["is_read"])
78
+ if unread:
79
+ print(f" ({unread} unread)")
80
+ else:
81
+ print(f"❌ Failed to get notifications: {notifs.get('error')}")
82
+
83
+ # 5. Demo posting (commented out to avoid spam)
84
+ print("\n5. Posting capability:")
85
+ print(" To post, you would use:")
86
+ print(
87
+ ' await client.call_tool("post_to_bluesky", {"text": "Hello from FastMCP! 🚀"})'
88
+ )
89
+
90
+ # Uncomment to actually post:
91
+ # post = await client.call_tool("post_to_bluesky", {
92
+ # "text": "Testing ATProto MCP server with FastMCP! 🚀"
93
+ # })
94
+ # if post.get("success"):
95
+ # print(f"✅ Posted successfully: {post['uri']}")
96
+
97
+ print("\n✨ Demo complete!")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ asyncio.run(main())
examples/atproto_mcp/src/atproto_mcp/server.py CHANGED
@@ -1,26 +1,210 @@
 
 
 
 
1
  from atproto_mcp.settings import settings
2
  from fastmcp import FastMCP
3
 
4
  atproto_mcp = FastMCP(
5
  "ATProto MCP Server",
6
  dependencies=[
7
- "atproto@git+https://github.com/MarshalX/atproto.git@refs/pull/605/head",
8
- "pydantic-settings>=2.0.0",
9
- "websockets>=15.0.1",
10
  ],
11
  )
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  @atproto_mcp.tool
15
- def atproto_status() -> str:
16
- """Checks the status of the ATProto connection."""
17
  try:
18
- # For now, just verify settings are loaded
19
- if settings.atproto_handle and settings.atproto_password:
20
- return (
21
- f"ATProto credentials configured for handle: {settings.atproto_handle}"
 
 
 
 
 
 
 
 
 
22
  )
23
- else:
24
- return "ATProto credentials not configured"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  except Exception as e:
26
- return f"ATProto status check failed: {e}"
 
1
+ from datetime import datetime
2
+
3
+ from atproto import Client
4
+
5
  from atproto_mcp.settings import settings
6
  from fastmcp import FastMCP
7
 
8
  atproto_mcp = FastMCP(
9
  "ATProto MCP Server",
10
  dependencies=[
11
+ "atproto_mcp@git+https://github.com/jlowin/fastmcp.git@atproto-example#subdirectory=examples/atproto_mcp",
 
 
12
  ],
13
  )
14
 
15
+ _client: Client | None = None
16
+
17
+
18
+ def get_client() -> Client:
19
+ """Get or create an authenticated ATProto client."""
20
+ global _client
21
+ if _client is None:
22
+ _client = Client()
23
+ _client.login(settings.atproto_handle, settings.atproto_password)
24
+ return _client
25
+
26
+
27
+ @atproto_mcp.tool
28
+ def atproto_status() -> dict:
29
+ """Check the status of the ATProto connection and current user."""
30
+ try:
31
+ client = get_client()
32
+ profile = client.get_profile(client.me.did)
33
+ return {
34
+ "connected": True,
35
+ "handle": profile.handle,
36
+ "display_name": profile.display_name,
37
+ "did": client.me.did,
38
+ "followers": profile.followers_count,
39
+ "following": profile.follows_count,
40
+ "posts": profile.posts_count,
41
+ }
42
+ except Exception as e:
43
+ return {"connected": False, "error": str(e)}
44
+
45
+
46
+ @atproto_mcp.tool
47
+ def post_to_bluesky(text: str) -> dict:
48
+ """Create a new post on Bluesky."""
49
+ try:
50
+ client = get_client()
51
+ post = client.send_post(text=text)
52
+ return {
53
+ "success": True,
54
+ "uri": post.uri,
55
+ "cid": post.cid,
56
+ "text": text,
57
+ "created_at": datetime.now().isoformat(),
58
+ }
59
+ except Exception as e:
60
+ return {"success": False, "error": str(e)}
61
+
62
+
63
+ @atproto_mcp.tool
64
+ def get_timeline(limit: int = 10) -> dict:
65
+ """Get the authenticated user's timeline."""
66
+ try:
67
+ client = get_client()
68
+ timeline = client.get_timeline(limit=limit)
69
+
70
+ posts = []
71
+ for feed_view in timeline.feed:
72
+ post = feed_view.post
73
+ posts.append(
74
+ {
75
+ "author": post.author.handle,
76
+ "text": post.record.text if hasattr(post.record, "text") else None,
77
+ "created_at": post.record.created_at
78
+ if hasattr(post.record, "created_at")
79
+ else None,
80
+ "likes": post.like_count,
81
+ "reposts": post.repost_count,
82
+ "replies": post.reply_count,
83
+ "uri": post.uri,
84
+ }
85
+ )
86
+
87
+ return {
88
+ "success": True,
89
+ "count": len(posts),
90
+ "posts": posts,
91
+ }
92
+ except Exception as e:
93
+ return {"success": False, "error": str(e)}
94
+
95
+
96
+ @atproto_mcp.tool
97
+ def search_posts(query: str, limit: int = 10) -> dict:
98
+ """Search for posts containing specific text."""
99
+ try:
100
+ client = get_client()
101
+ search_results = client.app.bsky.feed.search_posts(
102
+ q=query,
103
+ limit=limit,
104
+ )
105
+
106
+ posts = []
107
+ for post in search_results.posts:
108
+ posts.append(
109
+ {
110
+ "author": post.author.handle,
111
+ "text": post.record.text if hasattr(post.record, "text") else None,
112
+ "created_at": post.record.created_at
113
+ if hasattr(post.record, "created_at")
114
+ else None,
115
+ "likes": post.like_count,
116
+ "reposts": post.repost_count,
117
+ "replies": post.reply_count,
118
+ "uri": post.uri,
119
+ }
120
+ )
121
+
122
+ return {
123
+ "success": True,
124
+ "query": query,
125
+ "count": len(posts),
126
+ "posts": posts,
127
+ }
128
+ except Exception as e:
129
+ return {"success": False, "error": str(e)}
130
+
131
 
132
  @atproto_mcp.tool
133
+ def get_notifications(limit: int = 10) -> dict:
134
+ """Get recent notifications for the authenticated user."""
135
  try:
136
+ client = get_client()
137
+ notifications = client.app.bsky.notification.list_notifications(limit=limit)
138
+
139
+ notifs = []
140
+ for notif in notifications.notifications:
141
+ notifs.append(
142
+ {
143
+ "reason": notif.reason,
144
+ "author": notif.author.handle if notif.author else None,
145
+ "is_read": notif.is_read,
146
+ "created_at": notif.indexed_at,
147
+ "uri": notif.uri,
148
+ }
149
  )
150
+
151
+ return {
152
+ "success": True,
153
+ "count": len(notifs),
154
+ "notifications": notifs,
155
+ }
156
+ except Exception as e:
157
+ return {"success": False, "error": str(e)}
158
+
159
+
160
+ @atproto_mcp.tool
161
+ def follow_user(handle: str) -> dict:
162
+ """Follow a user by their handle."""
163
+ try:
164
+ client = get_client()
165
+ # Resolve handle to DID
166
+ resolved = client.app.bsky.actor.search_actors(q=handle, limit=1)
167
+ if not resolved.actors:
168
+ return {"success": False, "error": f"User {handle} not found"}
169
+
170
+ user_did = resolved.actors[0].did
171
+ follow = client.follow(user_did)
172
+
173
+ return {
174
+ "success": True,
175
+ "followed": handle,
176
+ "did": user_did,
177
+ "uri": follow.uri,
178
+ }
179
+ except Exception as e:
180
+ return {"success": False, "error": str(e)}
181
+
182
+
183
+ @atproto_mcp.tool
184
+ def like_post(uri: str) -> dict:
185
+ """Like a post by its AT URI."""
186
+ try:
187
+ client = get_client()
188
+ like = client.like(uri)
189
+ return {
190
+ "success": True,
191
+ "liked_uri": uri,
192
+ "like_uri": like.uri,
193
+ }
194
+ except Exception as e:
195
+ return {"success": False, "error": str(e)}
196
+
197
+
198
+ @atproto_mcp.tool
199
+ def repost(uri: str) -> dict:
200
+ """Repost a post by its AT URI."""
201
+ try:
202
+ client = get_client()
203
+ repost = client.repost(uri)
204
+ return {
205
+ "success": True,
206
+ "reposted_uri": uri,
207
+ "repost_uri": repost.uri,
208
+ }
209
  except Exception as e:
210
+ return {"success": False, "error": str(e)}