feat: add agent API and operational analytics
This commit is contained in:
1 parent
720ea0652a
commit
e8b730b592
65 files changed
+2650
-1214
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user