@meowtrix/atproto-mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 meowtrix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # atproto-mcp
2
+
3
+ AT Protocol MCP server — gives AI agents the ability to navigate the AT Protocol.
4
+
5
+ ## Quick start (npx)
6
+
7
+ Add this in your mcp config:
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "atproto": {
13
+ "command": "npx",
14
+ "args": ["-y", "@meowtrix/atproto-mcp", "--stdio"],
15
+ "env": {
16
+ "ATPROTO_HANDLE": "your-handle.bsky.social",
17
+ "ATPROTO_APP_PASSWORD": "xxxx-xxxx-xxxx-xxxx"
18
+ }
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Generate an app password at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords).
25
+
26
+ ## Environment variables
27
+
28
+ | Variable | Required | Default | Description |
29
+ |---|---|---|---|
30
+ | `ATPROTO_HANDLE` | Yes | — | Your AT Protocol handle (e.g. `alice.bsky.social`) |
31
+ | `ATPROTO_APP_PASSWORD` | Yes | — | App password (not your main password) |
32
+ | `ENABLE_INTERACTION_GUARDS` | No | `false` | Restrict replies/likes/reposts/follows to mutual connections |
33
+
34
+ ## HTTP mode
35
+
36
+ Start the server on port 3001:
37
+
38
+ ```bash
39
+ npm start
40
+ ```
41
+
42
+ Then configure your MCP client to connect via HTTP:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "atproto": {
48
+ "url": "http://localhost:3001/mcp"
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## Docker
55
+
56
+ ### Docker Compose
57
+
58
+ Create a `.env` file with your credentials:
59
+
60
+ ```
61
+ ATPROTO_HANDLE=your-handle.bsky.social
62
+ ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
63
+ ```
64
+
65
+ Then run:
66
+
67
+ ```bash
68
+ docker compose up
69
+ ```
70
+
71
+ ### Manual Docker
72
+
73
+ ```bash
74
+ docker build -t atproto-mcp .
75
+
76
+ docker run -p 3001:3001 \
77
+ -e ATPROTO_HANDLE=your-handle.bsky.social \
78
+ -e ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
79
+ atproto-mcp
80
+ ```
81
+
82
+ ## Resources
83
+
84
+ | Resource | URI | Description |
85
+ |---|---|---|
86
+ | My Repo Collections | `atproto:///collections` | Shows what record types (collection NSIDs) exist in your AT Protocol repo — useful for discovering what services and data you have |
87
+
88
+ ## Tools
89
+
90
+ ### Social
91
+
92
+ | Tool | Description |
93
+ |---|---|
94
+ | `post` | Create a post (supports replies and @-mentions) |
95
+ | `timeline` | Get your home timeline |
96
+ | `discover_feed` | Browse the Discover / What's Hot feed |
97
+ | `search_posts` | Search posts by keyword |
98
+ | `search_users` | Search for user accounts |
99
+ | `get_profile` | Get a user's profile |
100
+ | `get_author_feed` | Get a user's posts |
101
+ | `my_posts` | Get your own recent posts |
102
+ | `get_post_thread` | Get a post thread with replies |
103
+ | `like` | Like a post |
104
+ | `unlike` | Remove a like |
105
+ | `repost` | Repost a post |
106
+ | `unrepost` | Remove a repost |
107
+ | `follow` | Follow a user |
108
+ | `unfollow` | Unfollow a user |
109
+ | `delete_post` | Delete one of your posts |
110
+ | `all_notifications` | List all notifications |
111
+ | `unread_notifications` | List unread notifications |
112
+ | `mark_notifications_read` | Mark notifications as read |
113
+
114
+ ### Protocol
115
+
116
+ | Tool | Description |
117
+ |---|---|
118
+ | `whoami` | Check current authenticated session |
119
+ | `resolve_did` | Resolve a handle to a DID and fetch DID document |
120
+ | `get_record` | Fetch any record by repo/collection/rkey |
121
+ | `list_records` | List records in a collection |
122
+ | `describe_repo` | Get repo metadata and collections (defaults to your own repo) |
123
+ | `get_blob` | Download a blob by DID and CID |
124
+ | `upload_blob` | Upload a blob (image, etc.) |
125
+ | `create_record` | Create a record in any collection |
126
+ | `put_record` | Create or update a record by collection and rkey |
127
+ | `delete_record` | Delete a record by collection and rkey |
128
+
129
+ ### Graph
130
+
131
+ | Tool | Description |
132
+ |---|---|
133
+ | `update_profile` | Update your profile (display name, bio, avatar, banner) |
134
+ | `get_lists` | Get a user's lists |
135
+ | `get_list` | Get list details and items |
136
+ | `get_suggested_follows` | Get follow suggestions |
137
+ | `get_suggested_feeds` | Discover suggested custom feeds |
138
+ | `get_feed` | Get posts from a custom feed |
139
+ | `get_feed_generators` | Look up feed generator metadata |
140
+ | `get_followers` | List a user's followers |
141
+ | `get_follows` | List who a user follows |
142
+ | `get_preferences` | Read your account preferences |
143
+ | `put_preferences` | Replace all account preferences |
144
+
145
+ ## Interaction guards
146
+
147
+ When `ENABLE_INTERACTION_GUARDS=true`, the server restricts engagement actions (replies, likes, reposts, follows) to users who follow you back. This prevents the AI from interacting with strangers unsupervised.
package/bin/cli.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { register } from "tsx/esm/api";
3
+
4
+ register();
5
+ await import("../src/server.ts");
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@meowtrix/atproto-mcp",
3
+ "version": "0.1.1",
4
+ "description": "AT Protocol MCP server — gives AI agents the ability to navigate the AT Protocol",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/meowtrix/atproto-mcp"
10
+ },
11
+ "keywords": [
12
+ "atproto",
13
+ "bluesky",
14
+ "mcp",
15
+ "model-context-protocol"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "files": [
21
+ "bin/",
22
+ "src/",
23
+ "!src/**/*.test.ts"
24
+ ],
25
+ "bin": {
26
+ "atproto-mcp": "./bin/cli.js"
27
+ },
28
+ "scripts": {
29
+ "start": "node --import=tsx src/server.ts",
30
+ "test": "vitest run"
31
+ },
32
+ "dependencies": {
33
+ "@atcute/atproto": "^3.1.10",
34
+ "@atcute/bluesky": "^3.2.20",
35
+ "@atcute/client": "^4.2.1",
36
+ "@atcute/identity-resolver": "^1.2.2",
37
+ "@modelcontextprotocol/sdk": "^1.27.1",
38
+ "tsx": "^4.21.0",
39
+ "zod": "^3.24.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24.12.0",
43
+ "typescript": "^5.9.3",
44
+ "vitest": "^4.0.18"
45
+ }
46
+ }
@@ -0,0 +1,97 @@
1
+ import type { Client } from "@atcute/client";
2
+ import type {} from "@atcute/bluesky";
3
+ import type { Did, Handle } from "@atcute/lexicons";
4
+
5
+ const asActor = (s: string) => s as Did & Handle;
6
+
7
+ /**
8
+ * Server-side enforcement of reply and engagement constraints.
9
+ *
10
+ * Reply constraint: Cannot reply unless the authenticated user was
11
+ * @-mentioned in the thread or authored an ancestor (own thread).
12
+ *
13
+ * Engagement constraint (like/repost/follow): Allowed when the target
14
+ * is self or the target follows the authenticated user.
15
+ */
16
+
17
+ export type ConstraintRpc = Pick<Client, "get">;
18
+
19
+ export function* walkParentChain(thread: any): Generator<any> {
20
+ let current = thread;
21
+ while (current?.$type === "app.bsky.feed.defs#threadViewPost") {
22
+ yield current.post;
23
+ current = current.parent;
24
+ }
25
+ }
26
+
27
+ export async function validatePostConstraints(opts: {
28
+ postText: string;
29
+ facets: {
30
+ index: { byteStart: number; byteEnd: number };
31
+ features: [{ $type: string; did: string }];
32
+ }[];
33
+ threadData: any | null;
34
+ selfDid: string;
35
+ }): Promise<string | null> {
36
+ const { threadData, selfDid } = opts;
37
+
38
+ // Top-level posts are always allowed (no reply constraint to check)
39
+ if (!threadData) return null;
40
+
41
+ // Check own-thread exemption
42
+ for (const post of walkParentChain(threadData)) {
43
+ if (post.author?.did === selfDid) {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // Check if user was @-mentioned anywhere in the thread
49
+ for (const post of walkParentChain(threadData)) {
50
+ const postFacets = post.record?.facets ?? [];
51
+ const mentionsSelf = postFacets.some((f: any) =>
52
+ f.features?.some(
53
+ (feat: any) =>
54
+ feat.$type === "app.bsky.richtext.facet#mention" &&
55
+ feat.did === selfDid,
56
+ ),
57
+ );
58
+ if (mentionsSelf) return null;
59
+ }
60
+
61
+ return "Reply constraint: cannot reply — you were not mentioned in the thread and it is not your own thread.";
62
+ }
63
+
64
+ /**
65
+ * Check if a user follows the authenticated user (opt-in engagement).
66
+ * Used for like/repost/follow constraints.
67
+ */
68
+ export async function checkFollowsAgent(
69
+ did: string,
70
+ rpc: ConstraintRpc,
71
+ ): Promise<boolean> {
72
+ try {
73
+ const res = await rpc.get("app.bsky.actor.getProfiles", {
74
+ params: { actors: [asActor(did)] },
75
+ });
76
+ if (!res.ok) return false;
77
+ return !!res.data.profiles[0]?.viewer?.followedBy;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check if engagement (like/repost/follow) is allowed with a given DID.
85
+ *
86
+ * Allowed when any of:
87
+ * 1. The target is self (self-interaction)
88
+ * 2. The target follows the authenticated user (opt-in)
89
+ */
90
+ export async function checkEngagementAllowed(
91
+ did: string,
92
+ selfDid: string,
93
+ rpc: ConstraintRpc,
94
+ ): Promise<boolean> {
95
+ if (did === selfDid) return true;
96
+ return checkFollowsAgent(did, rpc);
97
+ }
@@ -0,0 +1,44 @@
1
+ import { Client } from "@atcute/client";
2
+ import { asHandle } from "../context.ts";
3
+
4
+ export const MENTION_RE = /(^|\s)(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+))/g;
5
+
6
+ export async function detectMentionFacets(
7
+ text: string,
8
+ rpc: Client,
9
+ ): Promise<
10
+ {
11
+ index: { byteStart: number; byteEnd: number };
12
+ features: [{ $type: "app.bsky.richtext.facet#mention"; did: string }];
13
+ }[]
14
+ > {
15
+ const encoder = new TextEncoder();
16
+ const facets: Awaited<ReturnType<typeof detectMentionFacets>> = [];
17
+
18
+ for (const match of text.matchAll(MENTION_RE)) {
19
+ const handle = match[3]; // the handle without @
20
+ const mentionStart = match.index! + match[1].length; // skip leading whitespace
21
+ const mentionText = match[2]; // @handle
22
+
23
+ const byteStart = encoder.encode(text.slice(0, mentionStart)).byteLength;
24
+ const byteEnd = byteStart + encoder.encode(mentionText).byteLength;
25
+
26
+ try {
27
+ const res = await rpc.get("com.atproto.identity.resolveHandle", {
28
+ params: { handle: asHandle(handle) },
29
+ });
30
+ if (!res.ok) {
31
+ console.warn(`Failed to resolve handle @${handle}: ${(res as any).data?.message}`);
32
+ continue;
33
+ }
34
+ facets.push({
35
+ index: { byteStart, byteEnd },
36
+ features: [{ $type: "app.bsky.richtext.facet#mention", did: res.data.did }],
37
+ });
38
+ } catch (e) {
39
+ console.warn(`Failed to resolve handle @${handle}:`, e);
40
+ }
41
+ }
42
+
43
+ return facets;
44
+ }
@@ -0,0 +1,325 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type {} from "@atcute/atproto";
3
+ import type { AppBskyGraphDefs } from "@atcute/bluesky";
4
+ import type { Did, Handle, Nsid } from "@atcute/lexicons";
5
+ import { z } from "zod";
6
+ import {
7
+ getCtx,
8
+ text,
9
+ error,
10
+ asActor,
11
+ asUri,
12
+ asUris,
13
+ parseAtUri,
14
+ validateBlobRef,
15
+ } from "../context.ts";
16
+ import { slimActor, slimFeedItem } from "./slim.ts";
17
+
18
+ export function registerGraphTools(server: McpServer) {
19
+ server.registerTool(
20
+ "update_profile",
21
+ {
22
+ description:
23
+ "Update your Bluesky profile (read-modify-write). Only provided fields are changed",
24
+ inputSchema: {
25
+ displayName: z.string().optional().describe("Display name"),
26
+ description: z.string().optional().describe("Profile bio/description"),
27
+ avatar: z
28
+ .string()
29
+ .optional()
30
+ .describe(
31
+ "Avatar blob ref as JSON string — use the exact complete object returned by upload_blob (includes $type, ref, mimeType, size). Omit to keep existing.",
32
+ ),
33
+ banner: z
34
+ .string()
35
+ .optional()
36
+ .describe(
37
+ "Banner blob ref as JSON string — use the exact complete object returned by upload_blob (includes $type, ref, mimeType, size). Omit to keep existing.",
38
+ ),
39
+ },
40
+ },
41
+ async ({ displayName, description, avatar, banner }) => {
42
+ try {
43
+ // Read existing profile record
44
+ let existing: Record<string, unknown> = {};
45
+ try {
46
+ const getRes = await getCtx().rpc.get("com.atproto.repo.getRecord", {
47
+ params: {
48
+ repo: getCtx().session.session!.did as Did & Handle,
49
+ collection: "app.bsky.actor.profile" as Nsid,
50
+ rkey: "self",
51
+ },
52
+ });
53
+ if (getRes.ok) {
54
+ existing = getRes.data.value as Record<string, unknown>;
55
+ }
56
+ } catch {
57
+ // No existing profile — start fresh
58
+ }
59
+
60
+ // Merge only provided fields
61
+ const updated: Record<string, unknown> = {
62
+ $type: "app.bsky.actor.profile",
63
+ ...existing,
64
+ };
65
+ if (displayName !== undefined) updated.displayName = displayName;
66
+ if (description !== undefined) updated.description = description;
67
+
68
+ if (avatar) {
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(avatar);
72
+ } catch {
73
+ return error(
74
+ "avatar is not valid JSON. Pass the exact JSON object returned by upload_blob.",
75
+ );
76
+ }
77
+ const err = validateBlobRef("avatar", parsed);
78
+ if (err) return error(err);
79
+ updated.avatar = parsed;
80
+ }
81
+
82
+ if (banner) {
83
+ let parsed: unknown;
84
+ try {
85
+ parsed = JSON.parse(banner);
86
+ } catch {
87
+ return error(
88
+ "banner is not valid JSON. Pass the exact JSON object returned by upload_blob.",
89
+ );
90
+ }
91
+ const err = validateBlobRef("banner", parsed);
92
+ if (err) return error(err);
93
+ updated.banner = parsed;
94
+ }
95
+
96
+ const res = await getCtx().rpc.post("com.atproto.repo.putRecord", {
97
+ input: {
98
+ repo: getCtx().session.session!.did,
99
+ collection: "app.bsky.actor.profile",
100
+ rkey: "self",
101
+ record: updated,
102
+ },
103
+ });
104
+ if (!res.ok) {
105
+ console.error(
106
+ "[atproto] putRecord failed:",
107
+ JSON.stringify(res.data),
108
+ );
109
+ return error(res.data);
110
+ }
111
+ return text(res.data);
112
+ } catch (e) {
113
+ return error(e);
114
+ }
115
+ },
116
+ );
117
+
118
+ server.registerTool(
119
+ "get_lists",
120
+ {
121
+ description: "Get a user's lists",
122
+ inputSchema: {
123
+ actor: z.string().describe("Handle or DID of the user"),
124
+ limit: z
125
+ .number()
126
+ .optional()
127
+ .describe("Max results (default 50, max 100)"),
128
+ cursor: z.string().optional().describe("Pagination cursor"),
129
+ },
130
+ },
131
+ async ({ actor, limit, cursor }) => {
132
+ try {
133
+ const res = await getCtx().rpc.get("app.bsky.graph.getLists", {
134
+ params: { actor: asActor(actor), limit: limit ?? 50, cursor },
135
+ });
136
+ if (!res.ok) return error(res.data.message);
137
+ return text(res.data);
138
+ } catch (e) {
139
+ return error(e);
140
+ }
141
+ },
142
+ );
143
+
144
+ server.registerTool(
145
+ "get_list",
146
+ {
147
+ description: "Get list details and items",
148
+ inputSchema: {
149
+ list: z.string().describe("AT-URI of the list (must start with at://)"),
150
+ limit: z
151
+ .number()
152
+ .optional()
153
+ .describe("Max items (default 50, max 100)"),
154
+ cursor: z.string().optional().describe("Pagination cursor"),
155
+ },
156
+ },
157
+ async ({ list, limit, cursor }) => {
158
+ try {
159
+ const res = await getCtx().rpc.get("app.bsky.graph.getList", {
160
+ params: { list: asUri(list), limit: limit ?? 50, cursor },
161
+ });
162
+ if (!res.ok) return error(res.data.message);
163
+ return text({
164
+ cursor: res.data.cursor,
165
+ list: res.data.list,
166
+ items: res.data.items.map((i: AppBskyGraphDefs.ListItemView) => ({
167
+ uri: i.uri,
168
+ subject: slimActor(i.subject),
169
+ })),
170
+ });
171
+ } catch (e) {
172
+ return error(e);
173
+ }
174
+ },
175
+ );
176
+
177
+ server.registerTool(
178
+ "get_suggested_follows",
179
+ {
180
+ description: "Get follow suggestions based on a user",
181
+ inputSchema: {
182
+ actor: z.string().describe("Handle or DID to base suggestions on"),
183
+ },
184
+ },
185
+ async ({ actor }) => {
186
+ try {
187
+ const res = await getCtx().rpc.get(
188
+ "app.bsky.graph.getSuggestedFollowsByActor",
189
+ {
190
+ params: { actor: asActor(actor) },
191
+ },
192
+ );
193
+ if (!res.ok) return error(res.data.message);
194
+ return text({ suggestions: res.data.suggestions.map(slimActor) });
195
+ } catch (e) {
196
+ return error(e);
197
+ }
198
+ },
199
+ );
200
+
201
+ server.registerTool(
202
+ "get_suggested_feeds",
203
+ {
204
+ description: "Discover suggested custom feeds",
205
+ inputSchema: {
206
+ limit: z
207
+ .number()
208
+ .optional()
209
+ .describe("Max results (default 50, max 100)"),
210
+ cursor: z.string().optional().describe("Pagination cursor"),
211
+ },
212
+ },
213
+ async ({ limit, cursor }) => {
214
+ try {
215
+ const res = await getCtx().rpc.get("app.bsky.feed.getSuggestedFeeds", {
216
+ params: { limit: limit ?? 50, cursor },
217
+ });
218
+ if (!res.ok) return error(res.data.message);
219
+ return text(res.data);
220
+ } catch (e) {
221
+ return error(e);
222
+ }
223
+ },
224
+ );
225
+
226
+ server.registerTool(
227
+ "get_feed",
228
+ {
229
+ description: "Get posts from a custom feed",
230
+ inputSchema: {
231
+ feed: z
232
+ .string()
233
+ .describe("AT-URI of the feed generator (must start with at://)"),
234
+ limit: z
235
+ .number()
236
+ .optional()
237
+ .describe("Max posts (default 50, max 100)"),
238
+ cursor: z.string().optional().describe("Pagination cursor"),
239
+ },
240
+ },
241
+ async ({ feed, limit, cursor }) => {
242
+ try {
243
+ const res = await getCtx().rpc.get("app.bsky.feed.getFeed", {
244
+ params: { feed: asUri(feed), limit: limit ?? 50, cursor },
245
+ });
246
+ if (!res.ok) return error(res.data.message);
247
+ return text({
248
+ cursor: res.data.cursor,
249
+ feed: res.data.feed.map(slimFeedItem),
250
+ });
251
+ } catch (e) {
252
+ return error(e);
253
+ }
254
+ },
255
+ );
256
+
257
+ server.registerTool(
258
+ "get_feed_generators",
259
+ {
260
+ description: "Look up metadata for one or more feed generators",
261
+ inputSchema: {
262
+ feeds: z
263
+ .array(z.string())
264
+ .describe(
265
+ "Array of AT-URIs of feed generators (each must start with at://)",
266
+ ),
267
+ },
268
+ },
269
+ async ({ feeds }) => {
270
+ try {
271
+ const res = await getCtx().rpc.get("app.bsky.feed.getFeedGenerators", {
272
+ params: { feeds: asUris(feeds) },
273
+ });
274
+ if (!res.ok) return error(res.data.message);
275
+ return text(res.data);
276
+ } catch (e) {
277
+ return error(e);
278
+ }
279
+ },
280
+ );
281
+
282
+ server.registerTool(
283
+ "get_preferences",
284
+ {
285
+ description: "Read your account preferences",
286
+ },
287
+ async () => {
288
+ try {
289
+ const res = await getCtx().rpc.get("app.bsky.actor.getPreferences", {
290
+ params: {},
291
+ });
292
+ if (!res.ok) return error(res.data.message);
293
+ return text(res.data);
294
+ } catch (e) {
295
+ return error(e);
296
+ }
297
+ },
298
+ );
299
+
300
+ server.registerTool(
301
+ "put_preferences",
302
+ {
303
+ description:
304
+ "Replace all account preferences. WARNING: this overwrites all existing preferences",
305
+ inputSchema: {
306
+ preferences: z
307
+ .string()
308
+ .describe("JSON string of the preferences array to set"),
309
+ },
310
+ },
311
+ async ({ preferences }) => {
312
+ try {
313
+ const parsed = JSON.parse(preferences);
314
+ const res = await getCtx().rpc.post("app.bsky.actor.putPreferences", {
315
+ input: { preferences: parsed },
316
+ as: null,
317
+ });
318
+ if (!res.ok) return error(res.data.message);
319
+ return text({ success: true });
320
+ } catch (e) {
321
+ return error(e);
322
+ }
323
+ },
324
+ );
325
+ }