nate nowack commited on
Commit
ebd569a
·
unverified ·
2 Parent(s): a574195dcd1a40

Merge pull request #916 from jlowin/atproto-example

Browse files
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