Bluesky MCP Server
by brianellin
Verified
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { AtpAgent } from "@atproto/api";
import * as dotenv from "dotenv";
import {
createErrorResponse,
createSuccessResponse,
fetchFeedPosts,
fetchPostsFromListMembers,
fetchUserPosts,
formatPost,
formatSummaryText,
getFeedNameFromId,
validateUri,
} from './utils.js';
import { registerResources, resourcesList } from './resources.js';
import { registerPrompts } from './prompts.js';
// Load environment variables
dotenv.config({ path: '.env' });
dotenv.config({ path: '.env.local', override: true });
// Create server instance
const server = new McpServer({
name: "bluesky-integration",
version: "1.0.0",
});
// Register resources from the resources.ts file
registerResources(server);
// Register prompts from the prompts.ts file
registerPrompts(server);
// Initialize ATP agent and session
let agent: AtpAgent | null = null;
// Connect to Bluesky using environment variables
async function initializeBlueskyConnection() {
const identifier = process.env.BLUESKY_IDENTIFIER;
const password = process.env.BLUESKY_APP_PASSWORD;
const service = process.env.BLUESKY_SERVICE_URL || "https://bsky.social";
if (!identifier || !password) {
console.error("Error: BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD environment variables must be set");
return false;
}
try {
agent = new AtpAgent({ service });
const result = await agent.login({ identifier, password });
if (result.success) {
console.error(`Successfully logged in as ${result.data.handle} (${result.data.did})`);
return true;
} else {
console.error("Login failed: Invalid credentials.");
return false;
}
} catch (error) {
console.error(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
server.tool(
"get-timeline-posts",
"Fetch your home timeline from Bluesky, which includes posts from all of the people you follow in reverse chronological order",
{
limit: z.number().min(1).max(100).default(50).describe("Number of posts to fetch (1-100)"),
},
async ({ limit }) => {
try {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not connected to Bluesky. Check your environment variables.",
},
],
};
}
const MAX_TOTAL_POSTS = 1000; // Safety limit to prevent excessive API calls
// Initial fetch using at most 100 posts per request
const initialFetchLimit = Math.min(100, limit);
const response = await agent.getTimeline({ limit: initialFetchLimit });
if (!response.success) {
return {
isError: true,
content: [
{
type: "text",
text: "Failed to fetch timeline.",
},
],
};
}
const allPosts: any[] = response.data.feed;
let nextCursor = response.data.cursor;
// If we have more posts available (cursor exists),
// keep paginating until we reach the maximum post limit
if (nextCursor && allPosts.length < MAX_TOTAL_POSTS) {
// For safety, don't fetch more than 10 pages
let paginationCount = 0;
while (nextCursor && paginationCount < 10 && allPosts.length < MAX_TOTAL_POSTS) {
paginationCount++;
try {
const nextPage = await agent.getTimeline({
cursor: nextCursor,
limit: initialFetchLimit
});
if (nextPage.success) {
allPosts.push(...nextPage.data.feed);
nextCursor = nextPage.data.cursor;
} else {
// If we hit an error, just use what we have
break;
}
} catch (error) {
// If we hit an error, just use what we have
break;
}
}
}
// Parse and format posts
const filteredFeed = [...allPosts];
// Limit the posts to the requested limit
const finalPosts = filteredFeed.length > limit
? filteredFeed.slice(0, limit)
: filteredFeed;
if (finalPosts.length === 0) {
return createSuccessResponse("Your timeline is empty.");
}
// Use the enhanced formatter for each post
const timelineData = finalPosts.map((item, index) => formatPost(item, index)).join("\n\n");
const summaryText = formatSummaryText(finalPosts.length, "timeline");
return {
content: [
{
type: "text",
text: `${summaryText}\n\n${timelineData}`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error fetching timeline: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"create-post",
"Create a new post on Bluesky",
{
text: z.string().max(300).describe("The content of your post"),
replyTo: z.string().optional().describe("Optional URI of post to reply to"),
},
async ({ text, replyTo }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not connected to Bluesky. Check your environment variables.",
},
],
};
}
try {
const record: any = {
text,
createdAt: new Date().toISOString(),
};
let replyRef;
if (replyTo) {
// Handle reply format
try {
const parts = replyTo.split('/');
const did = parts[2];
const rkey = parts[parts.length - 1];
const collection = parts[parts.length - 2] === 'app.bsky.feed.post' ? 'app.bsky.feed.post' : parts[parts.length - 2];
// Resolve the CID of the post we're replying to
const cidResponse = await agent.app.bsky.feed.getPostThread({ uri: replyTo });
if (!cidResponse.success) {
throw new Error('Could not get post information');
}
const threadPost = cidResponse.data.thread as any;
const parentCid = threadPost.post.cid;
// Add reply information to the record
record.reply = {
parent: { uri: replyTo, cid: parentCid },
root: { uri: replyTo, cid: parentCid }
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error parsing reply URI: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
const response = await agent.post(record);
return {
content: [
{
type: "text",
text: `Post created successfully! URI: ${response.uri}`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error creating post: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"get-profile",
"Get a user's profile from Bluesky",
{
handle: z.string().describe("The handle of the user (e.g., alice.bsky.social)"),
},
async ({ handle }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not logged in. Please use the login tool first.",
},
],
};
}
try {
const response = await agent.getProfile({ actor: handle });
if (!response.success) {
return {
isError: true,
content: [
{
type: "text",
text: `Failed to get profile for ${handle}.`,
},
],
};
}
const profile = response.data;
let profileText = `Profile for ${profile.displayName || handle} (@${profile.handle})
DID: ${profile.did}
${profile.description ? `Bio: ${profile.description}` : ''}
Followers: ${profile.followersCount || 0}
Following: ${profile.followsCount || 0}
Posts: ${profile.postsCount || 0}
${profile.labels?.length ? `Labels: ${profile.labels.map((l: any) => l.val).join(', ')}` : ''}`;
return {
content: [
{
type: "text",
text: profileText,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error fetching profile: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"search-posts",
"Search for posts on Bluesky",
{
query: z.string().describe("Search query"),
limit: z.number().min(1).max(100).default(50).describe("Number of results to fetch (1-100)"),
sort: z.enum(["top", "latest"]).default("top").describe("Sort order for search results - 'top' for most relevant or 'latest' for most recent"),
},
async ({ query, limit, sort }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not logged in. Please use the login tool first.",
},
],
};
}
try {
const response = await agent.app.bsky.feed.searchPosts({ q: query, limit, sort });
if (!response.success) {
return {
isError: true,
content: [
{
type: "text",
text: "Failed to search posts.",
},
],
};
}
const { posts } = response.data;
if (posts.length === 0) {
return {
content: [
{
type: "text",
text: `No results found for query: "${query}"`,
},
],
};
}
const results = posts.map((post: any, index: number) => {
const author = post.author;
return `Result #${index + 1}:
Author: ${author.displayName || author.handle} (@${author.handle})
Content: ${post.record.text}
${post.likeCount !== undefined ? `Likes: ${post.likeCount}` : ''}
${post.repostCount !== undefined ? `Reposts: ${post.repostCount}` : ''}
URI: ${post.uri}
Posted: ${new Date(post.indexedAt).toLocaleString()}
---`;
}).join("\n\n");
return {
content: [
{
type: "text",
text: results,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error searching posts: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"search-people",
"Search for users/actors on Bluesky",
{
query: z.string().describe("Search query for finding users"),
limit: z.number().min(1).max(100).default(20).describe("Number of results to fetch (1-100)"),
},
async ({ query, limit }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not logged in. Please use the login tool first.",
},
],
};
}
try {
const response = await agent.app.bsky.actor.searchActors({ q: query, limit });
if (!response.success) {
return {
isError: true,
content: [
{
type: "text",
text: "Failed to search for users.",
},
],
};
}
const { actors } = response.data;
if (actors.length === 0) {
return {
content: [
{
type: "text",
text: `No users found for query: "${query}"`,
},
],
};
}
const results = actors.map((actor: any, index: number) => {
return `User #${index + 1}:
Display Name: ${actor.displayName || 'No display name'}
Handle: @${actor.handle}
DID: ${actor.did}
${actor.description ? `Bio: ${actor.description}` : 'Bio: No bio provided'}
${actor.followersCount !== undefined ? `Followers: ${actor.followersCount}` : ''}
${actor.followsCount !== undefined ? `Following: ${actor.followsCount}` : ''}
${actor.postsCount !== undefined ? `Posts: ${actor.postsCount}` : ''}
${actor.indexedAt ? `Indexed At: ${new Date(actor.indexedAt).toLocaleString()}` : ''}
---`;
}).join("\n\n");
return {
content: [
{
type: "text",
text: results,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error searching for users: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"search-feeds",
"Search for custom feeds on Bluesky",
{
query: z.string().describe("Search query for finding feeds"),
limit: z.number().min(1).max(100).default(10).describe("Number of results to fetch (1-100)"),
},
async ({ query, limit }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not logged in. Please use the login tool first.",
},
],
};
}
try {
const response = await agent.api.app.bsky.unspecced.getPopularFeedGenerators({
query,
limit
});
if (!response.success) {
return {
isError: true,
content: [
{
type: "text",
text: "Failed to search for feeds.",
},
],
};
}
const { feeds } = response.data;
if (!feeds || feeds.length === 0) {
return {
content: [
{
type: "text",
text: `No feeds found for query: "${query}"`,
},
],
};
}
const results = feeds.map((feed: any, index: number) => {
return `Feed #${index + 1}:
Name: ${feed.displayName || 'Unnamed Feed'}
URI: ${feed.uri}
${feed.description ? `Description: ${feed.description}` : ''}
Creator: @${feed.creator.handle} ${feed.creator.displayName ? `(${feed.creator.displayName})` : ''}
Likes: ${feed.likeCount || 0}
${feed.indexedAt ? `Indexed At: ${new Date(feed.indexedAt).toLocaleString()}` : ''}
---`;
}).join("\n\n");
return {
content: [
{
type: "text",
text: results,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error searching for feeds: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"get-liked-posts",
"Get a list of posts that the authenticated user has liked",
{
limit: z.number().min(1).max(100).default(50).describe("Maximum number of liked posts to fetch (1-100)"),
},
async ({ limit }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not logged in. Please use the login tool first.",
},
],
};
}
const currentAgent = agent; // Assign to non-null variable to satisfy TypeScript
try {
// We can only get likes for the authenticated user
if (!currentAgent.session?.handle) {
return createErrorResponse("Not properly authenticated. Please check your credentials.");
}
const authenticatedUser = currentAgent.session.handle;
// Now fetch the authenticated user's likes with pagination
const MAX_BATCH_SIZE = 100; // Maximum number of likes per API call
const MAX_BATCHES = 5; // Maximum number of API calls to make (100 x 5 = 500)
let allLikes: any[] = [];
let nextCursor: string | undefined = undefined;
let batchCount = 0;
// Loop to fetch likes with pagination
while (batchCount < MAX_BATCHES && allLikes.length < limit) {
// Calculate how many likes to fetch in this batch
const batchLimit = Math.min(MAX_BATCH_SIZE, limit - allLikes.length);
// Make the API call with cursor if we have one
const response = await currentAgent.app.bsky.feed.getActorLikes({
actor: authenticatedUser,
limit: batchLimit,
cursor: nextCursor || undefined
});
if (!response.success) {
// If we've already fetched some likes, return those
if (allLikes.length > 0) {
break;
}
return createErrorResponse(`Failed to fetch your likes.`);
}
const { feed, cursor } = response.data;
// Add the fetched likes to our collection
allLikes = allLikes.concat(feed);
// Update cursor for the next batch
nextCursor = cursor;
batchCount++;
// If no cursor returned or we've reached our limit, stop paginating
if (!cursor || allLikes.length >= limit) {
break;
}
}
if (allLikes.length === 0) {
return createSuccessResponse(`You haven't liked any posts.`);
}
// Format the likes list, focusing on the posts rather than user info
const formattedLikes = allLikes.map((like: any, index: number) => {
const post = like.post;
return formatPost(post, index);
}).join("\n\n");
// Create a summary
const summaryText = formatSummaryText(allLikes.length, "liked posts");
return createSuccessResponse(`${summaryText}\n\n${formattedLikes}`);
} catch (error) {
return createErrorResponse(`Error fetching likes: ${error instanceof Error ? error.message : String(error)}`);
}
}
);
server.tool(
"get-trends",
"Get current trending topics on Bluesky",
{
limit: z.number().min(1).max(50).default(10).describe("Number of trending topics to fetch (1-50)"),
includeSuggested: z.boolean().default(false).describe("Whether to include suggested topics in addition to trending topics"),
},
async ({ limit, includeSuggested }) => {
if (!agent) {
return createErrorResponse("Not connected to Bluesky. Check your environment variables.");
}
const currentAgent = agent; // Assign to non-null variable to satisfy TypeScript
try {
// Call the unspecced API endpoint for trending topics
const response = await currentAgent.api.app.bsky.unspecced.getTrendingTopics({
limit: Math.min(50, limit) // API accepts up to 50 per call
});
if (!response.success) {
return createErrorResponse("Failed to fetch trending topics.");
}
const { topics, suggested } = response.data;
if (!topics || topics.length === 0) {
return createSuccessResponse("No trending topics found at this time.");
}
// Format trending topics
const formattedTopics = topics.map((topic: any, index: number) => {
const startTime = new Date(topic.startTime).toLocaleString();
return `#${index + 1}: ${topic.topic}
Post Count: ${topic.postCount} posts
Started Trending: ${startTime}
Feed Link: https://bsky.app${topic.link}
---`;
}).join("\n\n");
// Format suggested topics if requested
let suggestedContent = "";
if (includeSuggested && suggested && suggested.length > 0) {
const formattedSuggested = suggested.map((topic: any, index: number) => {
return `#${index + 1}: ${topic.topic}
Feed Link: https://bsky.app${topic.link}
---`;
}).join("\n\n");
suggestedContent = `\n\n## Suggested Topics for Exploration\n\n${formattedSuggested}`;
}
return createSuccessResponse(`## Current Trending Topics on Bluesky\n\n${formattedTopics}${suggestedContent}`);
} catch (error) {
return createErrorResponse(`Error fetching trending topics: ${error instanceof Error ? error.message : String(error)}`);
}
}
);
server.tool(
"like-post",
"Like a post on Bluesky",
{
uri: z.string().describe("The URI of the post to like"),
},
async ({ uri }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not logged in. Please use the login tool first.",
},
],
};
}
try {
// First, we need to get the CID of the post
const parts = uri.split('/');
const repo = parts[2]; // The DID
const collection = parts[4]; // Usually app.bsky.feed.post
const rkey = parts[5]; // The record key
const response = await agent.app.bsky.feed.getPostThread({ uri });
if (!response.success || response.data.thread.$type !== 'app.bsky.feed.defs#threadViewPost') {
return {
isError: true,
content: [
{
type: "text",
text: "Failed to get post information.",
},
],
};
}
// Type assertion to tell TypeScript this is a post
const threadPost = response.data.thread as any;
const post = threadPost.post;
const cid = post.cid;
await agent.like(uri, cid);
return {
content: [
{
type: "text",
text: "Post liked successfully!",
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error liking post: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"follow-user",
"Follow a user on Bluesky",
{
handle: z.string().describe("The handle of the user to follow"),
},
async ({ handle }) => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not logged in. Please use the login tool first.",
},
],
};
}
try {
// Resolve the handle to a DID
const resolveResponse = await agent.resolveHandle({ handle });
if (!resolveResponse.success) {
return {
isError: true,
content: [
{
type: "text",
text: `Failed to resolve handle: ${handle}`,
},
],
};
}
const did = resolveResponse.data.did;
await agent.follow(did);
return {
content: [
{
type: "text",
text: `Successfully followed @${handle}`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error following user: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"get-pinned-feeds",
"Get the authenticated user's pinned feeds and lists.",
{},
async () => {
if (!agent) {
return {
isError: true,
content: [
{
type: "text",
text: "Not connected to Bluesky. Check your environment variables.",
},
],
};
}
try {
// Get user preferences which include pinned feeds
const response = await agent.app.bsky.actor.getPreferences();
if (!response.success) {
return {
isError: true,
content: [
{
type: "text",
text: "Failed to get user preferences.",
},
],
};
}
// Find the savedFeedsPrefV2 in preferences
const savedFeedsPref = response.data.preferences.find((pref: any) =>
pref.$type === 'app.bsky.actor.defs#savedFeedsPrefV2'
) as { $type: string, items: Array<{ id: string, pinned: boolean, type: string, value: string }> } | undefined;
if (!savedFeedsPref || !savedFeedsPref.items) {
return {
content: [
{
type: "text",
text: "No saved feeds found in user preferences.",
},
],
};
}
// Get the pinned feeds
const pinnedFeeds = savedFeedsPref.items.filter((item: any) => item.pinned);
if (pinnedFeeds.length === 0) {
return {
content: [
{
type: "text",
text: "You don't have any pinned feeds.",
},
],
};
}
// Get additional details for each feed
const feedDetails = await Promise.all(
pinnedFeeds.map(async (feed: any) => {
try {
// Custom feeds (regular feeds)
if (feed.type === 'feed' && feed.value) {
const feedInfo = await agent?.app.bsky.feed.getFeedGenerator({
feed: feed.value
});
if (feedInfo?.success) {
return {
id: feed.id,
uri: feed.value,
name: feedInfo.data.view.displayName,
description: feedInfo.data.view.description || 'No description',
creator: `@${feedInfo.data.view.creator.handle}`,
type: 'Custom Feed'
};
}
}
// Lists
else if (feed.type === 'list' && feed.value) {
const listInfo = await agent?.app.bsky.graph.getList({
list: feed.value
});
if (listInfo?.success) {
const list = listInfo.data.list;
const memberCount = listInfo.data.items.length;
return {
id: feed.id,
uri: feed.value,
name: list.name,
description: list.description || 'No description',
creator: `@${list.creator.handle}`,
members: memberCount,
purpose: list.purpose === 'app.bsky.graph.defs#curatelist' ? 'Curated List' :
list.purpose === 'app.bsky.graph.defs#modlist' ? 'Moderation List' :
'Unknown Purpose',
type: 'List'
};
}
}
// For built-in feeds or if feed generator info failed
return {
id: feed.id,
uri: feed.value || 'N/A',
name: getFeedNameFromId(feed.id),
description: 'Built-in feed',
creator: 'Bluesky',
type: feed.type
};
} catch (error) {
return {
id: feed.id,
uri: feed.value || 'N/A',
name: getFeedNameFromId(feed.id),
description: 'Error fetching details',
type: feed.type
};
}
})
);
const formattedFeeds = feedDetails.map((feed: any, index: number) => {
// Common fields
let output = `Feed #${index + 1}:
Name: ${feed.name}
Type: ${feed.type}
${feed.uri !== 'N/A' ? `URI: ${feed.uri}` : ''}
${feed.description ? `Description: ${feed.description}` : ''}
${feed.creator ? `Creator: ${feed.creator}` : ''}`;
// List-specific fields
if (feed.type === 'List') {
output += `\n${feed.members !== undefined ? `Members: ${feed.members}` : ''}
${feed.purpose ? `Purpose: ${feed.purpose}` : ''}`;
}
output += '\n---';
return output;
}).join("\n\n");
return {
content: [
{
type: "text",
text: `Your Pinned Feeds:\n\n${formattedFeeds}`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error fetching pinned feeds: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
);
server.tool(
"get-feed-posts",
"Fetch posts from a specified feed",
{
feed: z.string().describe("The URI of the feed to fetch posts from (e.g., at://did:plc:abcdef/app.bsky.feed.generator/whats-hot)"),
limit: z.number().min(1).max(100).default(50).describe("Number of posts to fetch (1-100)"),
},
async ({ feed, limit }) => {
if (!agent) {
return createErrorResponse("Not connected to Bluesky. Check your environment variables.");
}
const currentAgent = agent; // Assign to non-null variable to satisfy TypeScript
try {
// First, validate the feed by getting its info
const feedInfo = await validateUri(currentAgent, feed, 'feed');
if (!feedInfo) {
return createErrorResponse(`Invalid feed URI or feed not found: ${feed}.`);
}
const maxPostsToFetch = 500; // For time-based fetching, we might need to retrieve more posts
// Fetch posts from the feed
const { posts: allPosts } = await fetchFeedPosts(currentAgent, feed, {
maxPosts: maxPostsToFetch,
});
// Limit the posts to the requested limit
const finalPosts = allPosts.length > limit
? allPosts.slice(0, limit)
: allPosts;
// If no posts were found after filtering
if (finalPosts.length === 0) {
return createSuccessResponse(`No posts found in the feed: ${feed}`);
}
// Format the posts
const formattedPosts = finalPosts.map((item, index) => formatPost(item, index)).join("\n\n");
// Add summary information
const summaryText = formatSummaryText(finalPosts.length, "feed");
return createSuccessResponse(`${summaryText}\n\n${formattedPosts}`);
} catch (error) {
return createErrorResponse(`Error fetching posts: ${error instanceof Error ? error.message : String(error)}`);
}
}
);
server.tool(
"get-list-posts",
"Fetch posts from users in a specified list",
{
list: z.string().describe("The URI of the list (e.g., at://did:plc:abcdef/app.bsky.graph.list/listname)"),
limit: z.number().min(1).max(100).default(50).describe("Number of posts to fetch (1-100)"),
},
async ({ list, limit }) => {
if (!agent) {
return createErrorResponse("Not connected to Bluesky. Check your environment variables.");
}
const currentAgent = agent; // Assign to non-null variable to satisfy TypeScript
try {
// Validate the list by getting its info
const listInfo = await validateUri(currentAgent, list, 'list');
if (!listInfo) {
return createErrorResponse(`Invalid list URI or list not found: ${list}.`);
}
// Get the list members
const members = listInfo.items.map((item: any) => item.subject.did);
if (members.length === 0) {
return createSuccessResponse(`The list ${listInfo.list.name} doesn't have any members.`);
}
const maxPostsToFetch = 500; // For time-based fetching, we might need to retrieve more posts
// Fetch posts from list members
const allPosts = await fetchPostsFromListMembers(currentAgent, members, {
maxPosts: maxPostsToFetch,
});
// Limit the posts to the requested limit
const finalPosts = allPosts.length > limit
? allPosts.slice(0, limit)
: allPosts;
// If no posts were found after filtering
if (finalPosts.length === 0) {
return createSuccessResponse(`No posts found from list members.`);
}
// Format the posts
const formattedPosts = finalPosts.map((item, index) => formatPost(item, index)).join("\n\n");
// Add summary information
const summaryText = formatSummaryText(finalPosts.length, "list");
return createSuccessResponse(`${summaryText}\n\n${formattedPosts}`);
} catch (error) {
return createErrorResponse(`Error fetching list posts: ${error instanceof Error ? error.message : String(error)}`);
}
}
);
server.tool(
"get-user-posts",
"Fetch posts from a specific user",
{
user: z.string().describe("The handle or DID of the user (e.g., alice.bsky.social)"),
limit: z.number().min(1).max(100).default(50).describe("Number of posts to fetch (1-100)"),
},
async ({ user, limit }) => {
if (!agent) {
return createErrorResponse("Not connected to Bluesky. Check your environment variables.");
}
const currentAgent = agent; // Assign to non-null variable to satisfy TypeScript
try {
// Verify the user exists by trying to get their profile
try {
const profileResponse = await currentAgent.getProfile({ actor: user });
if (!profileResponse.success) {
return createErrorResponse(`User not found: ${user}`);
}
// Use the display name in the summary if available
const displayName = profileResponse.data.displayName || user;
const maxPostsToFetch = 500; // For time-based fetching, we might need to retrieve more posts
// Fetch posts from the user
const { posts: allPosts } = await fetchUserPosts(currentAgent, user, {
maxPosts: maxPostsToFetch,
});
// Limit the posts to the requested limit
const finalPosts = allPosts.length > limit
? allPosts.slice(0, limit)
: allPosts;
// If no posts were found after filtering
if (finalPosts.length === 0) {
return createSuccessResponse(`No posts found from @${user}.`);
}
// Format the posts
const formattedPosts = finalPosts.map((item, index) => formatPost(item, index)).join("\n\n");
// Add summary information
const summaryText = formatSummaryText(finalPosts.length, "user");
return createSuccessResponse(`${summaryText}\n\n${formattedPosts}`);
} catch (profileError) {
return createErrorResponse(`Error retrieving user profile: ${profileError instanceof Error ? profileError.message : String(profileError)}`);
}
} catch (error) {
return createErrorResponse(`Error fetching user posts: ${error instanceof Error ? error.message : String(error)}`);
}
}
);
server.tool(
"get-follows",
"Get a list of users that a person follows",
{
user: z.string().describe("The handle or DID of the user (e.g., alice.bsky.social)"),
limit: z.number().min(1).max(500).default(500).describe("Maximum number of follows to fetch (1-500)"),
},
async ({ user, limit }) => {
if (!agent) {
return createErrorResponse("Not connected to Bluesky. Check your environment variables.");
}
const currentAgent = agent; // Assign to non-null variable to satisfy TypeScript
try {
// First, verify the user exists by trying to get their profile
try {
const profileResponse = await currentAgent.getProfile({ actor: user });
if (!profileResponse.success) {
return createErrorResponse(`User not found: ${user}`);
}
// Use the display name in the summary if available
const displayName = profileResponse.data.displayName || user;
// Now fetch who this user follows with pagination
const MAX_BATCH_SIZE = 100; // Maximum number of follows per API call
const MAX_BATCHES = 5; // Maximum number of API calls to make (100 x 5 = 500)
let allFollows: any[] = [];
let nextCursor: string | undefined = undefined;
let batchCount = 0;
// Loop to fetch follows with pagination
while (batchCount < MAX_BATCHES && allFollows.length < limit) {
// Calculate how many follows to fetch in this batch
const batchLimit = Math.min(MAX_BATCH_SIZE, limit - allFollows.length);
// Make the API call with cursor if we have one
const response = await currentAgent.app.bsky.graph.getFollows({
actor: user,
limit: batchLimit,
cursor: nextCursor
});
if (!response.success) {
// If we've already fetched some follows, return those
if (allFollows.length > 0) {
break;
}
return createErrorResponse(`Failed to fetch follows for ${user}.`);
}
const { follows, cursor } = response.data;
// Add the fetched follows to our collection
allFollows = allFollows.concat(follows);
// Update cursor for the next batch
nextCursor = cursor;
batchCount++;
// If no cursor returned or we've reached our limit, stop paginating
if (!cursor || allFollows.length >= limit) {
break;
}
}
if (allFollows.length === 0) {
return createSuccessResponse(`@${user} doesn't follow anyone.`);
}
// Format the follows list
const formattedFollows = allFollows.map((follow: any, index: number) => {
return `User #${index + 1}:
Display Name: ${follow.displayName || 'No display name'}
Handle: @${follow.handle}
DID: ${follow.did}
${follow.description ? `Bio: ${follow.description.substring(0, 100)}${follow.description.length > 100 ? '...' : ''}` : 'Bio: No bio provided'}
${follow.followersCount !== undefined ? `Followers: ${follow.followersCount}` : ''}
${follow.followsCount !== undefined ? `Following: ${follow.followsCount}` : ''}
${follow.postsCount !== undefined ? `Posts: ${follow.postsCount}` : ''}
${follow.indexedAt ? `Following since: ${new Date(follow.indexedAt).toLocaleString()}` : ''}
---`;
}).join("\n\n");
// Create a summary
const summaryText = `Retrieved ${allFollows.length} users that @${user} follows.${nextCursor ? ' More results are available.' : ''}`;
return createSuccessResponse(`${summaryText}\n\n${formattedFollows}`);
} catch (profileError) {
return createErrorResponse(`Error retrieving user profile: ${profileError instanceof Error ? profileError.message : String(profileError)}`);
}
} catch (error) {
return createErrorResponse(`Error fetching follows: ${error instanceof Error ? error.message : String(error)}`);
}
}
);
server.tool(
"get-post-likes",
"Get information about users who have liked a specific post",
{
uri: z.string().describe("The URI of the post to get likes for (e.g., at://did:plc:abcdef/app.bsky.feed.post/123)"),
limit: z.number().min(1).max(100).default(100).describe("Maximum number of likes to fetch (1-100)"),
},
async ({ uri, limit }) => {
if (!agent) {
return createErrorResponse("Not connected to Bluesky. Check your environment variables.");
}
const currentAgent = agent; // Assign to non-null variable to satisfy TypeScript
try {
// First, we need to get the post's CID
const response = await currentAgent.app.bsky.feed.getPostThread({ uri });
if (!response.success || response.data.thread.$type !== 'app.bsky.feed.defs#threadViewPost') {
return createErrorResponse("Failed to get post information.");
}
// Get the post's CID
const threadPost = response.data.thread as any;
const post = threadPost.post;
const cid = post.cid;
// Now fetch the likes
const likesResponse = await currentAgent.app.bsky.feed.getLikes({
uri,
cid,
limit
});
if (!likesResponse.success) {
return createErrorResponse("Failed to fetch likes for the post.");
}
const { likes } = likesResponse.data;
if (!likes || likes.length === 0) {
return createSuccessResponse("No likes found for this post.");
}
// Format the likes list
const formattedLikes = likes.map((like: any, index: number) => {
const actor = like.actor;
return `User #${index + 1}:
Display Name: ${actor.displayName || 'No display name'}
Handle: @${actor.handle}
DID: ${actor.did}
${actor.description ? `Bio: ${actor.description.substring(0, 100)}${actor.description.length > 100 ? '...' : ''}` : 'Bio: No bio provided'}
${actor.followersCount !== undefined ? `Followers: ${actor.followersCount}` : ''}
${actor.followsCount !== undefined ? `Following: ${actor.followsCount}` : ''}
${actor.postsCount !== undefined ? `Posts: ${actor.postsCount}` : ''}
${like.indexedAt ? `Liked at: ${new Date(like.indexedAt).toLocaleString()}` : ''}
---`;
}).join("\n\n");
// Create a summary
const summaryText = `Retrieved ${likes.length} likes for the post.${likesResponse.data.cursor ? ' More likes are available.' : ''}`;
return createSuccessResponse(`${summaryText}\n\n${formattedLikes}`);
} catch (error) {
return createErrorResponse(`Error fetching post likes: ${error instanceof Error ? error.message : String(error)}`);
}
}
);
server.tool(
"list-resources",
"List all available MCP resources with their descriptions",
{},
async () => {
const formattedResources = resourcesList.map((resource, index) => {
return `Resource #${index + 1}:
Name: ${resource.name}
URI: ${resource.uri}
Description: ${resource.description}
---`;
}).join("\n\n");
return createSuccessResponse(`Available MCP Resources:\n\n${formattedResources}\n\nTo use these resources, reference them by URI in your prompts or queries.`);
}
);
// Start the server
(async function() {
try {
// Initialize Bluesky connection
await initializeBlueskyConnection();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Bluesky MCP Server running on stdio");
} catch (error) {
console.error("Failed to start server:", error);
process.exit(1);
}
})();