Custom Integration Examples
Practical patterns for building custom integrations on top of the AT Protocol MCP Server.
How these examples work
This server speaks the Model Context Protocol over stdio. It does not expose a TypeScript API you import, and it does not bind an HTTP port. Your application (or its MCP client library) launches the server as a child process and talks to it with JSON-RPC tools/call requests:
// Request your MCP client sends over stdio
{
"method": "tools/call",
"params": {
"name": "create_post",
"arguments": { "text": "Hello world!" },
},
}Every tool result comes back as stringified JSON inside a text content block — there is no guaranteed structured schema, so parse the text field yourself when you need the data programmatically:
// Response the server returns
{
"content": [
{
"type": "text",
"text": "{\n \"uri\": \"at://did:plc:.../app.bsky.feed.post/...\",\n \"cid\": \"...\"\n}",
},
],
}In the examples below, callTool(name, args) is a stand-in for whatever your MCP client library exposes for sending a tools/call request. A minimal helper that parses the text payload looks like this:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const transport = new StdioClientTransport({
command: 'atproto-mcp',
// Pass credentials via the environment of the spawned process.
env: {
...process.env,
ATPROTO_IDENTIFIER: process.env.ATPROTO_IDENTIFIER!,
ATPROTO_PASSWORD: process.env.ATPROTO_PASSWORD!,
},
});
const client = new Client({ name: 'my-integration', version: '1.0.0' });
await client.connect(transport);
/** Call a tool and parse its stringified-JSON text result. */
async function callTool<T = unknown>(
name: string,
args: Record<string, unknown> = {}
): Promise<T> {
const result = await client.callTool({ name, arguments: args });
const block = result.content?.find(
(c: { type: string }) => c.type === 'text'
);
if (!block || block.type !== 'text') {
throw new Error(`Tool ${name} returned no text content`);
}
return JSON.parse(block.text) as T;
}Authentication
Most tools require authentication. Set ATPROTO_IDENTIFIER and ATPROTO_PASSWORD (an app password from Bluesky Settings → App Passwords) in the environment of the spawned server process. Without credentials only the public/enhanced tools — notably get_user_profile, get_user_connections, search_actors, and get_author_feed — are available. search_posts requires authentication: the AT Protocol search API changed in 2025 to require auth.
Rate limiting
The server rate-limits each tool to 100 requests per minute per tool. Long-running loops (RSS polling, backups) should pace their calls so they stay well under that limit and respect Bluesky's own platform limits.
RSS Feed Integration
RSS to AT Protocol Bridge
Polls an RSS feed and turns new items into posts by calling the create_post tool. The external-link embed is passed through to create_post's arguments.
import Parser from 'rss-parser';
const parser = new Parser();
const seenItems = new Set<string>();
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
function formatPost(item: Parser.Item): string {
const title = item.title || 'New Article';
const maxLength = 250;
return title.length > maxLength
? `${title.slice(0, maxLength - 3)}...`
: title;
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').slice(0, 300);
}
async function pollFeed(feedUrl: string) {
const feed = await parser.parseURL(feedUrl);
for (const item of feed.items) {
const id = item.guid || item.link;
if (!id || seenItems.has(id)) continue;
try {
const result = await callTool<{ uri: string }>('create_post', {
text: formatPost(item),
embed: item.link
? {
external: {
uri: item.link,
title: item.title || 'Article',
description: stripHtml(
item.contentSnippet || item.content || ''
),
},
}
: undefined,
});
console.log(`Posted RSS item: ${item.title} -> ${result.uri}`);
} catch (error) {
console.error('Failed to post RSS item:', error);
}
seenItems.add(id);
await delay(5000); // Pace calls to stay under the per-tool rate limit.
}
}
// Poll every 15 minutes.
setInterval(() => pollFeed('https://example.com/feed.xml'), 15 * 60 * 1000);Webhook Integration
Webhook Server
Your own HTTP server receives webhooks and forwards them to the MCP server via create_post. Note that the HTTP listener belongs to your application — in this example the AT Protocol MCP Server is reached over its default stdio transport and binds no port of its own.
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhook/post', async (req, res) => {
try {
const { text, embed } = req.body;
const result = await callTool<{ uri: string }>('create_post', {
text,
embed,
});
res.json({ success: true, uri: result.uri });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});Backup and Archive System
Account Backup
Pages through a user's posts with search_posts and saves a profile snapshot with get_user_profile. get_user_profile is an enhanced tool that works without authentication, but search_posts requires authentication (the AT Protocol search API changed in 2025 to require auth), so credentials must be set in the spawned server's environment for the backup loop below.
WARNING
search_posts requires authentication and a non-empty q. An empty query does not return all of an author's posts — combine a query term with the author filter, and page with cursor.
import { writeFileSync } from 'fs';
interface SearchResult {
posts: unknown[];
cursor?: string;
}
async function backupPosts(query: string, authorHandle: string) {
const allPosts: unknown[] = [];
let cursor: string | undefined;
do {
const results = await callTool<SearchResult>('search_posts', {
q: query,
author: authorHandle,
limit: 100,
cursor,
});
allPosts.push(...results.posts);
cursor = results.cursor;
console.log(`Backed up ${allPosts.length} posts...`);
} while (cursor);
writeFileSync(
`backup-${authorHandle}-${Date.now()}.json`,
JSON.stringify(
{
author: authorHandle,
query,
timestamp: new Date().toISOString(),
postCount: allPosts.length,
posts: allPosts,
},
null,
2
)
);
console.log(`Backup complete: ${allPosts.length} posts`);
}
async function backupProfile(authorHandle: string) {
const profile = await callTool('get_user_profile', { actor: authorHandle });
writeFileSync(
`profile-${authorHandle}-${Date.now()}.json`,
JSON.stringify(profile, null, 2)
);
console.log('Profile backup complete');
}
// Usage
await backupPosts('atproto', 'alice.bsky.social');
await backupProfile('alice.bsky.social');Cross-Platform Posting
Multi-Platform Publisher
Fans a single message out to several destinations. The AT Protocol destination is just a create_post call; add your own functions for other platforms.
interface Platform {
name: string;
post: (text: string) => Promise<void>;
}
async function publish(platforms: Platform[], text: string) {
const results = await Promise.allSettled(platforms.map(p => p.post(text)));
results.forEach((result, i) => {
const platform = platforms[i];
if (result.status === 'fulfilled') {
console.log(`Posted to ${platform.name}`);
} else {
console.error(`Failed to post to ${platform.name}:`, result.reason);
}
});
}
// Usage
const platforms: Platform[] = [
{
name: 'AT Protocol',
post: async text => {
await callTool('create_post', { text });
},
},
// Add other platforms, e.g. Mastodon, by implementing their own `post`.
];
await publish(platforms, 'Hello from all platforms!');Graceful Shutdown
When your integration runs as a long-lived process, close the MCP client cleanly on exit so the spawned server process is torn down with it.
async function shutdown() {
console.log('Shutting down gracefully...');
try {
await client.close(); // Closes the stdio transport and the child process.
} finally {
process.exit(0);
}
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);Streaming-based integrations
Real-time patterns such as live analytics, firehose-driven moderation, and event-driven bots are not supported by this server, and streaming tools are not planned: MCP tools are request/response, so a tool could only return a buffered snapshot of past events, not a live stream. The leftover firehose client code has been removed; if event consumption is ever added it would be built fresh on Jetstream. See Experimental & Roadmap for the current status.
If you need a polling-based approximation, discover with mode: "trending" samples your own home timeline (roughly the latest 100 posts, not the network firehose) and can be called on an interval the same way as the tools above.