nate nowack commited on
Commit
48f35cb
·
unverified ·
2 Parent(s): 619046fc7cb4fd

Merge pull request #927 from jlowin/bsky-example-create-thread

Browse files
examples/atproto_mcp/README.md CHANGED
@@ -13,6 +13,7 @@ This example demonstrates a FastMCP server that provides tools and resources for
13
  ### Tools (Actions)
14
 
15
  - **post**: Create posts with rich features (text, images, quotes, replies, links, mentions)
 
16
  - **search**: Search for posts by query
17
  - **follow**: Follow users by handle
18
  - **like**: Like posts by URI
@@ -107,6 +108,15 @@ async def demo():
107
  "reply_to": "at://did:plc:xxx/app.bsky.feed.post/yyy",
108
  "links": [{"text": "this article", "url": "https://example.com/article"}]
109
  })
 
 
 
 
 
 
 
 
 
110
  ```
111
 
112
  ## AI Assistant Use Cases
@@ -116,7 +126,7 @@ The unified API enables natural AI assistant interactions:
116
  - **"Reply to that post with these findings"** → Uses `reply_to` with rich text
117
  - **"Share this article with commentary"** → Uses `quote` with the article link
118
  - **"Post this chart with explanation"** → Uses `images` with descriptive text
119
- - **"Start a thread about AI safety"** → Chain multiple posts with `reply_to`
120
 
121
  ## Architecture
122
 
 
13
  ### Tools (Actions)
14
 
15
  - **post**: Create posts with rich features (text, images, quotes, replies, links, mentions)
16
+ - **create_thread**: Post multi-part threads with automatic linking
17
  - **search**: Search for posts by query
18
  - **follow**: Follow users by handle
19
  - **like**: Like posts by URI
 
108
  "reply_to": "at://did:plc:xxx/app.bsky.feed.post/yyy",
109
  "links": [{"text": "this article", "url": "https://example.com/article"}]
110
  })
111
+
112
+ # Create a thread
113
+ await client.call_tool("create_thread", {
114
+ "posts": [
115
+ {"text": "Starting a thread about Python 🧵"},
116
+ {"text": "Python is great for rapid prototyping"},
117
+ {"text": "And the ecosystem is amazing!", "images": ["https://example.com/python.jpg"]}
118
+ ]
119
+ })
120
  ```
121
 
122
  ## AI Assistant Use Cases
 
126
  - **"Reply to that post with these findings"** → Uses `reply_to` with rich text
127
  - **"Share this article with commentary"** → Uses `quote` with the article link
128
  - **"Post this chart with explanation"** → Uses `images` with descriptive text
129
+ - **"Start a thread about AI safety"** → Uses `create_thread` for automatic linking
130
 
131
  ## Architecture
132
 
examples/atproto_mcp/demo.py CHANGED
@@ -185,6 +185,37 @@ async def main(enable_posting: bool = False):
185
  )
186
  if json.loads(result[0].text).get("success"):
187
  print(" ✅ Followed @alternatebuild.dev!")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  else:
189
  print("\n5. Posting capabilities (not enabled):")
190
  print(" To test posting, run with --post flag")
@@ -206,6 +237,7 @@ async def main(enable_posting: bool = False):
206
  print(" • Quote posts")
207
  print(" • Combinations (quote + image, reply + rich text, etc.)")
208
  print(" - search: Search for posts")
 
209
  print(" - follow: Follow users")
210
  print(" - like: Like posts")
211
  print(" - repost: Share posts")
 
185
  )
186
  if json.loads(result[0].text).get("success"):
187
  print(" ✅ Followed @alternatebuild.dev!")
188
+
189
+ # h. Thread creation (new!)
190
+ print("\n h) Creating a thread...")
191
+ result = await client.call_tool(
192
+ "create_thread",
193
+ {
194
+ "posts": [
195
+ {
196
+ "text": "Let me share some thoughts about the ATProto MCP server 🧵"
197
+ },
198
+ {
199
+ "text": "First, it makes posting from the terminal incredibly smooth"
200
+ },
201
+ {
202
+ "text": "The unified post API means one tool handles everything",
203
+ "links": [
204
+ {
205
+ "text": "everything",
206
+ "url": "https://github.com/jlowin/fastmcp",
207
+ }
208
+ ],
209
+ },
210
+ {
211
+ "text": "And now with create_thread, multi-post threads are trivial!"
212
+ },
213
+ ]
214
+ },
215
+ )
216
+ if json.loads(result[0].text).get("success"):
217
+ thread_result = json.loads(result[0].text)
218
+ print(f" ✅ Thread created with {thread_result['post_count']} posts!")
219
  else:
