Spaces:
Running
Running
Merge pull request #916 from jlowin/atproto-example
Browse files- examples/atproto_mcp/README.md +146 -0
- examples/atproto_mcp/demo.py +225 -0
- examples/atproto_mcp/pyproject.toml +27 -0
- examples/atproto_mcp/src/atproto_mcp/__init__.py +3 -0
- examples/atproto_mcp/src/atproto_mcp/__main__.py +9 -0
- examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py +19 -0
- examples/atproto_mcp/src/atproto_mcp/_atproto/_client.py +16 -0
- examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py +284 -0
- examples/atproto_mcp/src/atproto_mcp/_atproto/_profile.py +33 -0
- examples/atproto_mcp/src/atproto_mcp/_atproto/_read.py +124 -0
- examples/atproto_mcp/src/atproto_mcp/_atproto/_social.py +108 -0
- examples/atproto_mcp/src/atproto_mcp/py.typed +0 -0
- examples/atproto_mcp/src/atproto_mcp/server.py +128 -0
- examples/atproto_mcp/src/atproto_mcp/settings.py +17 -0
- examples/atproto_mcp/src/atproto_mcp/types.py +121 -0
examples/atproto_mcp/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ATProto MCP Server
|
| 2 |
+
|
| 3 |
+
This example demonstrates a FastMCP server that provides tools and resources for interacting with the AT Protocol (Bluesky).
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
### Resources (Read-only)
|
| 8 |
+
|
| 9 |
+
- **atproto://profile/status**: Get connection status and profile information
|
| 10 |
+
- **atproto://timeline**: Retrieve your timeline feed
|
| 11 |
+
- **atproto://notifications**: Get recent notifications
|
| 12 |
+
|
| 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
|
| 19 |
+
- **repost**: Share posts by URI
|
| 20 |
+
|
| 21 |
+
## Setup
|
| 22 |
+
|
| 23 |
+
1. Create a `.env` file in the root directory with your Bluesky credentials:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
ATPROTO_HANDLE=your.handle@bsky.social
|
| 27 |
+
ATPROTO_PASSWORD=your-app-password
|
| 28 |
+
ATPROTO_PDS_URL=https://bsky.social # optional, defaults to bsky.social
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
2. Install and run the server:
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# Install dependencies
|
| 35 |
+
uv pip install -e .
|
| 36 |
+
|
| 37 |
+
# Run the server
|
| 38 |
+
uv run atproto-mcp
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## The Unified Post Tool
|
| 42 |
+
|
| 43 |
+
The `post` tool is a single, flexible interface for all posting needs:
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
async def post(
|
| 47 |
+
text: str, # Required: Post content
|
| 48 |
+
images: list[str] = None, # Optional: Image URLs (max 4)
|
| 49 |
+
image_alts: list[str] = None, # Optional: Alt text for images
|
| 50 |
+
links: list[RichTextLink] = None, # Optional: Embedded links
|
| 51 |
+
mentions: list[RichTextMention] = None, # Optional: User mentions
|
| 52 |
+
reply_to: str = None, # Optional: Reply to post URI
|
| 53 |
+
reply_root: str = None, # Optional: Thread root URI
|
| 54 |
+
quote: str = None, # Optional: Quote post URI
|
| 55 |
+
)
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### Usage Examples
|
| 59 |
+
|
| 60 |
+
```python
|
| 61 |
+
from fastmcp import Client
|
| 62 |
+
from atproto_mcp.server import atproto_mcp
|
| 63 |
+
|
| 64 |
+
async def demo():
|
| 65 |
+
async with Client(atproto_mcp) as client:
|
| 66 |
+
# Simple post
|
| 67 |
+
await client.call_tool("post", {
|
| 68 |
+
"text": "Hello from FastMCP!"
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
# Post with image
|
| 72 |
+
await client.call_tool("post", {
|
| 73 |
+
"text": "Beautiful sunset! 🌅",
|
| 74 |
+
"images": ["https://example.com/sunset.jpg"],
|
| 75 |
+
"image_alts": ["Sunset over the ocean"]
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
# Reply to a post
|
| 79 |
+
await client.call_tool("post", {
|
| 80 |
+
"text": "Great point!",
|
| 81 |
+
"reply_to": "at://did:plc:xxx/app.bsky.feed.post/yyy"
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
# Quote post
|
| 85 |
+
await client.call_tool("post", {
|
| 86 |
+
"text": "This is important:",
|
| 87 |
+
"quote": "at://did:plc:xxx/app.bsky.feed.post/yyy"
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
# Rich text with links and mentions
|
| 91 |
+
await client.call_tool("post", {
|
| 92 |
+
"text": "Check out FastMCP by @alternatebuild.dev",
|
| 93 |
+
"links": [{"text": "FastMCP", "url": "https://github.com/jlowin/fastmcp"}],
|
| 94 |
+
"mentions": [{"handle": "alternatebuild.dev", "display_text": "@alternatebuild.dev"}]
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
# Advanced: Quote with image
|
| 98 |
+
await client.call_tool("post", {
|
| 99 |
+
"text": "Adding visual context:",
|
| 100 |
+
"quote": "at://did:plc:xxx/app.bsky.feed.post/yyy",
|
| 101 |
+
"images": ["https://example.com/chart.png"]
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
# Advanced: Reply with rich text
|
| 105 |
+
await client.call_tool("post", {
|
| 106 |
+
"text": "I agree! See this article for more info",
|
| 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
|
| 113 |
+
|
| 114 |
+
The unified API enables natural AI assistant interactions:
|
| 115 |
+
|
| 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 |
+
|
| 123 |
+
The server is organized as:
|
| 124 |
+
- `server.py` - Public API with resources and tools
|
| 125 |
+
- `_atproto/` - Private implementation module
|
| 126 |
+
- `_client.py` - ATProto client management
|
| 127 |
+
- `_posts.py` - Unified posting logic
|
| 128 |
+
- `_profile.py` - Profile operations
|
| 129 |
+
- `_read.py` - Timeline, search, notifications
|
| 130 |
+
- `_social.py` - Follow, like, repost
|
| 131 |
+
- `types.py` - TypedDict definitions
|
| 132 |
+
- `settings.py` - Configuration management
|
| 133 |
+
|
| 134 |
+
## Running the Demo
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
# Run demo (read-only)
|
| 138 |
+
uv run python demo.py
|
| 139 |
+
|
| 140 |
+
# Run demo with posting enabled
|
| 141 |
+
uv run python demo.py --post
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
## Security Note
|
| 145 |
+
|
| 146 |
+
Store your Bluesky credentials securely in environment variables. Never commit credentials to version control.
|
examples/atproto_mcp/demo.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Demo script showing all ATProto MCP server capabilities."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
from typing import cast
|
| 7 |
+
|
| 8 |
+
from atproto_mcp.server import atproto_mcp
|
| 9 |
+
from atproto_mcp.types import (
|
| 10 |
+
NotificationsResult,
|
| 11 |
+
PostResult,
|
| 12 |
+
ProfileInfo,
|
| 13 |
+
SearchResult,
|
| 14 |
+
TimelineResult,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
from fastmcp import Client
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def main(enable_posting: bool = False):
|
| 21 |
+
print("🔵 ATProto MCP Server Demo\n")
|
| 22 |
+
|
| 23 |
+
async with Client(atproto_mcp) as client:
|
| 24 |
+
# 1. Check connection status (resource)
|
| 25 |
+
print("1. Checking connection status...")
|
| 26 |
+
result = await client.read_resource("atproto://profile/status")
|
| 27 |
+
status: ProfileInfo = (
|
| 28 |
+
json.loads(result[0].text) if result else cast(ProfileInfo, {})
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
if status.get("connected"):
|
| 32 |
+
print(f"✅ Connected as: @{status['handle']}")
|
| 33 |
+
print(f" Followers: {status['followers']}")
|
| 34 |
+
print(f" Following: {status['following']}")
|
| 35 |
+
print(f" Posts: {status['posts']}")
|
| 36 |
+
else:
|
| 37 |
+
print(f"❌ Connection failed: {status.get('error')}")
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
# 2. Get timeline
|
| 41 |
+
print("\n2. Getting timeline...")
|
| 42 |
+
result = await client.read_resource("atproto://timeline")
|
| 43 |
+
timeline: TimelineResult = (
|
| 44 |
+
json.loads(result[0].text) if result else cast(TimelineResult, {})
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
if timeline.get("success") and timeline["posts"]:
|
| 48 |
+
print(f"✅ Found {timeline['count']} posts")
|
| 49 |
+
post = timeline["posts"][0]
|
| 50 |
+
print(f" Latest by @{post['author']}: {post['text'][:80]}...")
|
| 51 |
+
save_uri = post["uri"] # Save for later interactions
|
| 52 |
+
else:
|
| 53 |
+
print("❌ No posts in timeline")
|
| 54 |
+
save_uri = None
|
| 55 |
+
|
| 56 |
+
# 3. Search for posts
|
| 57 |
+
print("\n3. Searching for posts about 'Bluesky'...")
|
| 58 |
+
result = await client.call_tool("search", {"query": "Bluesky", "limit": 5})
|
| 59 |
+
search: SearchResult = (
|
| 60 |
+
json.loads(result[0].text) if result else cast(SearchResult, {})
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
if search.get("success") and search["posts"]:
|
| 64 |
+
print(f"✅ Found {search['count']} posts")
|
| 65 |
+
print(f" Sample: {search['posts'][0]['text'][:80]}...")
|
| 66 |
+
|
| 67 |
+
# 4. Get notifications
|
| 68 |
+
print("\n4. Checking notifications...")
|
| 69 |
+
result = await client.read_resource("atproto://notifications")
|
| 70 |
+
notifs: NotificationsResult = (
|
| 71 |
+
json.loads(result[0].text) if result else cast(NotificationsResult, {})
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
if notifs.get("success"):
|
| 75 |
+
print(f"✅ You have {notifs['count']} notifications")
|
| 76 |
+
unread = sum(1 for n in notifs["notifications"] if not n["is_read"])
|
| 77 |
+
if unread:
|
| 78 |
+
print(f" ({unread} unread)")
|
| 79 |
+
|
| 80 |
+
# 5. Demo posting capabilities
|
| 81 |
+
if enable_posting:
|
| 82 |
+
print("\n5. Demonstrating posting capabilities...")
|
| 83 |
+
|
| 84 |
+
# a. Simple post
|
| 85 |
+
print("\n a) Creating a simple post...")
|
| 86 |
+
result = await client.call_tool(
|
| 87 |
+
"post",
|
| 88 |
+
{"text": "🧪 Testing the unified ATProto MCP post tool! #FastMCP"},
|
| 89 |
+
)
|
| 90 |
+
post_result: PostResult = json.loads(result[0].text) if result else {}
|
| 91 |
+
if post_result.get("success"):
|
| 92 |
+
print(" ✅ Posted successfully!")
|
| 93 |
+
simple_uri = post_result["uri"]
|
| 94 |
+
else:
|
| 95 |
+
print(f" ❌ Failed: {post_result.get('error')}")
|
| 96 |
+
simple_uri = None
|
| 97 |
+
|
| 98 |
+
# b. Post with rich text (link and mention)
|
| 99 |
+
print("\n b) Creating a post with rich text...")
|
| 100 |
+
result = await client.call_tool(
|
| 101 |
+
"post",
|
| 102 |
+
{
|
| 103 |
+
"text": "Check out FastMCP and follow @alternatebuild.dev for updates!",
|
| 104 |
+
"links": [
|
| 105 |
+
{"text": "FastMCP", "url": "https://github.com/jlowin/fastmcp"}
|
| 106 |
+
],
|
| 107 |
+
"mentions": [
|
| 108 |
+
{
|
| 109 |
+
"handle": "alternatebuild.dev",
|
| 110 |
+
"display_text": "@alternatebuild.dev",
|
| 111 |
+
}
|
| 112 |
+
],
|
| 113 |
+
},
|
| 114 |
+
)
|
| 115 |
+
if json.loads(result[0].text).get("success"):
|
| 116 |
+
print(" ✅ Rich text post created!")
|
| 117 |
+
|
| 118 |
+
# c. Reply to a post
|
| 119 |
+
if save_uri:
|
| 120 |
+
print("\n c) Replying to a post...")
|
| 121 |
+
result = await client.call_tool(
|
| 122 |
+
"post", {"text": "Great post! 👍", "reply_to": save_uri}
|
| 123 |
+
)
|
| 124 |
+
if json.loads(result[0].text).get("success"):
|
| 125 |
+
print(" ✅ Reply posted!")
|
| 126 |
+
|
| 127 |
+
# d. Quote post
|
| 128 |
+
if simple_uri:
|
| 129 |
+
print("\n d) Creating a quote post...")
|
| 130 |
+
result = await client.call_tool(
|
| 131 |
+
"post",
|
| 132 |
+
{
|
| 133 |
+
"text": "Quoting my own test post for demo purposes 🔄",
|
| 134 |
+
"quote": simple_uri,
|
| 135 |
+
},
|
| 136 |
+
)
|
| 137 |
+
if json.loads(result[0].text).get("success"):
|
| 138 |
+
print(" ✅ Quote post created!")
|
| 139 |
+
|
| 140 |
+
# e. Post with image
|
| 141 |
+
print("\n e) Creating a post with image...")
|
| 142 |
+
result = await client.call_tool(
|
| 143 |
+
"post",
|
| 144 |
+
{
|
| 145 |
+
"text": "Here's a test image post! 📸",
|
| 146 |
+
"images": ["https://picsum.photos/400/300"],
|
| 147 |
+
"image_alts": ["Random test image"],
|
| 148 |
+
},
|
| 149 |
+
)
|
| 150 |
+
if json.loads(result[0].text).get("success"):
|
| 151 |
+
print(" ✅ Image post created!")
|
| 152 |
+
|
| 153 |
+
# f. Quote with image (advanced)
|
| 154 |
+
if simple_uri:
|
| 155 |
+
print("\n f) Creating a quote post with image...")
|
| 156 |
+
result = await client.call_tool(
|
| 157 |
+
"post",
|
| 158 |
+
{
|
| 159 |
+
"text": "Quote + image combo! 🎨",
|
| 160 |
+
"quote": simple_uri,
|
| 161 |
+
"images": ["https://picsum.photos/300/200"],
|
| 162 |
+
"image_alts": ["Another test image"],
|
| 163 |
+
},
|
| 164 |
+
)
|
| 165 |
+
if json.loads(result[0].text).get("success"):
|
| 166 |
+
print(" ✅ Quote with image created!")
|
| 167 |
+
|
| 168 |
+
# g. Social actions
|
| 169 |
+
if save_uri:
|
| 170 |
+
print("\n g) Demonstrating social actions...")
|
| 171 |
+
|
| 172 |
+
# Like
|
| 173 |
+
result = await client.call_tool("like", {"uri": save_uri})
|
| 174 |
+
if json.loads(result[0].text).get("success"):
|
| 175 |
+
print(" ✅ Liked a post!")
|
| 176 |
+
|
| 177 |
+
# Repost
|
| 178 |
+
result = await client.call_tool("repost", {"uri": save_uri})
|
| 179 |
+
if json.loads(result[0].text).get("success"):
|
| 180 |
+
print(" ✅ Reposted!")
|
| 181 |
+
|
| 182 |
+
# Follow
|
| 183 |
+
result = await client.call_tool(
|
| 184 |
+
"follow", {"handle": "alternatebuild.dev"}
|
| 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")
|
| 191 |
+
print(" Example: python demo.py --post")
|
| 192 |
+
|
| 193 |
+
# 6. Show available capabilities
|
| 194 |
+
print("\n6. Available capabilities:")
|
| 195 |
+
print("\n Resources (read-only):")
|
| 196 |
+
print(" - atproto://profile/status")
|
| 197 |
+
print(" - atproto://timeline")
|
| 198 |
+
print(" - atproto://notifications")
|
| 199 |
+
|
| 200 |
+
print("\n Tools (actions):")
|
| 201 |
+
print(" - post: Unified posting with rich features")
|
| 202 |
+
print(" • Simple text posts")
|
| 203 |
+
print(" • Images (up to 4)")
|
| 204 |
+
print(" • Rich text (links, mentions)")
|
| 205 |
+
print(" • Replies and threads")
|
| 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")
|
| 212 |
+
|
| 213 |
+
print("\n✨ Demo complete!")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
if __name__ == "__main__":
|
| 217 |
+
parser = argparse.ArgumentParser(description="ATProto MCP Server Demo")
|
| 218 |
+
parser.add_argument(
|
| 219 |
+
"--post",
|
| 220 |
+
action="store_true",
|
| 221 |
+
help="Enable posting test messages to Bluesky",
|
| 222 |
+
)
|
| 223 |
+
args = parser.parse_args()
|
| 224 |
+
|
| 225 |
+
asyncio.run(main(enable_posting=args.post))
|
examples/atproto_mcp/pyproject.toml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "atproto-mcp"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
authors = [{ name = "zzstoatzz", email = "thrast36@gmail.com" }]
|
| 7 |
+
requires-python = ">=3.10"
|
| 8 |
+
dependencies = [
|
| 9 |
+
"fastmcp>=0.8.0",
|
| 10 |
+
"atproto@git+https://github.com/MarshalX/atproto.git@refs/pull/605/head",
|
| 11 |
+
"pydantic-settings>=2.0.0",
|
| 12 |
+
"websockets>=15.0.1",
|
| 13 |
+
"httpx>=0.27.0",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
[project.scripts]
|
| 17 |
+
atproto-mcp = "atproto_mcp.__main__:main"
|
| 18 |
+
|
| 19 |
+
[build-system]
|
| 20 |
+
requires = ["hatchling"]
|
| 21 |
+
build-backend = "hatchling.build"
|
| 22 |
+
|
| 23 |
+
[tool.hatch.metadata]
|
| 24 |
+
allow-direct-references = true
|
| 25 |
+
|
| 26 |
+
[tool.uv.sources]
|
| 27 |
+
fastmcp = { workspace = true }
|
examples/atproto_mcp/src/atproto_mcp/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from atproto_mcp.settings import settings
|
| 2 |
+
|
| 3 |
+
__all__ = ["settings"]
|
examples/atproto_mcp/src/atproto_mcp/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from atproto_mcp.server import atproto_mcp
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def main():
|
| 5 |
+
atproto_mcp.run()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
if __name__ == "__main__":
|
| 9 |
+
main()
|
examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"get_client",
|
| 11 |
+
"get_profile_info",
|
| 12 |
+
"create_post",
|
| 13 |
+
"fetch_timeline",
|
| 14 |
+
"search_for_posts",
|
| 15 |
+
"fetch_notifications",
|
| 16 |
+
"follow_user_by_handle",
|
| 17 |
+
"like_post_by_uri",
|
| 18 |
+
"repost_by_uri",
|
| 19 |
+
]
|
examples/atproto_mcp/src/atproto_mcp/_atproto/_client.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ATProto client management."""
|
| 2 |
+
|
| 3 |
+
from atproto import Client
|
| 4 |
+
|
| 5 |
+
from atproto_mcp.settings import settings
|
| 6 |
+
|
| 7 |
+
_client: Client | None = None
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_client() -> Client:
|
| 11 |
+
"""Get or create an authenticated ATProto client."""
|
| 12 |
+
global _client
|
| 13 |
+
if _client is None:
|
| 14 |
+
_client = Client()
|
| 15 |
+
_client.login(settings.atproto_handle, settings.atproto_password)
|
| 16 |
+
return _client
|
examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 11 |
+
|
| 12 |
+
def create_post(
|
| 13 |
+
text: str,
|
| 14 |
+
images: list[str] | None = None,
|
| 15 |
+
image_alts: list[str] | None = None,
|
| 16 |
+
links: list[RichTextLink] | None = None,
|
| 17 |
+
mentions: list[RichTextMention] | None = None,
|
| 18 |
+
reply_to: str | None = None,
|
| 19 |
+
reply_root: str | None = None,
|
| 20 |
+
quote: str | None = None,
|
| 21 |
+
) -> PostResult:
|
| 22 |
+
"""Create a unified post with optional features.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
text: Post text (max 300 chars)
|
| 26 |
+
images: URLs of images to attach (max 4)
|
| 27 |
+
image_alts: Alt text for images
|
| 28 |
+
links: Links to embed in rich text
|
| 29 |
+
mentions: User mentions to embed
|
| 30 |
+
reply_to: URI of post to reply to
|
| 31 |
+
reply_root: URI of thread root (defaults to reply_to)
|
| 32 |
+
quote: URI of post to quote
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
client = get_client()
|
| 36 |
+
facets = []
|
| 37 |
+
embed = None
|
| 38 |
+
reply_ref = None
|
| 39 |
+
|
| 40 |
+
# Handle rich text facets (links and mentions)
|
| 41 |
+
if links or mentions:
|
| 42 |
+
facets = _build_facets(text, links, mentions, client)
|
| 43 |
+
|
| 44 |
+
# Handle replies
|
| 45 |
+
if reply_to:
|
| 46 |
+
reply_ref = _build_reply_ref(reply_to, reply_root, client)
|
| 47 |
+
|
| 48 |
+
# Handle quotes and images
|
| 49 |
+
if quote and images:
|
| 50 |
+
# Quote with images - create record with media embed
|
| 51 |
+
embed = _build_quote_with_images_embed(quote, images, image_alts, client)
|
| 52 |
+
elif quote:
|
| 53 |
+
# Quote only
|
| 54 |
+
embed = _build_quote_embed(quote, client)
|
| 55 |
+
elif images:
|
| 56 |
+
# Images only - use send_images for proper handling
|
| 57 |
+
return _send_images(text, images, image_alts, facets, reply_ref, client)
|
| 58 |
+
|
| 59 |
+
# Send the post
|
| 60 |
+
post = client.send_post(
|
| 61 |
+
text=text,
|
| 62 |
+
facets=facets if facets else None,
|
| 63 |
+
embed=embed,
|
| 64 |
+
reply_to=reply_ref,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return PostResult(
|
| 68 |
+
success=True,
|
| 69 |
+
uri=post.uri,
|
| 70 |
+
cid=post.cid,
|
| 71 |
+
text=text,
|
| 72 |
+
created_at=datetime.now().isoformat(),
|
| 73 |
+
error=None,
|
| 74 |
+
)
|
| 75 |
+
except Exception as e:
|
| 76 |
+
return PostResult(
|
| 77 |
+
success=False,
|
| 78 |
+
uri=None,
|
| 79 |
+
cid=None,
|
| 80 |
+
text=None,
|
| 81 |
+
created_at=None,
|
| 82 |
+
error=str(e),
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _build_facets(
|
| 87 |
+
text: str,
|
| 88 |
+
links: list[RichTextLink] | None,
|
| 89 |
+
mentions: list[RichTextMention] | None,
|
| 90 |
+
client,
|
| 91 |
+
):
|
| 92 |
+
"""Build facets for rich text formatting."""
|
| 93 |
+
facets = []
|
| 94 |
+
|
| 95 |
+
# Process links
|
| 96 |
+
if links:
|
| 97 |
+
for link in links:
|
| 98 |
+
start = text.find(link["text"])
|
| 99 |
+
if start == -1:
|
| 100 |
+
continue
|
| 101 |
+
end = start + len(link["text"])
|
| 102 |
+
|
| 103 |
+
facets.append(
|
| 104 |
+
models.AppBskyRichtextFacet.Main(
|
| 105 |
+
features=[models.AppBskyRichtextFacet.Link(uri=link["url"])],
|
| 106 |
+
index=models.AppBskyRichtextFacet.ByteSlice(
|
| 107 |
+
byte_start=len(text[:start].encode("UTF-8")),
|
| 108 |
+
byte_end=len(text[:end].encode("UTF-8")),
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Process mentions
|
| 114 |
+
if mentions:
|
| 115 |
+
for mention in mentions:
|
| 116 |
+
display_text = mention.get("display_text") or f"@{mention['handle']}"
|
| 117 |
+
start = text.find(display_text)
|
| 118 |
+
if start == -1:
|
| 119 |
+
continue
|
| 120 |
+
end = start + len(display_text)
|
| 121 |
+
|
| 122 |
+
# Resolve handle to DID
|
| 123 |
+
resolved = client.app.bsky.actor.search_actors(
|
| 124 |
+
params={"q": mention["handle"], "limit": 1}
|
| 125 |
+
)
|
| 126 |
+
if not resolved.actors:
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
did = resolved.actors[0].did
|
| 130 |
+
facets.append(
|
| 131 |
+
models.AppBskyRichtextFacet.Main(
|
| 132 |
+
features=[models.AppBskyRichtextFacet.Mention(did=did)],
|
| 133 |
+
index=models.AppBskyRichtextFacet.ByteSlice(
|
| 134 |
+
byte_start=len(text[:start].encode("UTF-8")),
|
| 135 |
+
byte_end=len(text[:end].encode("UTF-8")),
|
| 136 |
+
),
|
| 137 |
+
)
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
return facets
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _build_reply_ref(reply_to: str, reply_root: str | None, client):
|
| 144 |
+
"""Build reply reference."""
|
| 145 |
+
# Get parent post to extract CID
|
| 146 |
+
parent_post = client.app.bsky.feed.get_posts(params={"uris": [reply_to]})
|
| 147 |
+
if not parent_post.posts:
|
| 148 |
+
raise ValueError("Parent post not found")
|
| 149 |
+
|
| 150 |
+
parent_cid = parent_post.posts[0].cid
|
| 151 |
+
parent_ref = models.ComAtprotoRepoStrongRef.Main(uri=reply_to, cid=parent_cid)
|
| 152 |
+
|
| 153 |
+
# If no root_uri provided, parent is the root
|
| 154 |
+
if reply_root is None:
|
| 155 |
+
root_ref = parent_ref
|
| 156 |
+
else:
|
| 157 |
+
# Get root post CID
|
| 158 |
+
root_post = client.app.bsky.feed.get_posts(params={"uris": [reply_root]})
|
| 159 |
+
if not root_post.posts:
|
| 160 |
+
raise ValueError("Root post not found")
|
| 161 |
+
root_cid = root_post.posts[0].cid
|
| 162 |
+
root_ref = models.ComAtprotoRepoStrongRef.Main(uri=reply_root, cid=root_cid)
|
| 163 |
+
|
| 164 |
+
return models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _build_quote_embed(quote_uri: str, client):
|
| 168 |
+
"""Build quote embed."""
|
| 169 |
+
# Get the post to quote
|
| 170 |
+
quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]})
|
| 171 |
+
if not quoted_post.posts:
|
| 172 |
+
raise ValueError("Quoted post not found")
|
| 173 |
+
|
| 174 |
+
# Create strong ref for the quoted post
|
| 175 |
+
quoted_cid = quoted_post.posts[0].cid
|
| 176 |
+
quoted_ref = models.ComAtprotoRepoStrongRef.Main(uri=quote_uri, cid=quoted_cid)
|
| 177 |
+
|
| 178 |
+
# Create the embed
|
| 179 |
+
return models.AppBskyEmbedRecord.Main(record=quoted_ref)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _build_quote_with_images_embed(
|
| 183 |
+
quote_uri: str, image_urls: list[str], image_alts: list[str] | None, client
|
| 184 |
+
):
|
| 185 |
+
"""Build quote embed with images."""
|
| 186 |
+
import httpx
|
| 187 |
+
|
| 188 |
+
# Get the quoted post
|
| 189 |
+
quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]})
|
| 190 |
+
if not quoted_post.posts:
|
| 191 |
+
raise ValueError("Quoted post not found")
|
| 192 |
+
|
| 193 |
+
quoted_cid = quoted_post.posts[0].cid
|
| 194 |
+
quoted_ref = models.ComAtprotoRepoStrongRef.Main(uri=quote_uri, cid=quoted_cid)
|
| 195 |
+
|
| 196 |
+
# Download and upload images
|
| 197 |
+
images = []
|
| 198 |
+
alts = image_alts or [""] * len(image_urls)
|
| 199 |
+
|
| 200 |
+
for i, url in enumerate(image_urls[:4]):
|
| 201 |
+
response = httpx.get(url, follow_redirects=True)
|
| 202 |
+
response.raise_for_status()
|
| 203 |
+
|
| 204 |
+
# Upload to blob storage
|
| 205 |
+
upload = client.upload_blob(response.content)
|
| 206 |
+
images.append(
|
| 207 |
+
models.AppBskyEmbedImages.Image(
|
| 208 |
+
alt=alts[i] if i < len(alts) else "",
|
| 209 |
+
image=upload.blob,
|
| 210 |
+
)
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Create record with media embed
|
| 214 |
+
return models.AppBskyEmbedRecordWithMedia.Main(
|
| 215 |
+
record=models.AppBskyEmbedRecord.Main(record=quoted_ref),
|
| 216 |
+
media=models.AppBskyEmbedImages.Main(images=images),
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _send_images(
|
| 221 |
+
text: str,
|
| 222 |
+
image_urls: list[str],
|
| 223 |
+
image_alts: list[str] | None,
|
| 224 |
+
facets,
|
| 225 |
+
reply_ref,
|
| 226 |
+
client,
|
| 227 |
+
):
|
| 228 |
+
"""Send post with images using the client's send_images method."""
|
| 229 |
+
import httpx
|
| 230 |
+
|
| 231 |
+
# Ensure alt_texts has same length as images
|
| 232 |
+
if image_alts is None:
|
| 233 |
+
image_alts = [""] * len(image_urls)
|
| 234 |
+
elif len(image_alts) < len(image_urls):
|
| 235 |
+
image_alts.extend([""] * (len(image_urls) - len(image_alts)))
|
| 236 |
+
|
| 237 |
+
image_data = []
|
| 238 |
+
alts = []
|
| 239 |
+
for i, url in enumerate(image_urls[:4]): # Max 4 images
|
| 240 |
+
# Download image (follow redirects)
|
| 241 |
+
response = httpx.get(url, follow_redirects=True)
|
| 242 |
+
response.raise_for_status()
|
| 243 |
+
|
| 244 |
+
image_data.append(response.content)
|
| 245 |
+
alts.append(image_alts[i] if i < len(image_alts) else "")
|
| 246 |
+
|
| 247 |
+
# Send post with images
|
| 248 |
+
# Note: send_images doesn't support facets or reply_to directly
|
| 249 |
+
# So we need to use send_post with manual image upload if we have those
|
| 250 |
+
if facets or reply_ref:
|
| 251 |
+
# Manual image upload
|
| 252 |
+
images = []
|
| 253 |
+
for i, data in enumerate(image_data):
|
| 254 |
+
upload = client.upload_blob(data)
|
| 255 |
+
images.append(
|
| 256 |
+
models.AppBskyEmbedImages.Image(
|
| 257 |
+
alt=alts[i],
|
| 258 |
+
image=upload.blob,
|
| 259 |
+
)
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
embed = models.AppBskyEmbedImages.Main(images=images)
|
| 263 |
+
post = client.send_post(
|
| 264 |
+
text=text,
|
| 265 |
+
facets=facets if facets else None,
|
| 266 |
+
embed=embed,
|
| 267 |
+
reply_to=reply_ref,
|
| 268 |
+
)
|
| 269 |
+
else:
|
| 270 |
+
# Use simple send_images
|
| 271 |
+
post = client.send_images(
|
| 272 |
+
text=text,
|
| 273 |
+
images=image_data,
|
| 274 |
+
image_alts=alts,
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
return PostResult(
|
| 278 |
+
success=True,
|
| 279 |
+
uri=post.uri,
|
| 280 |
+
cid=post.cid,
|
| 281 |
+
text=text,
|
| 282 |
+
created_at=datetime.now().isoformat(),
|
| 283 |
+
error=None,
|
| 284 |
+
)
|
examples/atproto_mcp/src/atproto_mcp/_atproto/_profile.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Profile-related operations."""
|
| 2 |
+
|
| 3 |
+
from atproto_mcp.types import ProfileInfo
|
| 4 |
+
|
| 5 |
+
from ._client import get_client
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_profile_info() -> ProfileInfo:
|
| 9 |
+
"""Get profile information for the authenticated user."""
|
| 10 |
+
try:
|
| 11 |
+
client = get_client()
|
| 12 |
+
profile = client.get_profile(client.me.did)
|
| 13 |
+
return ProfileInfo(
|
| 14 |
+
connected=True,
|
| 15 |
+
handle=profile.handle,
|
| 16 |
+
display_name=profile.display_name,
|
| 17 |
+
did=client.me.did,
|
| 18 |
+
followers=profile.followers_count,
|
| 19 |
+
following=profile.follows_count,
|
| 20 |
+
posts=profile.posts_count,
|
| 21 |
+
error=None,
|
| 22 |
+
)
|
| 23 |
+
except Exception as e:
|
| 24 |
+
return ProfileInfo(
|
| 25 |
+
connected=False,
|
| 26 |
+
handle=None,
|
| 27 |
+
display_name=None,
|
| 28 |
+
did=None,
|
| 29 |
+
followers=None,
|
| 30 |
+
following=None,
|
| 31 |
+
posts=None,
|
| 32 |
+
error=str(e),
|
| 33 |
+
)
|
examples/atproto_mcp/src/atproto_mcp/_atproto/_read.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read-only operations for timeline, search, and notifications."""
|
| 2 |
+
|
| 3 |
+
from atproto_mcp.types import (
|
| 4 |
+
Notification,
|
| 5 |
+
NotificationsResult,
|
| 6 |
+
Post,
|
| 7 |
+
SearchResult,
|
| 8 |
+
TimelineResult,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
from ._client import get_client
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def fetch_timeline(limit: int = 10) -> TimelineResult:
|
| 15 |
+
"""Fetch the authenticated user's timeline."""
|
| 16 |
+
try:
|
| 17 |
+
client = get_client()
|
| 18 |
+
timeline = client.get_timeline(limit=limit)
|
| 19 |
+
|
| 20 |
+
posts = []
|
| 21 |
+
for feed_view in timeline.feed:
|
| 22 |
+
post = feed_view.post
|
| 23 |
+
posts.append(
|
| 24 |
+
Post(
|
| 25 |
+
uri=post.uri,
|
| 26 |
+
cid=post.cid,
|
| 27 |
+
text=post.record.text if hasattr(post.record, "text") else "",
|
| 28 |
+
author=post.author.handle,
|
| 29 |
+
created_at=post.record.created_at,
|
| 30 |
+
likes=post.like_count or 0,
|
| 31 |
+
reposts=post.repost_count or 0,
|
| 32 |
+
replies=post.reply_count or 0,
|
| 33 |
+
)
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
return TimelineResult(
|
| 37 |
+
success=True,
|
| 38 |
+
posts=posts,
|
| 39 |
+
count=len(posts),
|
| 40 |
+
error=None,
|
| 41 |
+
)
|
| 42 |
+
except Exception as e:
|
| 43 |
+
return TimelineResult(
|
| 44 |
+
success=False,
|
| 45 |
+
posts=[],
|
| 46 |
+
count=0,
|
| 47 |
+
error=str(e),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def search_for_posts(query: str, limit: int = 10) -> SearchResult:
|
| 52 |
+
"""Search for posts containing specific text."""
|
| 53 |
+
try:
|
| 54 |
+
client = get_client()
|
| 55 |
+
search_results = client.app.bsky.feed.search_posts(
|
| 56 |
+
params={"q": query, "limit": limit}
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
posts = []
|
| 60 |
+
for post in search_results.posts:
|
| 61 |
+
posts.append(
|
| 62 |
+
Post(
|
| 63 |
+
uri=post.uri,
|
| 64 |
+
cid=post.cid,
|
| 65 |
+
text=post.record.text if hasattr(post.record, "text") else "",
|
| 66 |
+
author=post.author.handle,
|
| 67 |
+
created_at=post.record.created_at,
|
| 68 |
+
likes=post.like_count or 0,
|
| 69 |
+
reposts=post.repost_count or 0,
|
| 70 |
+
replies=post.reply_count or 0,
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return SearchResult(
|
| 75 |
+
success=True,
|
| 76 |
+
query=query,
|
| 77 |
+
posts=posts,
|
| 78 |
+
count=len(posts),
|
| 79 |
+
error=None,
|
| 80 |
+
)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
return SearchResult(
|
| 83 |
+
success=False,
|
| 84 |
+
query=query,
|
| 85 |
+
posts=[],
|
| 86 |
+
count=0,
|
| 87 |
+
error=str(e),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def fetch_notifications(limit: int = 10) -> NotificationsResult:
|
| 92 |
+
"""Fetch recent notifications."""
|
| 93 |
+
try:
|
| 94 |
+
client = get_client()
|
| 95 |
+
notifs = client.app.bsky.notification.list_notifications(
|
| 96 |
+
params={"limit": limit}
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
notifications = []
|
| 100 |
+
for notif in notifs.notifications:
|
| 101 |
+
notifications.append(
|
| 102 |
+
Notification(
|
| 103 |
+
uri=notif.uri,
|
| 104 |
+
cid=notif.cid,
|
| 105 |
+
author=notif.author.handle,
|
| 106 |
+
reason=notif.reason,
|
| 107 |
+
is_read=notif.is_read,
|
| 108 |
+
indexed_at=notif.indexed_at,
|
| 109 |
+
)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return NotificationsResult(
|
| 113 |
+
success=True,
|
| 114 |
+
notifications=notifications,
|
| 115 |
+
count=len(notifications),
|
| 116 |
+
error=None,
|
| 117 |
+
)
|
| 118 |
+
except Exception as e:
|
| 119 |
+
return NotificationsResult(
|
| 120 |
+
success=False,
|
| 121 |
+
notifications=[],
|
| 122 |
+
count=0,
|
| 123 |
+
error=str(e),
|
| 124 |
+
)
|
examples/atproto_mcp/src/atproto_mcp/_atproto/_social.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Social actions like follow, like, and repost."""
|
| 2 |
+
|
| 3 |
+
from atproto_mcp.types import FollowResult, LikeResult, RepostResult
|
| 4 |
+
|
| 5 |
+
from ._client import get_client
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def follow_user_by_handle(handle: str) -> FollowResult:
|
| 9 |
+
"""Follow a user by their handle."""
|
| 10 |
+
try:
|
| 11 |
+
client = get_client()
|
| 12 |
+
# Search for the user to get their DID
|
| 13 |
+
results = client.app.bsky.actor.search_actors(params={"q": handle, "limit": 1})
|
| 14 |
+
if not results.actors:
|
| 15 |
+
return FollowResult(
|
| 16 |
+
success=False,
|
| 17 |
+
did=None,
|
| 18 |
+
handle=None,
|
| 19 |
+
uri=None,
|
| 20 |
+
error=f"User @{handle} not found",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
actor = results.actors[0]
|
| 24 |
+
# Create the follow
|
| 25 |
+
follow = client.follow(actor.did)
|
| 26 |
+
return FollowResult(
|
| 27 |
+
success=True,
|
| 28 |
+
did=actor.did,
|
| 29 |
+
handle=actor.handle,
|
| 30 |
+
uri=follow.uri,
|
| 31 |
+
error=None,
|
| 32 |
+
)
|
| 33 |
+
except Exception as e:
|
| 34 |
+
return FollowResult(
|
| 35 |
+
success=False,
|
| 36 |
+
did=None,
|
| 37 |
+
handle=None,
|
| 38 |
+
uri=None,
|
| 39 |
+
error=str(e),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def like_post_by_uri(uri: str) -> LikeResult:
|
| 44 |
+
"""Like a post by its AT URI."""
|
| 45 |
+
try:
|
| 46 |
+
client = get_client()
|
| 47 |
+
# Parse the URI to get the components
|
| 48 |
+
# URI format: at://did:plc:xxx/app.bsky.feed.post/yyy
|
| 49 |
+
parts = uri.replace("at://", "").split("/")
|
| 50 |
+
if len(parts) != 3 or parts[1] != "app.bsky.feed.post":
|
| 51 |
+
raise ValueError("Invalid post URI format")
|
| 52 |
+
|
| 53 |
+
# Get the post to retrieve its CID
|
| 54 |
+
post = client.app.bsky.feed.get_posts(params={"uris": [uri]})
|
| 55 |
+
if not post.posts:
|
| 56 |
+
raise ValueError("Post not found")
|
| 57 |
+
|
| 58 |
+
cid = post.posts[0].cid
|
| 59 |
+
|
| 60 |
+
# Now like the post with both URI and CID
|
| 61 |
+
like = client.like(uri, cid)
|
| 62 |
+
return LikeResult(
|
| 63 |
+
success=True,
|
| 64 |
+
liked_uri=uri,
|
| 65 |
+
like_uri=like.uri,
|
| 66 |
+
error=None,
|
| 67 |
+
)
|
| 68 |
+
except Exception as e:
|
| 69 |
+
return LikeResult(
|
| 70 |
+
success=False,
|
| 71 |
+
liked_uri=None,
|
| 72 |
+
like_uri=None,
|
| 73 |
+
error=str(e),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def repost_by_uri(uri: str) -> RepostResult:
|
| 78 |
+
"""Repost a post by its AT URI."""
|
| 79 |
+
try:
|
| 80 |
+
client = get_client()
|
| 81 |
+
# Parse the URI to get the components
|
| 82 |
+
# URI format: at://did:plc:xxx/app.bsky.feed.post/yyy
|
| 83 |
+
parts = uri.replace("at://", "").split("/")
|
| 84 |
+
if len(parts) != 3 or parts[1] != "app.bsky.feed.post":
|
| 85 |
+
raise ValueError("Invalid post URI format")
|
| 86 |
+
|
| 87 |
+
# Get the post to retrieve its CID
|
| 88 |
+
post = client.app.bsky.feed.get_posts(params={"uris": [uri]})
|
| 89 |
+
if not post.posts:
|
| 90 |
+
raise ValueError("Post not found")
|
| 91 |
+
|
| 92 |
+
cid = post.posts[0].cid
|
| 93 |
+
|
| 94 |
+
# Now repost with both URI and CID
|
| 95 |
+
repost = client.repost(uri, cid)
|
| 96 |
+
return RepostResult(
|
| 97 |
+
success=True,
|
| 98 |
+
reposted_uri=uri,
|
| 99 |
+
repost_uri=repost.uri,
|
| 100 |
+
error=None,
|
| 101 |
+
)
|
| 102 |
+
except Exception as e:
|
| 103 |
+
return RepostResult(
|
| 104 |
+
success=False,
|
| 105 |
+
reposted_uri=None,
|
| 106 |
+
repost_uri=None,
|
| 107 |
+
error=str(e),
|
| 108 |
+
)
|
examples/atproto_mcp/src/atproto_mcp/py.typed
ADDED
|
File without changes
|
examples/atproto_mcp/src/atproto_mcp/server.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ATProto MCP Server - Public API exposing Bluesky tools and resources."""
|
| 2 |
+
|
| 3 |
+
from typing import Annotated
|
| 4 |
+
|
| 5 |
+
from pydantic import Field
|
| 6 |
+
|
| 7 |
+
from atproto_mcp import _atproto
|
| 8 |
+
from atproto_mcp.settings import settings
|
| 9 |
+
from atproto_mcp.types import (
|
| 10 |
+
FollowResult,
|
| 11 |
+
LikeResult,
|
| 12 |
+
NotificationsResult,
|
| 13 |
+
PostResult,
|
| 14 |
+
ProfileInfo,
|
| 15 |
+
RepostResult,
|
| 16 |
+
RichTextLink,
|
| 17 |
+
RichTextMention,
|
| 18 |
+
SearchResult,
|
| 19 |
+
TimelineResult,
|
| 20 |
+
)
|
| 21 |
+
from fastmcp import FastMCP
|
| 22 |
+
|
| 23 |
+
atproto_mcp = FastMCP(
|
| 24 |
+
"ATProto MCP Server",
|
| 25 |
+
dependencies=[
|
| 26 |
+
"atproto_mcp@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/atproto_mcp",
|
| 27 |
+
],
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Resources - read-only operations
|
| 32 |
+
@atproto_mcp.resource("atproto://profile/status")
|
| 33 |
+
def atproto_status() -> ProfileInfo:
|
| 34 |
+
"""Check the status of the ATProto connection and current user profile."""
|
| 35 |
+
return _atproto.get_profile_info()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@atproto_mcp.resource("atproto://timeline")
|
| 39 |
+
def get_timeline() -> TimelineResult:
|
| 40 |
+
"""Get the authenticated user's timeline feed."""
|
| 41 |
+
return _atproto.fetch_timeline(settings.atproto_timeline_default_limit)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@atproto_mcp.resource("atproto://notifications")
|
| 45 |
+
def get_notifications() -> NotificationsResult:
|
| 46 |
+
"""Get recent notifications for the authenticated user."""
|
| 47 |
+
return _atproto.fetch_notifications(settings.atproto_notifications_default_limit)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# Tools - actions that modify state
|
| 51 |
+
@atproto_mcp.tool
|
| 52 |
+
def post(
|
| 53 |
+
text: Annotated[
|
| 54 |
+
str, Field(max_length=300, description="The text content of the post")
|
| 55 |
+
],
|
| 56 |
+
images: Annotated[
|
| 57 |
+
list[str] | None,
|
| 58 |
+
Field(max_length=4, description="URLs of images to attach (max 4)"),
|
| 59 |
+
] = None,
|
| 60 |
+
image_alts: Annotated[
|
| 61 |
+
list[str] | None, Field(description="Alt text for each image")
|
| 62 |
+
] = None,
|
| 63 |
+
links: Annotated[
|
| 64 |
+
list[RichTextLink] | None, Field(description="Links to embed in the text")
|
| 65 |
+
] = None,
|
| 66 |
+
mentions: Annotated[
|
| 67 |
+
list[RichTextMention] | None, Field(description="User mentions to embed")
|
| 68 |
+
] = None,
|
| 69 |
+
reply_to: Annotated[
|
| 70 |
+
str | None, Field(description="AT URI of post to reply to")
|
| 71 |
+
] = None,
|
| 72 |
+
reply_root: Annotated[
|
| 73 |
+
str | None, Field(description="AT URI of thread root (defaults to reply_to)")
|
| 74 |
+
] = None,
|
| 75 |
+
quote: Annotated[str | None, Field(description="AT URI of post to quote")] = None,
|
| 76 |
+
) -> PostResult:
|
| 77 |
+
"""Create a post with optional rich features like images, quotes, replies, and rich text.
|
| 78 |
+
|
| 79 |
+
Examples:
|
| 80 |
+
- Simple post: post("Hello world!")
|
| 81 |
+
- With image: post("Check this out!", images=["https://example.com/img.jpg"])
|
| 82 |
+
- Reply: post("I agree!", reply_to="at://did/app.bsky.feed.post/123")
|
| 83 |
+
- Quote: post("Great point!", quote="at://did/app.bsky.feed.post/456")
|
| 84 |
+
- Rich text: post("Check out example.com", links=[{"text": "example.com", "url": "https://example.com"}])
|
| 85 |
+
"""
|
| 86 |
+
return _atproto.create_post(
|
| 87 |
+
text, images, image_alts, links, mentions, reply_to, reply_root, quote
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@atproto_mcp.tool
|
| 92 |
+
def follow(
|
| 93 |
+
handle: Annotated[
|
| 94 |
+
str,
|
| 95 |
+
Field(
|
| 96 |
+
description="The handle of the user to follow (e.g., 'user.bsky.social')"
|
| 97 |
+
),
|
| 98 |
+
],
|
| 99 |
+
) -> FollowResult:
|
| 100 |
+
"""Follow a user by their handle."""
|
| 101 |
+
return _atproto.follow_user_by_handle(handle)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@atproto_mcp.tool
|
| 105 |
+
def like(
|
| 106 |
+
uri: Annotated[str, Field(description="The AT URI of the post to like")],
|
| 107 |
+
) -> LikeResult:
|
| 108 |
+
"""Like a post by its AT URI."""
|
| 109 |
+
return _atproto.like_post_by_uri(uri)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@atproto_mcp.tool
|
| 113 |
+
def repost(
|
| 114 |
+
uri: Annotated[str, Field(description="The AT URI of the post to repost")],
|
| 115 |
+
) -> RepostResult:
|
| 116 |
+
"""Repost a post by its AT URI."""
|
| 117 |
+
return _atproto.repost_by_uri(uri)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@atproto_mcp.tool
|
| 121 |
+
def search(
|
| 122 |
+
query: Annotated[str, Field(description="Search query for posts")],
|
| 123 |
+
limit: Annotated[
|
| 124 |
+
int, Field(ge=1, le=100, description="Number of results to return")
|
| 125 |
+
] = settings.atproto_search_default_limit,
|
| 126 |
+
) -> SearchResult:
|
| 127 |
+
"""Search for posts containing specific text."""
|
| 128 |
+
return _atproto.search_for_posts(query, limit)
|
examples/atproto_mcp/src/atproto_mcp/settings.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import Field
|
| 2 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
model_config = SettingsConfigDict(env_file=[".env"], extra="ignore")
|
| 7 |
+
|
| 8 |
+
atproto_handle: str = Field(default=...)
|
| 9 |
+
atproto_password: str = Field(default=...)
|
| 10 |
+
atproto_pds_url: str = Field(default="https://bsky.social")
|
| 11 |
+
|
| 12 |
+
atproto_notifications_default_limit: int = Field(default=10)
|
| 13 |
+
atproto_timeline_default_limit: int = Field(default=10)
|
| 14 |
+
atproto_search_default_limit: int = Field(default=10)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
settings = Settings()
|
examples/atproto_mcp/src/atproto_mcp/types.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Type definitions for ATProto MCP server."""
|
| 2 |
+
|
| 3 |
+
from typing import TypedDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ProfileInfo(TypedDict):
|
| 7 |
+
"""Profile information response."""
|
| 8 |
+
|
| 9 |
+
connected: bool
|
| 10 |
+
handle: str | None
|
| 11 |
+
display_name: str | None
|
| 12 |
+
did: str | None
|
| 13 |
+
followers: int | None
|
| 14 |
+
following: int | None
|
| 15 |
+
posts: int | None
|
| 16 |
+
error: str | None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PostResult(TypedDict):
|
| 20 |
+
"""Result of creating a post."""
|
| 21 |
+
|
| 22 |
+
success: bool
|
| 23 |
+
uri: str | None
|
| 24 |
+
cid: str | None
|
| 25 |
+
text: str | None
|
| 26 |
+
created_at: str | None
|
| 27 |
+
error: str | None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Post(TypedDict):
|
| 31 |
+
"""A single post."""
|
| 32 |
+
|
| 33 |
+
author: str
|
| 34 |
+
text: str | None
|
| 35 |
+
created_at: str | None
|
| 36 |
+
likes: int
|
| 37 |
+
reposts: int
|
| 38 |
+
replies: int
|
| 39 |
+
uri: str
|
| 40 |
+
cid: str
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TimelineResult(TypedDict):
|
| 44 |
+
"""Timeline fetch result."""
|
| 45 |
+
|
| 46 |
+
success: bool
|
| 47 |
+
count: int
|
| 48 |
+
posts: list[Post]
|
| 49 |
+
error: str | None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class SearchResult(TypedDict):
|
| 53 |
+
"""Search result."""
|
| 54 |
+
|
| 55 |
+
success: bool
|
| 56 |
+
query: str
|
| 57 |
+
count: int
|
| 58 |
+
posts: list[Post]
|
| 59 |
+
error: str | None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Notification(TypedDict):
|
| 63 |
+
"""A single notification."""
|
| 64 |
+
|
| 65 |
+
reason: str
|
| 66 |
+
author: str | None
|
| 67 |
+
is_read: bool
|
| 68 |
+
indexed_at: str
|
| 69 |
+
uri: str
|
| 70 |
+
cid: str
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class NotificationsResult(TypedDict):
|
| 74 |
+
"""Notifications fetch result."""
|
| 75 |
+
|
| 76 |
+
success: bool
|
| 77 |
+
count: int
|
| 78 |
+
notifications: list[Notification]
|
| 79 |
+
error: str | None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class FollowResult(TypedDict):
|
| 83 |
+
"""Result of following a user."""
|
| 84 |
+
|
| 85 |
+
success: bool
|
| 86 |
+
handle: str | None
|
| 87 |
+
did: str | None
|
| 88 |
+
uri: str | None
|
| 89 |
+
error: str | None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class LikeResult(TypedDict):
|
| 93 |
+
"""Result of liking a post."""
|
| 94 |
+
|
| 95 |
+
success: bool
|
| 96 |
+
liked_uri: str | None
|
| 97 |
+
like_uri: str | None
|
| 98 |
+
error: str | None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class RepostResult(TypedDict):
|
| 102 |
+
"""Result of reposting."""
|
| 103 |
+
|
| 104 |
+
success: bool
|
| 105 |
+
reposted_uri: str | None
|
| 106 |
+
repost_uri: str | None
|
| 107 |
+
error: str | None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class RichTextLink(TypedDict):
|
| 111 |
+
"""A link in rich text."""
|
| 112 |
+
|
| 113 |
+
text: str
|
| 114 |
+
url: str
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class RichTextMention(TypedDict):
|
| 118 |
+
"""A mention in rich text."""
|
| 119 |
+
|
| 120 |
+
handle: str
|
| 121 |
+
display_text: str | None
|