Skip to main content
Glama
2389-research

MCP Agent Social Media Server

api-client.ts8.83 kB
// ABOUTME: HTTP client for communicating with the external social media API // ABOUTME: Handles authentication, error handling, and typed responses import fetch, { type RequestInit, type Response } from 'node-fetch'; // Type for the fetch function to enable mocking in tests export type FetchFunction = typeof fetch; import { config } from './config.js'; import { logger } from './logger.js'; import { McpAuthenticationError, McpMethodNotFoundError, McpRateLimitError, McpTimeoutError, } from './middleware/error-handler.js'; import type { PostData, PostQueryOptions, PostResponse, PostsResponse } from './types.js'; // Remote API response types interface RemotePost { postId: string; author: string; content: string; tags?: string[]; createdAt?: { _seconds: number; }; parentPostId?: string; } interface RemotePostsResponse { posts: RemotePost[]; totalCount: number; nextOffset?: string; } interface RemotePostResponse { postId: string; author: string; content: string; tags?: string[]; createdAt?: { _seconds: number; }; parentPostId?: string; } export interface IApiClient { fetchPosts(teamName: string, options?: PostQueryOptions): Promise<PostsResponse>; createPost(teamName: string, postData: PostData): Promise<PostResponse>; } export class ApiClient implements IApiClient { private baseUrl: string; private apiKey: string; private timeout: number; private fetchFn: FetchFunction; constructor( baseUrl: string = config.socialApiBaseUrl, apiKey: string = config.socialApiKey, timeout: number = config.apiTimeout, fetchFn: FetchFunction = fetch, ) { this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash this.apiKey = apiKey; this.timeout = timeout; this.fetchFn = fetchFn; logger.debug('ApiClient initialized', { baseUrl: this.baseUrl, timeout: this.timeout, hasApiKey: !!this.apiKey, }); } /** * Fetch posts from the API */ async fetchPosts(teamName: string, options?: PostQueryOptions): Promise<PostsResponse> { const params = new URLSearchParams(); if (options?.limit !== undefined) { params.append('limit', options.limit.toString()); } // Remote API uses cursor-based pagination, not numeric offset // For now, we'll ignore the offset parameter since the remote API doesn't support it // TODO: Implement proper cursor-based pagination mapping // Note: remote API may not support agent/tag filters - these params might be ignored if (options?.agent_filter) { params.append('agent', options.agent_filter); } if (options?.tag_filter) { params.append('tag', options.tag_filter); } if (options?.thread_id) { params.append('thread_id', options.thread_id); } const queryString = params.toString(); const url = `${this.baseUrl}/teams/${encodeURIComponent(teamName)}/posts${ queryString ? `?${queryString}` : '' }`; logger.debug('Fetching posts', { teamName, queryParams: Object.fromEntries(params), url, }); const response = await this.makeRequest('GET', url); const remoteResponse = response as RemotePostsResponse; // Validate remote response if (!remoteResponse.posts || !Array.isArray(remoteResponse.posts)) { throw new Error('Invalid API response: posts array missing or malformed'); } // Adapt remote response to our schema const adaptedPosts = remoteResponse.posts .filter((post: RemotePost) => { if (!post.postId || !post.author || !post.content) { logger.warn('Skipping malformed post', { post }); return false; } return true; }) .map((post: RemotePost) => ({ id: post.postId, author_name: post.author, content: post.content, tags: post.tags || [], timestamp: post.createdAt?._seconds ? new Date(post.createdAt._seconds * 1000).toISOString() : new Date().toISOString(), parent_post_id: post.parentPostId || undefined, team_name: teamName, })); const adaptedResponse: PostsResponse = { posts: adaptedPosts, total: adaptedPosts.length, // Remote API doesn't provide total, estimate from current page has_more: Boolean(remoteResponse.nextOffset), }; return adaptedResponse; } /** * Create a new post */ async createPost(teamName: string, postData: PostData): Promise<PostResponse> { const url = `${this.baseUrl}/teams/${encodeURIComponent(teamName)}/posts`; logger.debug('Creating post', { teamName, url, hasContent: !!postData.content, tagsCount: postData.tags?.length || 0, }); // Adapt to remote API schema - use 'author' instead of 'author_name' const remotePostData = { author: postData.author_name, content: postData.content, tags: postData.tags, parentPostId: postData.parent_post_id, }; const response = await this.makeRequest('POST', url, remotePostData); const remoteResponse = response as RemotePostResponse; // Adapt remote response back to our schema const adaptedResponse: PostResponse = { post: { id: remoteResponse.postId, author_name: remoteResponse.author, content: remoteResponse.content, tags: remoteResponse.tags || [], timestamp: remoteResponse.createdAt?._seconds ? new Date(remoteResponse.createdAt._seconds * 1000).toISOString() : new Date().toISOString(), parent_post_id: remoteResponse.parentPostId || undefined, team_name: teamName, }, }; return adaptedResponse; } /** * Make an HTTP request with error handling and logging */ private async makeRequest(method: string, url: string, body?: unknown): Promise<unknown> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); const startTime = Date.now(); try { const options: RequestInit = { method, headers: { 'x-api-key': this.apiKey, 'Content-Type': 'application/json', Accept: 'application/json', }, signal: controller.signal, }; if (body) { options.body = JSON.stringify(body); } logger.apiRequest(method, url, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, hasBody: !!body, timeout: this.timeout, }); const response = await this.fetchFn(url, options); const duration = Date.now() - startTime; if (!response.ok) { logger.apiResponse(method, url, response.status, duration, { statusText: response.statusText, failed: true, }); throw await this.handleErrorResponse(response); } logger.apiResponse(method, url, response.status, duration); const data = await response.json(); return data; } catch (error) { const duration = Date.now() - startTime; if (error instanceof Error && error.name === 'AbortError') { logger.apiError(method, url, new Error(`Request timeout after ${this.timeout}ms`), { duration, timeout: true, }); throw new McpTimeoutError(`Request timeout after ${this.timeout}ms`, this.timeout); } logger.apiError(method, url, error instanceof Error ? error : new Error(String(error)), { duration, errorType: error instanceof Error ? error.name : 'unknown', }); throw error; } finally { clearTimeout(timeoutId); } } /** * Handle error responses from the API */ private async handleErrorResponse(response: Response): Promise<Error> { let errorMessage = `API request failed: ${response.status} ${response.statusText}`; try { const errorData = (await response.json()) as { error?: string; message?: string; code?: string; }; errorMessage = errorData.error || errorData.message || errorMessage; } catch { // Ignore JSON parse errors } // Error handled, no logging needed for production switch (response.status) { case 401: throw new McpAuthenticationError(`Authentication failed: ${errorMessage}`); case 403: throw new Error(`Access forbidden: ${errorMessage}`); case 404: throw new McpMethodNotFoundError(`Resource not found: ${errorMessage}`); case 429: throw new McpRateLimitError(`Rate limit exceeded: ${errorMessage}`); case 500: case 502: case 503: case 504: throw new Error(`Server error: ${errorMessage}`); default: throw new Error(errorMessage); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/2389-research/mcp-socialmedia'

If you have feedback or need assistance with the MCP directory API, please join our Discord server