220
  print("\n5. Posting capabilities (not enabled):")
221
  print(" To test posting, run with --post flag")
 
237
  print(" • Quote posts")
238
  print(" • Combinations (quote + image, reply + rich text, etc.)")
239
  print(" - search: Search for posts")
240
+ print(" - create_thread: Post multi-part threads")
241
  print(" - follow: Follow users")
242
  print(" - like: Like posts")
243
  print(" - repost: Share posts")
examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
  """Private ATProto implementation module."""
2
 
3
  from ._client import get_client
4
- from ._posts import create_post
5
  from ._profile import get_profile_info
6
  from ._read import fetch_notifications, fetch_timeline, search_for_posts
7
  from ._social import follow_user_by_handle, like_post_by_uri, repost_by_uri
@@ -10,6 +10,7 @@ __all__ = [
10
  "get_client",
11
  "get_profile_info",
12
  "create_post",
 
13
  "fetch_timeline",
14
  "search_for_posts",
15
  "fetch_notifications",
 
1
  """Private ATProto implementation module."""
2
 
3
  from ._client import get_client
4
+ from ._posts import create_post, create_thread
5
  from ._profile import get_profile_info
6
  from ._read import fetch_notifications, fetch_timeline, search_for_posts
7
  from ._social import follow_user_by_handle, like_post_by_uri, repost_by_uri
 
10
  "get_client",
11
  "get_profile_info",
12
  "create_post",
13
+ "create_thread",
14
  "fetch_timeline",
15
  "search_for_posts",
16
  "fetch_notifications",
examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py CHANGED
@@ -1,10 +1,17 @@
1
  """Unified posting functionality."""
2
 
 
3
  from datetime import datetime
4
 
5
  from atproto import models
6
 
7
- from atproto_mcp.types import PostResult, RichTextLink, RichTextMention
 
 
 
 
 
 
8
 
9
  from ._client import get_client
10
 
@@ -282,3 +289,97 @@ def _send_images(
282
  created_at=datetime.now().isoformat(),
283
  error=None,
284
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Unified posting functionality."""
2
 
3
+ import time
4
  from datetime import datetime
5
 
6
  from atproto import models
7
 
8
+ from atproto_mcp.types import (
9
+ PostResult,
10
+ RichTextLink,
11
+ RichTextMention,
12
+ ThreadPost,
13
+ ThreadResult,
14
+ )
15
 
16
  from ._client import get_client
17
 
 
289
  created_at=datetime.now().isoformat(),
290
  error=None,
291
  )
292
+
293
+
294
+ def create_thread(posts: list[ThreadPost]) -> ThreadResult:
295
+ """Create a thread of posts with automatic linking.
296
+
297
+ Args:
298
+ posts: List of posts to create as a thread. First post is the root.
299
+ """
300
+ if not posts:
301
+ return ThreadResult(
302
+ success=False,
303
+ thread_uri=None,
304
+ post_uris=[],
305
+ post_count=0,
306
+ error="No posts provided",
307
+ )
308
+
309
+ try:
310
+ post_uris = []
311
+ root_uri = None
312
+ parent_uri = None
313
+
314
+ for i, post_data in enumerate(posts):
315
+ # First post is the root
316
+ if i == 0:
317
+ result = create_post(
318
+ text=post_data["text"],
319
+ images=post_data.get("images"),
320
+ image_alts=post_data.get("image_alts"),
321
+ links=post_data.get("links"),
322
+ mentions=post_data.get("mentions"),
323
+ quote=post_data.get("quote"),
324
+ )
325
+
326
+ if not result["success"]:
327
+ return ThreadResult(
328
+ success=False,
329
+ thread_uri=None,
330
+ post_uris=post_uris,
331
+ post_count=len(post_uris),
332
+ error=f"Failed to create root post: {result['error']}",
333
+ )
334
+
335
+ root_uri = result["uri"]
336
+ parent_uri = root_uri
337
+ post_uris.append(root_uri)
338
+
339
+ # Small delay to ensure post is indexed
340
+ time.sleep(0.5)
341
+ else:
342
+ # Subsequent posts reply to the previous one
343
+ result = create_post(
344
+ text=post_data["text"],
345
+ images=post_data.get("images"),
346
+ image_alts=post_data.get("image_alts"),
347
+ links=post_data.get("links"),
348
+ mentions=post_data.get("mentions"),
349
+ quote=post_data.get("quote"),
350
+ reply_to=parent_uri,
351
+ reply_root=root_uri,
352
+ )
353
+
354
+ if not result["success"]:
355
+ return ThreadResult(
356
+ success=False,
357
+ thread_uri=root_uri,
358
+ post_uris=post_uris,
359
+ post_count=len(post_uris),
360
+ error=f"Failed to create post {i + 1}: {result['error']}",
361
+ )
362
+
363
+ parent_uri = result["uri"]
364
+ post_uris.append(parent_uri)
365
+
366
+ # Small delay between posts
367
+ if i < len(posts) - 1:
368
+ time.sleep(0.5)
369
+
370
+ return ThreadResult(
371
+ success=True,
372
+ thread_uri=root_uri,
373
+ post_uris=post_uris,
374
+ post_count=len(post_uris),
375
+ error=None,
376
+ )
377
+
378
+ except Exception as e:
379
+ return ThreadResult(
380
+ success=False,
381
+ thread_uri=None,
382
+ post_uris=post_uris,
383
+ post_count=len(post_uris),
384
+ error=str(e),
385
+ )
examples/atproto_mcp/src/atproto_mcp/server.py CHANGED
@@ -16,6 +16,8 @@ from atproto_mcp.types import (
16
  RichTextLink,
17
  RichTextMention,
18
  SearchResult,
 
 
19
  TimelineResult,
20
  )
21
  from fastmcp import FastMCP
@@ -126,3 +128,27 @@ def search(
126
  ) -> SearchResult:
127
  """Search for posts containing specific text."""
128
  return _atproto.search_for_posts(query, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  RichTextLink,
17
  RichTextMention,
18
  SearchResult,
19
+ ThreadPost,
20
+ ThreadResult,
21
  TimelineResult,
22
  )
23
  from fastmcp import FastMCP
 
128
  ) -> SearchResult:
129
  """Search for posts containing specific text."""
130
  return _atproto.search_for_posts(query, limit)
131
+
132
+
133
+ @atproto_mcp.tool
134
+ def create_thread(
135
+ posts: Annotated[
136
+ list[ThreadPost],
137
+ Field(
138
+ description="List of posts to create as a thread. Each post can have text, images, links, mentions, and quotes."
139
+ ),
140
+ ],
141
+ ) -> ThreadResult:
142
+ """Create a thread of posts with automatic linking.
143
+
144
+ The first post becomes the root of the thread, and each subsequent post
145
+ replies to the previous one, maintaining the thread structure.
146
+
147
+ Example:
148
+ create_thread([
149
+ {"text": "Starting a thread about Python 🧵"},
150
+ {"text": "Python is great for rapid development"},
151
+ {"text": "And the ecosystem is amazing!", "images": ["https://example.com/python.jpg"]}
152
+ ])
153
+ """
154
+ return _atproto.create_thread(posts)
examples/atproto_mcp/src/atproto_mcp/types.py CHANGED
@@ -119,3 +119,24 @@ class RichTextMention(TypedDict):
119
 
120
  handle: str
121
  display_text: str | None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  handle: str
121
  display_text: str | None
122
+
123
+
124
+ class ThreadPost(TypedDict, total=False):
125
+ """A post in a thread."""
126
+
127
+ text: str # Required
128
+ images: list[str] | None
129
+ image_alts: list[str] | None
130
+ links: list[RichTextLink] | None
131
+ mentions: list[RichTextMention] | None
132
+ quote: str | None
133
+
134
+
135
+ class ThreadResult(TypedDict):
136
+ """Result of creating a thread."""
137
+
138
+ success: bool
139
+ thread_uri: str | None # URI of the first post
140
+ post_uris: list[str]
141
+ post_count: int
142
+ error: str | None