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

add `create_thread` tool to bsky MCP server
This commit is contained in:
nate nowack 2025-06-23 12:52:06 -05:00 committed by GitHub
commit 691e047031
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 194 additions and 3 deletions

View file

@ -13,6 +13,7 @@ This example demonstrates a FastMCP server that provides tools and resources for
### Tools (Actions)
- **post**: Create posts with rich features (text, images, quotes, replies, links, mentions)
- **create_thread**: Post multi-part threads with automatic linking
- **search**: Search for posts by query
- **follow**: Follow users by handle
- **like**: Like posts by URI
@ -107,6 +108,15 @@ async def demo():
"reply_to": "at://did:plc:xxx/app.bsky.feed.post/yyy",
"links": [{"text": "this article", "url": "https://example.com/article"}]
})
# Create a thread
await client.call_tool("create_thread", {
"posts": [
{"text": "Starting a thread about Python 🧵"},
{"text": "Python is great for rapid prototyping"},
{"text": "And the ecosystem is amazing!", "images": ["https://example.com/python.jpg"]}
]
})
```
## AI Assistant Use Cases
@ -116,7 +126,7 @@ The unified API enables natural AI assistant interactions:
- **"Reply to that post with these findings"** → Uses `reply_to` with rich text
- **"Share this article with commentary"** → Uses `quote` with the article link
- **"Post this chart with explanation"** → Uses `images` with descriptive text
- **"Start a thread about AI safety"** → Chain multiple posts with `reply_to`
- **"Start a thread about AI safety"** → Uses `create_thread` for automatic linking
## Architecture

View file

@ -185,6 +185,37 @@ async def main(enable_posting: bool = False):
)
if json.loads(result[0].text).get("success"):
print(" ✅ Followed @alternatebuild.dev!")
# h. Thread creation (new!)
print("\n h) Creating a thread...")
result = await client.call_tool(
"create_thread",
{
"posts": [
{
"text": "Let me share some thoughts about the ATProto MCP server 🧵"
},
{
"text": "First, it makes posting from the terminal incredibly smooth"
},
{
"text": "The unified post API means one tool handles everything",
"links": [
{
"text": "everything",
"url": "https://github.com/jlowin/fastmcp",
}
],
},
{
"text": "And now with create_thread, multi-post threads are trivial!"
},
]
},
)
if json.loads(result[0].text).get("success"):
thread_result = json.loads(result[0].text)
print(f" ✅ Thread created with {thread_result['post_count']} posts!")
else:
print("\n5. Posting capabilities (not enabled):")
print(" To test posting, run with --post flag")
@ -206,6 +237,7 @@ async def main(enable_posting: bool = False):
print(" • Quote posts")
print(" • Combinations (quote + image, reply + rich text, etc.)")
print(" - search: Search for posts")
print(" - create_thread: Post multi-part threads")
print(" - follow: Follow users")
print(" - like: Like posts")
print(" - repost: Share posts")

View file

@ -1,7 +1,7 @@
"""Private ATProto implementation module."""
from ._client import get_client
from ._posts import create_post
from ._posts import create_post, create_thread
from ._profile import get_profile_info
from ._read import fetch_notifications, fetch_timeline, search_for_posts
from ._social import follow_user_by_handle, like_post_by_uri, repost_by_uri
@ -10,6 +10,7 @@ __all__ = [
"get_client",
"get_profile_info",
"create_post",
"create_thread",
"fetch_timeline",
"search_for_posts",
"fetch_notifications",

View file

@ -1,10 +1,17 @@
"""Unified posting functionality."""
import time
from datetime import datetime
from atproto import models
from atproto_mcp.types import PostResult, RichTextLink, RichTextMention
from atproto_mcp.types import (
PostResult,
RichTextLink,
RichTextMention,
ThreadPost,
ThreadResult,
)
from ._client import get_client
@ -282,3 +289,97 @@ def _send_images(
created_at=datetime.now().isoformat(),
error=None,
)
def create_thread(posts: list[ThreadPost]) -> ThreadResult:
"""Create a thread of posts with automatic linking.
Args:
posts: List of posts to create as a thread. First post is the root.
"""
if not posts:
return ThreadResult(
success=False,
thread_uri=None,
post_uris=[],
post_count=0,
error="No posts provided",
)
try:
post_uris = []
root_uri = None
parent_uri = None
for i, post_data in enumerate(posts):
# First post is the root
if i == 0:
result = create_post(
text=post_data["text"],
images=post_data.get("images"),
image_alts=post_data.get("image_alts"),
links=post_data.get("links"),
mentions=post_data.get("mentions"),
quote=post_data.get("quote"),
)
if not result["success"]:
return ThreadResult(
success=False,
thread_uri=None,
post_uris=post_uris,
post_count=len(post_uris),
error=f"Failed to create root post: {result['error']}",
)
root_uri = result["uri"]
parent_uri = root_uri
post_uris.append(root_uri)
# Small delay to ensure post is indexed
time.sleep(0.5)
else:
# Subsequent posts reply to the previous one
result = create_post(
text=post_data["text"],
images=post_data.get("images"),
image_alts=post_data.get("image_alts"),
links=post_data.get("links"),
mentions=post_data.get("mentions"),
quote=post_data.get("quote"),
reply_to=parent_uri,
reply_root=root_uri,
)
if not result["success"]:
return ThreadResult(
success=False,
thread_uri=root_uri,
post_uris=post_uris,
post_count=len(post_uris),
error=f"Failed to create post {i + 1}: {result['error']}",
)
parent_uri = result["uri"]
post_uris.append(parent_uri)
# Small delay between posts
if i < len(posts) - 1:
time.sleep(0.5)
return ThreadResult(
success=True,
thread_uri=root_uri,
post_uris=post_uris,
post_count=len(post_uris),
error=None,
)
except Exception as e:
return ThreadResult(
success=False,
thread_uri=None,
post_uris=post_uris,
post_count=len(post_uris),
error=str(e),
)

View file

@ -16,6 +16,8 @@ from atproto_mcp.types import (
RichTextLink,
RichTextMention,
SearchResult,
ThreadPost,
ThreadResult,
TimelineResult,
)
from fastmcp import FastMCP
@ -126,3 +128,27 @@ def search(
) -> SearchResult:
"""Search for posts containing specific text."""
return _atproto.search_for_posts(query, limit)
@atproto_mcp.tool
def create_thread(
posts: Annotated[
list[ThreadPost],
Field(
description="List of posts to create as a thread. Each post can have text, images, links, mentions, and quotes."
),
],
) -> ThreadResult:
"""Create a thread of posts with automatic linking.
The first post becomes the root of the thread, and each subsequent post
replies to the previous one, maintaining the thread structure.
Example:
create_thread([
{"text": "Starting a thread about Python 🧵"},
{"text": "Python is great for rapid development"},
{"text": "And the ecosystem is amazing!", "images": ["https://example.com/python.jpg"]}
])
"""
return _atproto.create_thread(posts)

View file

@ -119,3 +119,24 @@ class RichTextMention(TypedDict):
handle: str
display_text: str | None
class ThreadPost(TypedDict, total=False):
"""A post in a thread."""
text: str # Required
images: list[str] | None
image_alts: list[str] | None
links: list[RichTextLink] | None
mentions: list[RichTextMention] | None
quote: str | None
class ThreadResult(TypedDict):
"""Result of creating a thread."""
success: bool
thread_uri: str | None # URI of the first post
post_uris: list[str]
post_count: int
error: str | None