Spaces:
Running
Running
zzstoatzz commited on
Commit ·
bbbf830
1
Parent(s): 619046f
thread
Browse files- examples/atproto_mcp/README.md +11 -1
- examples/atproto_mcp/demo.py +32 -0
- examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py +2 -1
- examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py +94 -1
- examples/atproto_mcp/src/atproto_mcp/server.py +26 -0
- examples/atproto_mcp/src/atproto_mcp/types.py +21 -0
- examples/atproto_mcp/uv.lock +0 -0
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"** →
|
| 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
|
@@ -4,7 +4,13 @@ from datetime import datetime
|
|
| 4 |
|
| 5 |
from atproto import models
|
| 6 |
|
| 7 |
-
from atproto_mcp.types import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
from ._client import get_client
|
| 10 |
|
|
@@ -282,3 +288,90 @@ def _send_images(
|
|
| 282 |
created_at=datetime.now().isoformat(),
|
| 283 |
error=None,
|
| 284 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
from atproto import models
|
| 6 |
|
| 7 |
+
from atproto_mcp.types import (
|
| 8 |
+
PostResult,
|
| 9 |
+
RichTextLink,
|
| 10 |
+
RichTextMention,
|
| 11 |
+
ThreadPost,
|
| 12 |
+
ThreadResult,
|
| 13 |
+
)
|
| 14 |
|
| 15 |
from ._client import get_client
|
| 16 |
|
|
|
|
| 288 |
created_at=datetime.now().isoformat(),
|
| 289 |
error=None,
|
| 290 |
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def create_thread(posts: list[ThreadPost]) -> ThreadResult:
|
| 294 |
+
"""Create a thread of posts with automatic linking.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
posts: List of posts to create as a thread. First post is the root.
|
| 298 |
+
"""
|
| 299 |
+
if not posts:
|
| 300 |
+
return ThreadResult(
|
| 301 |
+
success=False,
|
| 302 |
+
thread_uri=None,
|
| 303 |
+
post_uris=[],
|
| 304 |
+
post_count=0,
|
| 305 |
+
error="No posts provided",
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
try:
|
| 309 |
+
post_uris = []
|
| 310 |
+
root_uri = None
|
| 311 |
+
parent_uri = None
|
| 312 |
+
|
| 313 |
+
for i, post_data in enumerate(posts):
|
| 314 |
+
# First post is the root
|
| 315 |
+
if i == 0:
|
| 316 |
+
result = create_post(
|
| 317 |
+
text=post_data["text"],
|
| 318 |
+
images=post_data.get("images"),
|
| 319 |
+
image_alts=post_data.get("image_alts"),
|
| 320 |
+
links=post_data.get("links"),
|
| 321 |
+
mentions=post_data.get("mentions"),
|
| 322 |
+
quote=post_data.get("quote"),
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
if not result["success"]:
|
| 326 |
+
return ThreadResult(
|
| 327 |
+
success=False,
|
| 328 |
+
thread_uri=None,
|
| 329 |
+
post_uris=post_uris,
|
| 330 |
+
post_count=len(post_uris),
|
| 331 |
+
error=f"Failed to create root post: {result['error']}",
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
root_uri = result["uri"]
|
| 335 |
+
parent_uri = root_uri
|
| 336 |
+
post_uris.append(root_uri)
|
| 337 |
+
else:
|
| 338 |
+
# Subsequent posts reply to the previous one
|
| 339 |
+
result = create_post(
|
| 340 |
+
text=post_data["text"],
|
| 341 |
+
images=post_data.get("images"),
|
| 342 |
+
image_alts=post_data.get("image_alts"),
|
| 343 |
+
links=post_data.get("links"),
|
| 344 |
+
mentions=post_data.get("mentions"),
|
| 345 |
+
quote=post_data.get("quote"),
|
| 346 |
+
reply_to=parent_uri,
|
| 347 |
+
reply_root=root_uri,
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
if not result["success"]:
|
| 351 |
+
return ThreadResult(
|
| 352 |
+
success=False,
|
| 353 |
+
thread_uri=root_uri,
|
| 354 |
+
post_uris=post_uris,
|
| 355 |
+
post_count=len(post_uris),
|
| 356 |
+
error=f"Failed to create post {i + 1}: {result['error']}",
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
parent_uri = result["uri"]
|
| 360 |
+
post_uris.append(parent_uri)
|
| 361 |
+
|
| 362 |
+
return ThreadResult(
|
| 363 |
+
success=True,
|
| 364 |
+
thread_uri=root_uri,
|
| 365 |
+
post_uris=post_uris,
|
| 366 |
+
post_count=len(post_uris),
|
| 367 |
+
error=None,
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
except Exception as e:
|
| 371 |
+
return ThreadResult(
|
| 372 |
+
success=False,
|
| 373 |
+
thread_uri=None,
|
| 374 |
+
post_uris=post_uris,
|
| 375 |
+
post_count=len(post_uris),
|
| 376 |
+
error=str(e),
|
| 377 |
+
)
|
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
|
examples/atproto_mcp/uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|