feat: add agent API and operational analytics

This commit is contained in:
jjbrunton committed 2026-08-08 22:08:14 +01:00
1 parent 720ea0652a
commit e8b730b592
65 files changed
+2650 -1214

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
export interface RedditBotApiClientOptions {
baseUrl?: string;
token?: string;
fetchImplementation?: typeof fetch;
}
export class RedditBotApiError extends Error {
constructor(message: string, readonly status: number) {
super(message);
this.name = 'RedditBotApiError';
}
}
export class RedditBotApiClient {
private readonly baseUrl: string;
private readonly token: string;
private readonly fetchImplementation: typeof fetch;
constructor(options: RedditBotApiClientOptions = {}) {
this.baseUrl = (options.baseUrl ?? process.env.REDDIT_BOT_API_URL ?? 'http://127.0.0.1:3000').replace(/\/$/, '');
this.token = options.token
?? process.env.REDDIT_BOT_AGENT_TOKEN
?? process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN
?? process.env.REDDIT_BOT_AGENT_READ_TOKEN
?? process.env.WEB_DASHBOARD_TOKEN
?? '';
this.fetchImplementation = options.fetchImplementation ?? fetch;
if (!this.token) {
throw new Error('MCP server requires REDDIT_BOT_AGENT_TOKEN or WEB_DASHBOARD_TOKEN');
}
new URL(this.baseUrl);
}
get(path: string): Promise<unknown> {
return this.request(path);
}
post(path: string, body?: unknown): Promise<unknown> {
return this.request(path, {
method: 'POST',
...(body === undefined ? {} : {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
});
}
private async request(path: string, init: RequestInit = {}): Promise<unknown> {
const response = await this.fetchImplementation(`${this.baseUrl}${path}`, {
...init,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/json',
...init.headers,
},
});
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text) as unknown;
} catch {
payload = text;
}
}
if (!response.ok) {
const message = typeof payload === 'object' && payload !== null && 'error' in payload
&& typeof payload.error === 'string'
? payload.error
: `RedditBot API request failed (${response.status})`;
throw new RedditBotApiError(message, response.status);
}
return payload;
}
}
+172
View File
@@ -0,0 +1,172 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { RedditBotApiClient } from './api-client.js';
function result(value: unknown) {
const structuredContent = isRecord(value) ? value : { items: value };
return {
content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }],
structuredContent,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function createRedditBotMcpServer(client = new RedditBotApiClient()): McpServer {
const server = new McpServer({ name: 'reddit-bot', version: '1.0.0' });
const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
const controlAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
const sendAnnotations = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
server.registerTool('get_overview', {
description: 'Get one consolidated snapshot of bot, account, authentication, sync, rate-limit, and recent error state.',
annotations: readAnnotations,
}, async () => result(await client.get('/api/v1/overview')));
server.registerTool('get_readiness', {
description: 'Check whether core application components are initialized and whether account authentication is degraded.',
annotations: readAnnotations,
}, async () => result(await client.get('/api/v1/readyz')));
server.registerTool('list_accounts', {
description: 'List configured Reddit accounts and their runtime state.',
annotations: readAnnotations,
}, async () => result(await client.get('/api/accounts')));
server.registerTool('list_recent_activity', {
description: 'List recent observable bot activity.',
inputSchema: { limit: z.number().int().min(1).max(200).default(50) },
annotations: readAnnotations,
}, async ({ limit }) => {
const activity = await client.get('/api/activity');
return result(Array.isArray(activity) ? activity.slice(0, limit) : activity);
});
server.registerTool('list_conversations', {
description: 'List cached Reddit conversations across running accounts.',
inputSchema: { includeImageMetadata: z.boolean().default(true) },
annotations: readAnnotations,
}, async ({ includeImageMetadata }) => result(await client.get(`/api/conversations?includeImageMetadata=${includeImageMetadata}`)));
server.registerTool('get_conversation', {
description: 'Get one conversation, its messages, owner, triggering post, and sync freshness state.',
inputSchema: { id: z.string().min(1).describe('Owned conversation ID returned by list_conversations') },
annotations: readAnnotations,
}, async ({ id }) => result(await client.get(`/api/conversations/${encodeURIComponent(id)}`)));
server.registerTool('list_recent_errors', {
description: 'List recent structured error logs without streaming the live event feed.',
inputSchema: { limit: z.number().int().min(1).max(100).default(20) },
annotations: readAnnotations,
}, async ({ limit }) => {
const logs = await client.get('/api/logs?limit=500');
const errors = Array.isArray(logs)
? logs.filter((entry) => isRecord(entry) && entry.level === 'error').slice(-limit).reverse()
: logs;
return result(errors);
});
server.registerTool('get_sync_status', {
description: 'Get both chat and media synchronization state.',
annotations: readAnnotations,
}, async () => result({
chat: await client.get('/api/chats/sync'),
media: await client.get('/api/media/sync'),
}));
server.registerTool('get_performance', {
description: 'Get performance by subreddit and strategy plus the requested daily trend.',
inputSchema: { days: z.number().int().min(1).max(365).default(30) },
annotations: readAnnotations,
}, async ({ days }) => result({
bySubreddit: await client.get('/api/analytics/by-subreddit'),
byStrategy: await client.get('/api/analytics/by-strategy'),
funnel: await client.get('/api/analytics/funnel'),
trend: await client.get(`/api/analytics/trend?days=${days}`),
}));
server.registerTool('get_control_status', {
description: 'Get bot start/stop state without changing it.',
annotations: readAnnotations,
}, async () => result(await client.get('/api/v1/control')));
server.registerTool('start_bot', {
description: 'Start bot processing. Fails safely if no Reddit account is fully authenticated.',
annotations: controlAnnotations,
}, async () => result(await client.post('/api/v1/control/start')));
server.registerTool('stop_bot', {
description: 'Stop bot processing gracefully. Calling this when already stopped is safe.',
annotations: controlAnnotations,
}, async () => result(await client.post('/api/v1/control/stop')));
server.registerTool('set_bot_running', {
description: 'Explicitly set the desired bot running state.',
inputSchema: { running: z.boolean() },
annotations: controlAnnotations,
}, async ({ running }) => result(await client.post('/api/v1/control', { running })));
server.registerTool('trigger_sync', {
description: 'Queue a tracked chat or media sync job and return its job ID.',
inputSchema: {
type: z.enum(['chat-sync', 'media-sync']),
kind: z.enum(['delta', 'full']).default('delta'),
accountId: z.string().min(1).optional(),
},
annotations: { ...controlAnnotations, idempotentHint: false },
}, async (input) => result(await client.post('/api/v1/jobs', input)));
server.registerTool('get_job', {
description: 'Get the current state and result of a tracked sync job.',
inputSchema: { id: z.string().uuid() },
annotations: readAnnotations,
}, async ({ id }) => result(await client.get(`/api/v1/jobs/${encodeURIComponent(id)}`)));
server.registerTool('send_reply', {
description: 'Send a message to an existing conversation. This has an external side effect and is not idempotent.',
inputSchema: { conversationId: z.string().min(1), message: z.string().min(1).max(10_000) },
annotations: sendAnnotations,
}, async ({ conversationId, message }) => result(await client.post(
`/api/conversations/${encodeURIComponent(conversationId)}/send-reply`,
{ message },
)));
server.registerTool('start_conversation', {
description: 'Start a new Reddit conversation and send its first message. This has an external side effect and is not idempotent.',
inputSchema: {
accountId: z.string().min(1),
username: z.string().min(1),
message: z.string().min(1).max(10_000),
},
annotations: sendAnnotations,
}, async (input) => result(await client.post('/api/conversations/start', input)));
server.registerResource('openapi', 'redditbot://api/openapi', {
title: 'RedditBot OpenAPI',
description: 'Machine-readable HTTP API definition',
mimeType: 'application/json',
}, async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await client.get('/api/openapi.json'), null, 2) }] }));
server.registerResource('capabilities', 'redditbot://api/capabilities', {
title: 'RedditBot capabilities',
description: 'Operations with read/control/admin and mutation metadata',
mimeType: 'application/json',
}, async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await client.get('/api/v1/capabilities'), null, 2) }] }));
return server;
}
async function main(): Promise<void> {
const server = createRedditBotMcpServer();
await server.connect(new StdioServerTransport());
}
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
main().catch((error: unknown) => {
process.stderr.write(`RedditBot MCP server failed: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}